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 // 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 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 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 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) }