cs2-outnumbered/Outnumbered/Config/DomainConfig.cs
Kamal Tufekcic d701598350
All checks were successful
CI / build (push) Successful in 32s
CI / release (push) Successful in 32s
CI / lint (push) Successful in 30s
initial commit
2026-07-05 13:28:35 +03:00

340 lines
30 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Text.Json.Serialization;
namespace Outnumbered.Config;
// The config sub-types the pure Domain layer reads (handicap / progression / stat / combat / survival economy). Split out
// of OutnumberedConfig.cs — which is CounterStrikeSharp-coupled (BasePluginConfig) — so the xUnit test project can
// <Compile Include> these alongside Domain/*.cs with NO CounterStrikeSharp reference. They're plain POCOs (System.Text.Json
// attributes only); the engine-coupled config (OutnumberedConfig + Hud/Shop/Match/GunGame/... blocks) stays in OutnumberedConfig.cs.
// One killstreak ability's tunables (spec §4). Magnitude meaning is per-ability:
// No Reload — (unused; clip-refill only)
// Adrenaline — Magnitude = % incoming-damage reduction
// Overcharge — Magnitude = % bonus outgoing damage
// Bloodthirst — Magnitude = +% HP lifesteal, Magnitude2 = +% armor lifesteal (additive, for the duration)
// Berserk — Magnitude = bonus damage per full missing-HP fraction, Magnitude2 = bonus crit-damage per missing-HP fraction
public sealed class AbilityDef
{
[JsonPropertyName("StreakReq")] public int StreakReq { get; set; }
[JsonPropertyName("Cooldown")] public float Cooldown { get; set; }
[JsonPropertyName("Duration")] public float Duration { get; set; }
[JsonPropertyName("Magnitude")] public double Magnitude { get; set; }
[JsonPropertyName("Magnitude2")] public double Magnitude2 { get; set; }
// "Movie filter" tint shown while this ability is active (blended when several are up). Spec §7 juice.
[JsonPropertyName("TintR")] public int TintR { get; set; }
[JsonPropertyName("TintG")] public int TintG { get; set; }
[JsonPropertyName("TintB")] public int TintB { get; set; }
}
// The 5 killstreak abilities + baseline infinite-reserve ammo (spec §4/§8).
public sealed class AbilitiesConfig
{
[JsonPropertyName("Enabled")] public bool Enabled { get; set; } = true;
// Active-ability screen tint ("movie filter") via the CS2 fade user message. UNVERIFIED format — toggle off
// if it misbehaves; the HUD active-banner is the reliable fallback. TintAlpha = overlay strength (0-255).
[JsonPropertyName("ScreenTint")] public bool ScreenTint { get; set; } = true;
[JsonPropertyName("TintAlpha")] public int TintAlpha { get; set; } = 50;
// sv_infinite_ammo is cheat-flagged at sv_cheats 0, so reserve is topped up in code (spec §1).
[JsonPropertyName("InfiniteReserve")] public bool InfiniteReserve { get; set; } = true;
[JsonPropertyName("InfiniteReserveAmount")] public int InfiniteReserveAmount { get; set; } = 250;
// Grenade-key input: a grenade-select key (slot6-10) only reaches the server while the player holds
// that grenade. So we GRANT the matching grenade exactly while its ability is castable and REMOVE it on
// use — the key is live only when the ability is, the grenade can never be thrown, and there's no clutter.
[JsonPropertyName("GrenadeKeyInput")] public bool GrenadeKeyInput { get; set; } = true;
// Indexed by ability (0=No Reload, 1=Adrenaline, 2=Overcharge, 3=Bloodthirst, 4=Berserk). CT uses
// weapon_incgrenade (not molotov). Must stay aligned with AbilityForGrenade in Abilities.cs.
[JsonPropertyName("AbilityGrenades")]
public List<string> AbilityGrenades { get; set; } = new()
{ "weapon_hegrenade", "weapon_flashbang", "weapon_smokegrenade", "weapon_incgrenade", "weapon_decoy" };
[JsonPropertyName("NoReload")] public AbilityDef NoReload { get; set; } = new() { StreakReq = 10, Cooldown = 40, Duration = 12, TintR = 255, TintG = 221, TintB = 85 };
[JsonPropertyName("Adrenaline")] public AbilityDef Adrenaline { get; set; } = new() { StreakReq = 20, Cooldown = 50, Duration = 8, Magnitude = 35, TintR = 90, TintG = 150, TintB = 255 };
[JsonPropertyName("Overcharge")] public AbilityDef Overcharge { get; set; } = new() { StreakReq = 30, Cooldown = 60, Duration = 8, Magnitude = 50, TintR = 255, TintG = 140, TintB = 40 };
[JsonPropertyName("Bloodthirst")] public AbilityDef Bloodthirst { get; set; } = new() { StreakReq = 40, Cooldown = 70, Duration = 8, Magnitude = 60, Magnitude2 = 40, TintR = 90, TintG = 220, TintB = 90 };
[JsonPropertyName("Berserk")] public AbilityDef Berserk { get; set; } = new() { StreakReq = 50, Cooldown = 90, Duration = 10, Magnitude = 1.5, Magnitude2 = 1.5, TintR = 255, TintG = 60, TintB = 60 };
}
// The per-player balance handicap (spec §5). A single signed index t in [-1,+1] drives all three
// outputs (deal / take / XP) so they move together and hit their extremes at the SAME thresholds.
// t = N - B. N (nerf, 0..1) = weighted avg of four factors each normalised to its "maxed" threshold;
// B (buff, 0..1) = how far K/D sits below neutral (comeback help).
public sealed class HandicapConfig
{
// These defaults are tuned so the 100-point stat ceiling (~x3 offense) can't outscale the reachable nerf: Kd/Level are
// up-weighted and LevelMaxNerf spans the full point-investment range, keeping "better = harder + more XP, worse =
// easier + less XP" monotonic (otherwise a leveled/statted player inverts the handicap into a net advantage).
[JsonPropertyName("Enabled")] public bool Enabled { get; set; } = true;
// Scales the whole signed deviation from neutral. 0 = off, ~1 = mild, 1.6 = retuned designed value, >2 = brutal.
[JsonPropertyName("MasterDifficulty")] public double MasterDifficulty { get; set; } = 1.6;
// Shapes |t| -> output. 1 = linear; >1 = eases IN (mild leads barely touched, then a hard cliff at the top —
// a wide "god with base stats" band); <1 = bites EARLY (debuff ramps fast the moment you pull ahead, then
// saturates). Lower = punishes a moderate lead sooner/harder. 1.3 keeps a small mild-lead toe.
[JsonPropertyName("Curve")] public double Curve { get; set; } = 1.3;
// Each nerf factor ramps 0->1 from neutral to its threshold; ALL four at threshold -> full nerf (t=1).
[JsonPropertyName("KdNeutral")] public double KdNeutral { get; set; } = 1.5; // K/D break-even (vs-many-bots avg is >1)
[JsonPropertyName("KdMaxNerf")] public double KdMaxNerf { get; set; } = 4.0; // K/D that maxes the K/D factor (4 so realistic 3-4 K/D bites, not just 5+)
[JsonPropertyName("KdMinBuff")] public double KdMinBuff { get; set; } = 0.5; // K/D that maxes the comeback buff
[JsonPropertyName("HsMaxNerf")] public double HsMaxNerf { get; set; } = 0.60; // headshot-kill rate that maxes the HS factor
[JsonPropertyName("HsMinKills")] public int HsMinKills { get; set; } = 5; // ignore HS rate until this many kills
[JsonPropertyName("StreakMaxNerf")] public int StreakMaxNerf { get; set; } = 20; // killstreak that maxes the streak factor
[JsonPropertyName("LevelMaxNerf")] public int LevelMaxNerf { get; set; } = 100; // level that maxes the level factor (100 spans the full point-investment range)
// Relative weights of the factors in the nerf average (normalised by their sum). Kd + Level up-weighted: those are the
// axes a skilled player actually maxes, so winning without farming HS/streak still bites.
[JsonPropertyName("KdWeight")] public double KdWeight { get; set; } = 2.0;
[JsonPropertyName("HsWeight")] public double HsWeight { get; set; } = 1.0;
[JsonPropertyName("StreakWeight")] public double StreakWeight { get; set; } = 1.0;
[JsonPropertyName("LevelWeight")] public double LevelWeight { get; set; } = 1.5;
// Weight of the active mode's "progress" axis (0..1, supplied by the driver). 0 in TDM (no axis). In Gun Game
// it's ladder position, so climbing nerfs you harder and a bot-demotion eases it. Default 0 (off for TDM).
[JsonPropertyName("ProgressWeight")] public double ProgressWeight { get; set; } = 0.0;
// Output bands, linear in t. At t=+1 deal=Floor / take=Ceiling / xp=Ceiling; at t=-1 deal=Ceiling / take=Floor / xp=Floor.
[JsonPropertyName("MDealFloor")] public double MDealFloor { get; set; } = 0.033; // dominant players deal ~3.3% base; the +50% Damage stat lands the effective body-shot floor at ~0.05 (GG/Survival pin this back to 0.1, where the ladder/wave needs a viable deal)
[JsonPropertyName("MDealCeiling")] public double MDealCeiling { get; set; } = 1.3; // strugglers deal up to 130% (compressed: the buff multiplies onto stats)
[JsonPropertyName("MTakeFloor")] public double MTakeFloor { get; set; } = 0.85; // strugglers take down to 85% (compressed comeback band)
[JsonPropertyName("MTakeCeiling")] public double MTakeCeiling { get; set; } = 8.0; // dominant players take 800%
[JsonPropertyName("XpFloor")] public double XpFloor { get; set; } = 0.1; // strugglers climb at 10%
[JsonPropertyName("XpCeiling")] public double XpCeiling { get; set; } = 10.0; // dominant players climb at 1000%
}
// Per-mode handicap override — a nullable mirror of HandicapConfig. The base `Handicap` block is the DEFAULT;
// each mode may carry its own `Handicap` sub-object overriding ONLY the fields it sets, e.g.
// "GunGame": { "Handicap": { "Curve": 1.6, "MTakeCeiling": 5.0 } } — everything else inherits the base. The effective
// config is resolved per active mode (Handicap.cs RebuildEffectiveHandicap). Add new fields here AND in ApplyTo.
public sealed class HandicapOverride
{
// Every field is nullable and WhenWritingNull-ignored: an unset field is OMITTED from the generated JSON entirely
// (rather than emitted as a confusing `null`), so a mode's Handicap block lists ONLY the fields it actually overrides
// and the rest transparently inherit the base block. A missing field still deserializes to null -> inherit.
[JsonPropertyName("Enabled"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public bool? Enabled { get; set; }
[JsonPropertyName("MasterDifficulty"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public double? MasterDifficulty { get; set; }
[JsonPropertyName("Curve"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public double? Curve { get; set; }
[JsonPropertyName("KdNeutral"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public double? KdNeutral { get; set; }
[JsonPropertyName("KdMaxNerf"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public double? KdMaxNerf { get; set; }
[JsonPropertyName("KdMinBuff"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public double? KdMinBuff { get; set; }
[JsonPropertyName("HsMaxNerf"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public double? HsMaxNerf { get; set; }
[JsonPropertyName("HsMinKills"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public int? HsMinKills { get; set; }
[JsonPropertyName("StreakMaxNerf"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public int? StreakMaxNerf { get; set; }
[JsonPropertyName("LevelMaxNerf"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public int? LevelMaxNerf { get; set; }
[JsonPropertyName("KdWeight"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public double? KdWeight { get; set; }
[JsonPropertyName("HsWeight"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public double? HsWeight { get; set; }
[JsonPropertyName("StreakWeight"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public double? StreakWeight { get; set; }
[JsonPropertyName("LevelWeight"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public double? LevelWeight { get; set; }
[JsonPropertyName("ProgressWeight"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public double? ProgressWeight { get; set; }
[JsonPropertyName("MDealFloor"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public double? MDealFloor { get; set; }
[JsonPropertyName("MDealCeiling"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public double? MDealCeiling { get; set; }
[JsonPropertyName("MTakeFloor"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public double? MTakeFloor { get; set; }
[JsonPropertyName("MTakeCeiling"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public double? MTakeCeiling { get; set; }
[JsonPropertyName("XpFloor"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public double? XpFloor { get; set; }
[JsonPropertyName("XpCeiling"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public double? XpCeiling { get; set; }
// Produce a fresh effective config = base with this override's non-null fields applied.
public HandicapConfig ApplyTo(HandicapConfig b) => new()
{
Enabled = Enabled ?? b.Enabled,
MasterDifficulty = MasterDifficulty ?? b.MasterDifficulty,
Curve = Curve ?? b.Curve,
KdNeutral = KdNeutral ?? b.KdNeutral,
KdMaxNerf = KdMaxNerf ?? b.KdMaxNerf,
KdMinBuff = KdMinBuff ?? b.KdMinBuff,
HsMaxNerf = HsMaxNerf ?? b.HsMaxNerf,
HsMinKills = HsMinKills ?? b.HsMinKills,
StreakMaxNerf = StreakMaxNerf ?? b.StreakMaxNerf,
LevelMaxNerf = LevelMaxNerf ?? b.LevelMaxNerf,
KdWeight = KdWeight ?? b.KdWeight,
HsWeight = HsWeight ?? b.HsWeight,
StreakWeight = StreakWeight ?? b.StreakWeight,
LevelWeight = LevelWeight ?? b.LevelWeight,
ProgressWeight = ProgressWeight ?? b.ProgressWeight,
MDealFloor = MDealFloor ?? b.MDealFloor,
MDealCeiling = MDealCeiling ?? b.MDealCeiling,
MTakeFloor = MTakeFloor ?? b.MTakeFloor,
MTakeCeiling = MTakeCeiling ?? b.MTakeCeiling,
XpFloor = XpFloor ?? b.XpFloor,
XpCeiling = XpCeiling ?? b.XpCeiling,
};
}
// XP / level / prestige economy (spec §2). Curve: xp to go from level L to L+1
// = LevelXpBase + LevelXpStep * (L-1)^LevelXpExponent. Exponent 1.0 = linear; >1 = progressive (accelerating).
public sealed class ProgressionConfig
{
// XP is earned per-hit on damage to bots (not a flat per-kill lump): final HP damage × rate, plus flat
// bonuses for headshots/crits/the kill itself. Each component is run through the same XP multipliers
// (Xp-Boost stat × prestige × handicap) at grant time. A clean 100-HP bodyshot kill = 100×Rate + KillBonus.
[JsonPropertyName("DamageXpPerHp")] public double DamageXpPerHp { get; set; } = 0.15; // XP per point of final HP damage
[JsonPropertyName("KillXpBonus")] public double KillXpBonus { get; set; } = 10; // flat, on the lethal blow (0 = kills give no XP)
[JsonPropertyName("HeadshotXpBonus")] public double HeadshotXpBonus { get; set; } = 10; // flat, per headshot hit
[JsonPropertyName("CritXpBonus")] public double CritXpBonus { get; set; } = 10; // flat, per crit hit
[JsonPropertyName("LevelXpBase")] public long LevelXpBase { get; set; } = 100; // flat cost of level 1 -> 2
[JsonPropertyName("LevelXpStep")] public long LevelXpStep { get; set; } = 8; // coefficient on the curve term
[JsonPropertyName("LevelXpExponent")] public double LevelXpExponent { get; set; } = 1.7; // curve steepness (more convex = gentler low, steeper high)
[JsonPropertyName("LevelCap")] public int LevelCap { get; set; } = 100;
[JsonPropertyName("PrestigeCap")] public int PrestigeCap { get; set; } = 10;
[JsonPropertyName("PrestigeXpBoostPercent")] public double PrestigeXpBoostPercent { get; set; } = 10; // +% XP per prestige (25 let prestige override the struggler XP penalty)
}
// One survival draft card: a run-scoped buff that maps onto an existing stat key (so EffRun stacks it on top of the
// permanent stat, "everything stacks") with a per-pick magnitude in Eff units + a pick cap. Cards are STRONG on
// purpose — they must be able to outscale the escalating handicap floor. Bias the magnitudes UP and tune live.
public sealed class SurvivalCardDef
{
[JsonPropertyName("Key")] public string Key { get; set; } = ""; // a StatKeys.* value (damage, crit_damage, max_hp, ...) or a CardKeys.* effect key
[JsonPropertyName("Name")] public string Name { get; set; } = ""; // shown on the draft card
[JsonPropertyName("PerPick")] public double PerPick { get; set; } // added to Eff per pick (Damage 100 = +100% = x2/pick on top of base)
[JsonPropertyName("Cap")] public int Cap { get; set; } = 3; // max times this card can be drafted in a run
// ---- draft DISPLAY/COUNTING metadata (data-driven so a new card doesn't need code edits in Survival's switches) ----
[JsonPropertyName("IsTeam")] public bool IsTeam { get; set; } // squad-wide team card (shared level via MDeal/MTake) vs per-player
[JsonPropertyName("Flat")] public bool Flat { get; set; } // the draft shows "+N" flat points (HP/armor caps, flat regen) instead of "+N%"
// Optional draft detail line for EFFECT cards where a raw +N% would mislead. A string.Format template; {0} = the
// value after the next pick (level x PerPick). Empty -> the card shows the standard Now%->Next% panel. (The compounding
// team cards + Burn carry their own computed detail in code; everything else lives here as data.)
[JsonPropertyName("Detail")] public string Detail { get; set; } = "";
public SurvivalCardDef() { }
public SurvivalCardDef(string key, string name, double perPick, int cap, bool isTeam = false, bool flat = false, string detail = "")
{ Key = key; Name = name; PerPick = perPick; Cap = cap; IsTeam = isTeam; Flat = flat; Detail = detail; }
}
// Wave Survival mode (Mode="survival", see Survival.cs). Co-op escalating bot waves on the small arms-race maps.
// VANILLA bots (never tougher) — escalation is MORE bots + the inherited handicap floor tightening per wave. The
// roguelite DRAFT (strong run-scoped cards) is the counter-pressure; you outscale until the floor + horde win.
// Run-XP is a SEPARATE accumulator converted to main XP once at run-end. All values live-tunable via !og_reload.
public sealed class SurvivalConfig
{
// ---- bot population (vanilla HP; only the COUNT escalates) ----
[JsonPropertyName("AliveCap")] public int AliveCap { get; set; } = 20; // hard ceiling on bots alive at once (CPU lever; 20 = BotsPerHuman 5 x 4 conceptually)
// Bots ALIVE at once this wave = AliveBase + AlivePerWave*(wave-1), clamped to [1, AliveCap]. Separate from the kill
// budget: you face this many simultaneously (they respawn as you kill) until the wave's total kills are reached.
[JsonPropertyName("AliveBase")] public int AliveBase { get; set; } = 4; // alive at once on wave 1 (gentle start)
[JsonPropertyName("AlivePerWave")] public int AlivePerWave { get; set; } = 2; // +alive/wave -> hits AliveCap around wave 9
[JsonPropertyName("MaxHumansOnCt")] public int MaxHumansOnCt { get; set; } = 5; // co-op cap (EnforceHumanTeam)
// Total kills to clear a wave = BudgetBase + BudgetPerWave*(wave-1) + BudgetPerPlayer*aliveHumansAtWaveStart.
[JsonPropertyName("BudgetBase")] public int BudgetBase { get; set; } = 6;
[JsonPropertyName("BudgetPerWave")] public int BudgetPerWave { get; set; } = 4;
[JsonPropertyName("BudgetPerPlayer")] public int BudgetPerPlayer { get; set; } = 3;
// ---- wave structure ----
[JsonPropertyName("WaveCount")] public int WaveCount { get; set; } = 20; // finite campaign; clearing this = WIN. Tune by feel + XP-vs-TDM/GG.
[JsonPropertyName("WaveBreakSeconds")] public float WaveBreakSeconds { get; set; } = 15f;
[JsonPropertyName("WaveTimeoutSeconds")] public float WaveTimeoutSeconds { get; set; } = 240f; // no kill for this long (despite nudges) = genuine deadlock -> fail the run (measures time since last KILL, not wall-time)
[JsonPropertyName("StallNudgeSeconds")] public float StallNudgeSeconds { get; set; } = 20f; // no kill for this long -> teleport the straggler bots to the squad (so the wave never stalls/ends with bots alive)
[JsonPropertyName("StartGraceSeconds")] public float StartGraceSeconds { get; set; } = 6f; // wait this long after a (re)spawn-able map before starting wave 1
// ---- death / revive ----
[JsonPropertyName("ReviveOnWaveClear")] public bool ReviveOnWaveClear { get; set; } = true; // false = hardcore "you die, you spectate the rest of the run"
[JsonPropertyName("ReviveHpPercent")] public double ReviveHpPercent { get; set; } = 0.5;
// ---- XP economy (granted PER WAVE at each wave clear, NOT once at run-end). Each cleared wave grants:
// rawWaveXp (HP-damage + HS/crit, handicap-mult EXCLUDED) x prestige x waveMult(wave).
// WinMult is the ONLY knob: the FINAL-wave / "win" multiplier. The per-wave multiplier ramps exponentially from x1
// (wave 1) to xWinMult (wave WaveCount): waveMult(w) = WinMult^((w-1)/(WaveCount-1)) — early waves pay ~nothing, the
// last few pay big. NO per-run cap (depth is the gate: you must survive + contribute to reach the big back multipliers;
// an in-progress wave is forfeited on a wipe). Sized so a top CONTRIBUTOR (not a carried player) can climb toward max
// level over a DEEP run; tune with research/handicap-balance-calc.py --survival-wave. Default fits the ~50-wave
// production target — at the current WaveCount it pays proportionally less (re-solve when you raise WaveCount). ----
[JsonPropertyName("WinMult")] public double WinMult { get; set; } = 24.0;
// ---- the draft ----
[JsonPropertyName("DraftSize")] public int DraftSize { get; set; } = 3; // cards offered per pick
[JsonPropertyName("CardsPerWave")] public int CardsPerWave { get; set; } = 1; // picks granted on each wave clear
// ---- effect-card tunables (the new logic cards; all live-tunable) ----
[JsonPropertyName("BurnDamagePerSecond")] public double BurnDamagePerSecond { get; set; } = 3; // flat HP/s the Burn card deals (armor-skipping). Different attackers STACK; a re-hit just refreshes your own.
[JsonPropertyName("BurnDurationSeconds")] public float BurnDurationSeconds { get; set; } = 5f; // each hit (re)applies this much burn time
[JsonPropertyName("BurnTickSeconds")] public float BurnTickSeconds { get; set; } = 1f; // burn damage cadence (DPS held at BurnDamagePerSecond; changing this needs a reload to re-arm the timer)
[JsonPropertyName("ExplodeBaseDamage")] public double ExplodeBaseDamage { get; set; } = 100; // Explode-on-Kill HE base damage — then scaled UP by the killer's damage build + handicap on detonation
[JsonPropertyName("ExplodeRadius")] public double ExplodeRadius { get; set; } = 350; // Explode-on-Kill HE blast radius (engine distance falloff applies before the scaling)
[JsonPropertyName("ExplodeFuseSeconds")] public float ExplodeFuseSeconds { get; set; } = 0.1f; // delay before the corpse grenade detonates (0.1 = near-instant; bump for a telegraphed beep-then-boom)
// ---- handicap escalation ----
// The wave floor reaches max nerf (t=1) at MaxNerfWave (feel-tuned; cards must be able to outscale it). Monotonic.
[JsonPropertyName("MaxNerfWave")] public int MaxNerfWave { get; set; } = 25;
// Full per-mode handicap override (all bands tunable). Seeded with a higher MTakeCeiling than base (10x) because
// cards + permanent stats stack — a maxed-HP/lifesteal/regen/thorns build can be unkillable at 8x. Tune live.
// In survival the WAVE FLOOR is the difficulty driver, so the inherited factors are heavily down-weighted —
// otherwise a high-level player is pre-nerfed to near-max-take on wave 1 (level/KD maxing the factor before the
// floor even ramps). Low weights keep a mild skill component; the floor (-> max nerf at MaxNerfWave) does the work.
[JsonPropertyName("Handicap")]
public HandicapOverride? Handicap { get; set; } = new()
{
ProgressWeight = 0.0,
MTakeCeiling = 10.0,
KdWeight = 0.3,
HsWeight = 0.3,
StreakWeight = 0.3,
LevelWeight = 0.1,
MDealFloor = 0.1, // pin: the base dropped to 0.033 for TDM, but waves need a viable deal to be clearable
};
// Map pool (share the GG arms-race maps). EMPTY -> falls back to Match.Maps.
[JsonPropertyName("Maps")] public List<string> Maps { get; set; } = new();
// ---- the card catalog (each maps to a StatKeys.* so EffRun stacks it; STRONG on purpose, tune live) ----
[JsonPropertyName("Cards")]
public List<SurvivalCardDef> Cards { get; set; } = new()
{
new("damage", "+Damage", 100, 3), // +100%/pick -> x4 dmg from cards alone, maxed
new("hs_damage", "+Headshot Dmg", 50, 3), // +50%/pick -> +150% maxed
new("crit_chance", "+Crit Chance", 15, 3),// +15%/pick -> +45% maxed
new("crit_damage", "+Crit Dmg", 83, 3), // +83%/pick -> ~+250% maxed
new("max_hp", "+Max HP", 40, 3, flat: true), // +40 HP/pick -> +120 maxed (flat points, not %)
new("max_armor", "+Max Armor", 40, 3, flat: true),
new("lifesteal", "+Lifesteal", 8, 3), // +8%/pick -> +24% maxed
new("hp_regen", "+HP Regen", 2, 3, flat: true), // FLAT +2 HP/s/pick -> +6 HP/s maxed (stacks on the always-on regen stat)
// ---- effect-logic cards (NEW keys, NOT StatKeys; the effect hooks read them via StatBonus, never folded into EffRun) ----
// 1-pick (Cap 1): a single draft fully unlocks the effect; PerPick on the two FLAG cards is just a presence marker
// (their magnitude comes from the Burn*/Explode* knobs above), the other two carry their whole value in one pick.
// Detail = the draft line ({0} = level x PerPick); Burn + the two team cards carry computed detail in code.
new("explode_kill", "Explode on Kill", 1, 1, detail: "HE blast on kill"), // kill a bot -> real HE blast on the corpse (scales with your dmg build); chains freely
new("burn", "Burn", 1, 1), // every hit ignites the bot: flat BurnDamagePerSecond HP/s, armor-skipping, per-attacker stacking
new("ability_cdr", "-Ability Cooldown", 30, 1, detail: "-{0:0}% ability cooldown"),
new("xp_mult", "+Run XP", 50, 1, detail: "+{0:0}% run XP"),
// Leveled (Cap 3): per-player, scale per pick.
new("hs_reduction", "Headshot Armor", 15, 3, detail: "-{0:0}% headshot dmg"), // armor doesn't help vs HS; this does
new("berserk_passive", "Berserker", 40, 3, detail: "+{0:0}% dmg near death"), // scaled by missing HP (always-on, no streak gate)
// Leveled (Cap 3) but TEAM-WIDE: one shared squad level (max 3), compounding, applied to EVERY survivor via MDeal/MTake.
new("global_deal", "Team: +Damage", 10, 3, isTeam: true), // +10%/level ALL outgoing dmg, compounding -> x1.331 squad-wide maxed
new("global_take", "Team: -Damage Taken", 10, 3, isTeam: true),// -10%/level ALL incoming dmg, compounding -> x0.729 squad-wide maxed
};
}
// One tunable stat: Base value at 0 invested levels + PerLevel increment, up to MaxLevel.
public sealed class StatDef
{
[JsonPropertyName("MaxLevel")] public int MaxLevel { get; set; }
[JsonPropertyName("PerLevel")] public double PerLevel { get; set; }
[JsonPropertyName("Base")] public double Base { get; set; }
public StatDef() { }
public StatDef(int maxLevel, double perLevel, double @base = 0) { MaxLevel = maxLevel; PerLevel = perLevel; Base = @base; }
}
// The passive stat tree (spec §3). All values tunable; max levels sum to 100. Pure POCO (StatDef + primitives) so it lives
// here with the other Domain-read configs (the resolvers index it via _statDefs) — CSSharp-free, test-shareable.
public sealed class StatsConfig
{
[JsonPropertyName("Damage")] public StatDef Damage { get; set; } = new(5, 10); // +10% dmg / lvl -> +50% (x1.5) maxed
[JsonPropertyName("CritChance")] public StatDef CritChance { get; set; } = new(20, 2.5); // +2.5% chance / lvl -> 50% maxed (slow ramp, de-rushed)
[JsonPropertyName("CritDamage")] public StatDef CritDamage { get; set; } = new(10, 10, 100); // base +100% (x2), +10%/lvl -> +200% (x3) maxed
[JsonPropertyName("HeadshotDamage")] public StatDef HeadshotDamage { get; set; } = new(5, 10); // +10% / lvl -> +50% (x1.5) on top of engine 4x
[JsonPropertyName("MaxHp")] public StatDef MaxHp { get; set; } = new(10, 25); // +25 hp / lvl (over base 100)
[JsonPropertyName("MaxArmor")] public StatDef MaxArmor { get; set; } = new(10, 25);
[JsonPropertyName("Lifesteal")] public StatDef Lifesteal { get; set; } = new(10, 2.5); // +2.5% of dmg as HP / lvl
[JsonPropertyName("ArmorLifesteal")] public StatDef ArmorLifesteal { get; set; } = new(10, 2);
[JsonPropertyName("HpRegen")] public StatDef HpRegen { get; set; } = new(5, 1); // FLAT +1 HP / regen-tick(=1s) / lvl -> 5 HP/s maxed. ALWAYS ON (no out-of-combat gate).
[JsonPropertyName("ArmorRegen")] public StatDef ArmorRegen { get; set; } = new(5, 1); // FLAT +1 armor / regen-tick(=1s) / lvl. ALWAYS ON.
[JsonPropertyName("Thorns")] public StatDef Thorns { get; set; } = new(5, 2); // reflect +2% of damage taken / lvl -> 10% maxed (dealt FLAT back at the bot — a straight % of the hit you took, no build/handicap scaling; hard-capped 25 HP/hit; OFF while a knife/zeus is held so a GG melee finale needs a real kill)
[JsonPropertyName("XpBoost")] public StatDef XpBoost { get; set; } = new(5, 20); // +20% / lvl -> +100% (x2) maxed
[JsonPropertyName("RegenIntervalSeconds")] public float RegenIntervalSeconds { get; set; } = 1f;
[JsonPropertyName("RegenNoDamageDelaySeconds")] public float RegenNoDamageDelaySeconds { get; set; } = 5f; // DEPRECATED — regen is now ALWAYS ON (flat). Kept so existing JSON doesn't error; no longer read.
[JsonPropertyName("CritLifestealMultiplier")] public double CritLifestealMultiplier { get; set; } = 1.5; // crit hits steal +50%
[JsonPropertyName("LifestealMinHeal")] public int LifestealMinHeal { get; set; } = 2; // floor on a lifesteal heal (HP)
}