340 lines
30 KiB
C#
340 lines
30 KiB
C#
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)
|
||
}
|