initial commit
All checks were successful
CI / build (push) Successful in 32s
CI / release (push) Successful in 32s
CI / lint (push) Successful in 30s

This commit is contained in:
Kamal Tufekcic 2026-07-05 13:28:35 +03:00
commit d701598350
67 changed files with 9351 additions and 0 deletions

View file

@ -0,0 +1,340 @@
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)
}

View file

@ -0,0 +1,360 @@
using System.Text.Json.Serialization;
using CounterStrikeSharp.API.Core;
namespace Outnumbered.Config;
// Auto-loaded from configs/plugins/outnumbered/outnumbered.json on first load.
// NOTE: the config sub-types the pure Domain layer reads (AbilityDef/AbilitiesConfig/HandicapConfig/HandicapOverride/
// ProgressionConfig/SurvivalCardDef/SurvivalConfig/StatDef) live in Config/DomainConfig.cs — split out so they're
// CounterStrikeSharp-free and the test project can compile them with Domain/. This file holds OutnumberedConfig (which
// extends the CSSharp BasePluginConfig) + the engine-coupled / presentation blocks.
public sealed class OutnumberedConfig : BasePluginConfig
{
// Which match driver runs this instance: "tdm" (default), "gungame", or "survival" (Driver.NormalizeMode also accepts
// 0/1/2 and aliases gg/wave). A per-instance launch decision — the whole RPG core is shared; only the match ruleset
// changes. Source of truth for the accepted values: Driver.ModeRegistry + NormalizeMode. (Mode-driver split, see Modes.cs.)
[JsonPropertyName("Mode")]
public string Mode { get; set; } = "tdm";
[JsonPropertyName("Database")]
public DatabaseConfig Database { get; set; } = new();
[JsonPropertyName("Api")]
public ApiConfig Api { get; set; } = new();
[JsonPropertyName("Website")]
public WebsiteConfig Website { get; set; } = new();
[JsonPropertyName("Match")]
public MatchConfig Match { get; set; } = new();
[JsonPropertyName("GunGame")]
public GunGameConfig GunGame { get; set; } = new();
[JsonPropertyName("Survival")]
public SurvivalConfig Survival { get; set; } = new();
[JsonPropertyName("Stats")]
public StatsConfig Stats { get; set; } = new();
[JsonPropertyName("Progression")]
public ProgressionConfig Progression { get; set; } = new();
[JsonPropertyName("Handicap")]
public HandicapConfig Handicap { get; set; } = new();
[JsonPropertyName("Abilities")]
public AbilitiesConfig Abilities { get; set; } = new();
[JsonPropertyName("Hud")]
public HudConfig Hud { get; set; } = new();
[JsonPropertyName("Shop")]
public ShopConfig Shop { get; set; } = new();
[JsonPropertyName("Sounds")]
public SoundsConfig Sounds { get; set; } = new();
[JsonPropertyName("FlushIntervalSeconds")]
public float FlushIntervalSeconds { get; set; } = 90f;
}
// Per-player sound cues (spec §7), played via the client `play` command. Paths are built-in CS2 sounds
// (same style GG2 uses). Empty string = no cue. Swap freely; `play <path>` resolves under csgo/.
public sealed class SoundsConfig
{
[JsonPropertyName("Enabled")] public bool Enabled { get; set; } = true;
[JsonPropertyName("LevelUp")] public string LevelUp { get; set; } = "sounds/ui/armsrace_level_up.wav";
[JsonPropertyName("Prestige")] public string Prestige { get; set; } = "sounds/ui/armsrace_level_up.wav";
[JsonPropertyName("AbilityReady")] public string AbilityReady { get; set; } = "sounds/ambient/office/tech_oneshot_08.wav";
[JsonPropertyName("AbilityActivate")] public string AbilityActivate { get; set; } = "sounds/training/pointscored.wav";
[JsonPropertyName("Crit")] public string Crit { get; set; } = ""; // off by default — per-crit can get spammy
}
// The local read-only status/balance/top API, served over a per-instance Unix domain socket (Api.cs). The companion
// website is the consumer; there is no network exposure — the socket is a filesystem path, access = file permissions.
public sealed class ApiConfig
{
[JsonPropertyName("Enabled")] public bool Enabled { get; set; } = true;
// Directory holding the per-instance sockets (<SocketDir>/<ServerId>.sock). Created externally (systemd tmpfiles.d);
// when it's missing or unwritable the API disables itself with one log line instead of failing the plugin load.
[JsonPropertyName("SocketDir")] public string SocketDir { get; set; } = "/run/outnumbered";
}
// The companion-website plug: a periodic chat line + an !about line pointing players at the site. Url empty = the
// whole feature is OFF (the default — an operator without a site gets no dangling advert). Url is read live per
// announce (!og_reload applies); changing the interval needs a plugin reload to re-arm the timer.
public sealed class WebsiteConfig
{
[JsonPropertyName("Url")] public string Url { get; set; } = "";
[JsonPropertyName("AnnounceIntervalSeconds")] public float AnnounceIntervalSeconds { get; set; } = 300f;
}
// The always-on HUD (spec §7): handicap multipliers + streak + the 5 ability states.
public sealed class HudConfig
{
[JsonPropertyName("Enabled")] public bool Enabled { get; set; } = true;
// "center" = center HTML panel (crisp, multi-color, 2D, no lag — fixed upper-center + width). DEFAULT.
// "world" = CPointWorldText entity (positionable/wide/sized, but single-color, 3D parallax, lags on movement
// since the viewmodel isn't accessible to parent to — kept as an option, not recommended).
[JsonPropertyName("Mode")] public string Mode { get; set; } = "center";
// Refresh cadence in ticks. Center HTML fades only after ~5s, so it does NOT need per-tick resends: 4 (=16 Hz at
// 64 tick) keeps countdowns smooth at a quarter of the per-human message + string-build cost of 1. Every refresh
// sends a reliable usermessage per human, so this dial is also the HUD's client/network footprint.
[JsonPropertyName("RefreshEveryTicks")] public int RefreshEveryTicks { get; set; } = 4;
// Ability icons for the center HUD (indexed 0..4: No Reload, Adrenaline, Overcharge, Bloodthirst, Berserk).
// Glyph rendering depends on the client font — if any show as a box, swap it here and `css_plugins reload`.
// Defaults kept in BMP symbol blocks that render on the CS2 panel font (astral emoji like 🔥💧 show as boxes).
[JsonPropertyName("AbilityIcons")]
public List<string> AbilityIcons { get; set; } = new()
{ "∞", "⛨", "⇈", "♥", "☠" };
// ---- world-text placement — LIVE-TUNABLE: edit JSON, then `css_plugins reload outnumbered` (no rebuild) ----
[JsonPropertyName("ForwardOffset")] public float ForwardOffset { get; set; } = 7f; // units in front of the eye (keep small so walls don't clip it)
[JsonPropertyName("RightOffset")] public float RightOffset { get; set; } = 0f; // + = right, - = left
[JsonPropertyName("UpOffset")] public float UpOffset { get; set; } = -2.6f; // - = lower on screen
[JsonPropertyName("FontSize")] public float FontSize { get; set; } = 26f;
[JsonPropertyName("WorldUnitsPerPx")] public float WorldUnitsPerPx { get; set; } = 0.0075f; // smaller = smaller/finer text
[JsonPropertyName("FontName")] public string FontName { get; set; } = "Arial Bold";
[JsonPropertyName("DrawBackground")] public bool DrawBackground { get; set; } = true;
[JsonPropertyName("ColorR")] public int ColorR { get; set; } = 255;
[JsonPropertyName("ColorG")] public int ColorG { get; set; } = 255;
[JsonPropertyName("ColorB")] public int ColorB { get; set; } = 255;
}
// The quick-buy shop world-text panel. Opened by switching to the healthshot (X), frozen while open.
// Placement is LIVE-TUNABLE: edit JSON, then `css_plugins reload outnumbered` (no rebuild) to reposition.
public sealed class ShopConfig
{
[JsonPropertyName("Enabled")] public bool Enabled { get; set; } = true;
[JsonPropertyName("ForwardOffset")] public float ForwardOffset { get; set; } = 7f; // units in front of the eye
[JsonPropertyName("RightOffset")] public float RightOffset { get; set; } = 0f; // centre anchor: + = right, - = left
[JsonPropertyName("SplitOffset")] public float SplitOffset { get; set; } = 3f; // half-gap: left panel sits -this, right panel +this
[JsonPropertyName("CardSpread")] public float CardSpread { get; set; } = 6f; // survival draft: horizontal gap between the 3 cards (centre card at RightOffset)
[JsonPropertyName("UpOffset")] public float UpOffset { get; set; } = 0f; // + = higher, - = lower
[JsonPropertyName("FontSize")] public float FontSize { get; set; } = 40f;
[JsonPropertyName("WorldUnitsPerPx")] public float WorldUnitsPerPx { get; set; } = 0.011f; // smaller = finer
[JsonPropertyName("InfoFontSize")] public float InfoFontSize { get; set; } = 34f; // right (info) panel: smaller than shop, but readable
[JsonPropertyName("InfoWorldUnitsPerPx")] public float InfoWorldUnitsPerPx { get; set; } = 0.0085f;
// Server/about text shown atop the info panel (the !info / !about blurb) — edit per-server. Keep lines SHORT
// (this panel is narrow/tall by design); long lines run off-screen at lower resolutions.
[JsonPropertyName("InfoLines")]
public List<string> InfoLines { get; set; } = new()
{
"=== OUTNUMBERED ===",
"Kill bots -> XP -> level.",
"Spend points in Skills.",
"L100: !prestige resets you",
"for a permanent boost.",
"Streaks unlock abilities",
"(grenade keys 6-0).",
"Handicap scales bots",
"to your power.",
"",
};
[JsonPropertyName("FontName")] public string FontName { get; set; } = "Arial Bold";
[JsonPropertyName("DrawBackground")] public bool DrawBackground { get; set; } = true;
[JsonPropertyName("ColorR")] public int ColorR { get; set; } = 255;
[JsonPropertyName("ColorG")] public int ColorG { get; set; } = 255;
[JsonPropertyName("ColorB")] public int ColorB { get; set; } = 255;
}
// Separate file outnumbered.ranks.json (NOT auto-loaded by CSSharp — loaded manually, see Ranks.cs). Spec §9.
public sealed class RanksConfig
{
[JsonPropertyName("Enabled")] public bool Enabled { get; set; } = true;
[JsonPropertyName("ShowClanTag")] public bool ShowClanTag { get; set; } = true; // scoreboard tag via m_szClan
[JsonPropertyName("ChatTag")] public bool ChatTag { get; set; } = true; // coloured rank tag in chat (reformats chat)
// Prestige tag colouring: I-V = the unlocked perk's tint; VI-VII 2-colour, VIII-IX 3-colour, X all-5 (HUD animated, chat static).
[JsonPropertyName("PrestigeColors")] public bool PrestigeColors { get; set; } = true;
[JsonPropertyName("TopCount")] public int TopCount { get; set; } = 10; // !top size
// {0} = Roman prestige; only shown when prestige > 0. e.g. "P {0}" -> "P III".
[JsonPropertyName("PrestigeTagFormat")] public string PrestigeTagFormat { get; set; } = "P{0}";
// Highest MinLevel that is <= the player's level wins. Levels are within a prestige (cap 100).
[JsonPropertyName("LevelRanks")]
public List<RankTier> LevelRanks { get; set; } = new()
{
new(1, "Recruit"), new(26, "Soldier"), new(51, "Veteran"), new(76, "Elite"), new(100, "Apex"),
};
}
public sealed class RankTier
{
[JsonPropertyName("MinLevel")] public int MinLevel { get; set; }
[JsonPropertyName("Name")] public string Name { get; set; } = "";
public RankTier() { }
public RankTier(int minLevel, string name) { MinLevel = minLevel; Name = name; }
}
public sealed class DatabaseConfig
{
// "sqlite" for dev; "postgres" is the final-step swap (no logic change — repository interface).
[JsonPropertyName("Provider")]
public string Provider { get; set; } = "sqlite";
// Relative to csgo/ . Defaults next to the plugin's own config.
[JsonPropertyName("SqliteFile")]
public string SqliteFile { get; set; } = "addons/counterstrikesharp/configs/plugins/outnumbered/outnumbered.db";
// Unused while Provider == "sqlite"; filled in for the Postgres phase.
[JsonPropertyName("PostgresConnectionString")]
public string PostgresConnectionString { get; set; } = "Host=localhost;Port=5432;Database=outnumbered;Username=outnumbered;Password=";
}
// The TDM match driver (spec §1). Humans on CT vs bots on T, continuous respawn, no objectives.
public sealed class MatchConfig
{
[JsonPropertyName("BotsPerHuman")] public int BotsPerHuman { get; set; } = 4;
[JsonPropertyName("MaxBots")] public int MaxBots { get; set; } = 12;
[JsonPropertyName("MaxHumansOnCt")] public int MaxHumansOnCt { get; set; } = 3;
[JsonPropertyName("RespawnDelaySeconds")] public float RespawnDelaySeconds { get; set; } = 1.0f; // DEPRECATED — native DM respawn handles timing; no longer read (kept so existing JSON doesn't error).
// 5s so you can spend skill points on respawn; it breaks the instant you fire, so it's not abusable (PvE anyway).
[JsonPropertyName("SpawnProtectionSeconds")] public float SpawnProtectionSeconds { get; set; } = 5.0f;
// Map ends when ANY single player reaches this many kills (spec §1).
[JsonPropertyName("KillGoal")] public int KillGoal { get; set; } = 250;
[JsonPropertyName("MapChangeDelaySeconds")] public float MapChangeDelaySeconds { get; set; } = 6.0f;
[JsonPropertyName("Maps")]
public List<string> Maps { get; set; } = new()
{ "cs_italy", "de_dust2", "de_vertigo", "de_nuke", "de_inferno" };
// bot_difficulty caps at 4 on this build (+ custom_bot_difficulty) — cookbook.
[JsonPropertyName("BotDifficulty")] public int BotDifficulty { get; set; } = 4;
// Base loadout = the only weapons free from level 1; everything else is level-gated (WeaponUnlockLevel).
// Prestige resets level → re-locks everything back to these. Persisted per-player choice falls back here when locked.
[JsonPropertyName("HumanPrimary")] public string HumanPrimary { get; set; } = "weapon_mp9";
[JsonPropertyName("HumanSecondary")] public string HumanSecondary { get; set; } = "weapon_usp_silencer";
// weapon -> level required to select it (absent/0 = free). Weak/cheap early, the cheese (AK/AWP/Negev/autos) late.
[JsonPropertyName("WeaponUnlockLevel")]
public Dictionary<string, int> WeaponUnlockLevel { get; set; } = new()
{
["weapon_glock"] = 2,
["weapon_p250"] = 4,
["weapon_mac10"] = 6,
["weapon_nova"] = 8,
["weapon_hkp2000"] = 10,
["weapon_bizon"] = 12,
["weapon_mp7"] = 14,
["weapon_galilar"] = 16,
["weapon_sawedoff"] = 18,
["weapon_elite"] = 20,
["weapon_famas"] = 22,
["weapon_ump45"] = 24,
["weapon_fiveseven"] = 27,
["weapon_tec9"] = 30,
["weapon_mp5sd"] = 33,
["weapon_mag7"] = 36,
["weapon_ssg08"] = 39,
["weapon_p90"] = 42,
["weapon_aug"] = 45,
["weapon_cz75a"] = 48,
["weapon_xm1014"] = 51,
["weapon_sg556"] = 55,
["weapon_deagle"] = 59,
["weapon_revolver"] = 63,
["weapon_m4a1"] = 67,
["weapon_m4a1_silencer"] = 71,
["weapon_ak47"] = 75,
["weapon_m249"] = 80,
["weapon_scar20"] = 85,
["weapon_g3sg1"] = 90,
["weapon_negev"] = 95,
["weapon_awp"] = 100,
};
// Free-selection weapon menu (!guns), split into categories (!rifles/!smgs/!snipers/!shotguns/!heavy/!pistols).
// Edit to taste; entries must be valid weapon_ item names. "Pistols" are the secondary slot; the rest are primary.
[JsonPropertyName("Rifles")]
public List<string> Rifles { get; set; } = new()
{ "weapon_ak47", "weapon_m4a1_silencer", "weapon_m4a1", "weapon_aug", "weapon_sg556", "weapon_galilar", "weapon_famas" };
[JsonPropertyName("Smgs")]
public List<string> Smgs { get; set; } = new()
{ "weapon_mp9", "weapon_mp7", "weapon_mp5sd", "weapon_ump45", "weapon_p90", "weapon_mac10", "weapon_bizon" };
[JsonPropertyName("Snipers")]
public List<string> Snipers { get; set; } = new()
{ "weapon_awp", "weapon_ssg08", "weapon_scar20", "weapon_g3sg1" };
[JsonPropertyName("Shotguns")]
public List<string> Shotguns { get; set; } = new()
{ "weapon_nova", "weapon_xm1014", "weapon_mag7", "weapon_sawedoff" };
[JsonPropertyName("Heavy")]
public List<string> Heavy { get; set; } = new()
{ "weapon_m249", "weapon_negev" };
[JsonPropertyName("Pistols")]
public List<string> Pistols { get; set; } = new()
{ "weapon_deagle", "weapon_revolver", "weapon_glock", "weapon_usp_silencer", "weapon_hkp2000",
"weapon_p250", "weapon_fiveseven", "weapon_tec9", "weapon_cz75a", "weapon_elite" };
// Bot squad pool — cycled per bot spawn for a mixed T side (full squad templates come later).
[JsonPropertyName("BotWeapons")]
public List<string> BotWeapons { get; set; } = new()
{ "weapon_ak47", "weapon_ak47", "weapon_awp", "weapon_nova" };
[JsonPropertyName("BotGrenades")]
public List<string> BotGrenades { get; set; } = new()
{ "weapon_hegrenade" };
}
// One Gun Game ladder rung: a weapon + its strength tier (1=weak/hard-to-use .. 5=cheese). The tier indexes the
// per-tier kill curves below — it does NOT affect the climb ORDER (that's this list's order, shared by players+bots).
public sealed class GunGameRung
{
[JsonPropertyName("Weapon")] public string Weapon { get; set; } = "";
[JsonPropertyName("Tier")] public int Tier { get; set; } = 1;
public GunGameRung() { }
public GunGameRung(string weapon, int tier) { Weapon = weapon; Tier = tier; }
}
// Gun Game mode (Mode="gungame", see Modes.cs). Climb a weapon ladder by kills; the full RPG kit rides along.
// DUAL pacing: kills-to-advance a rung is driven by the weapon's TIER, and players vs bots use INVERSE curves —
// players grind weak guns + breeze strong ones (so an HS-demotion into the grind stings); bots skip weak guns
// fast + linger on strong ones (so they reach dangerous weapons instead of being stuck on pistols). The ORDER is
// shared (bots have no muscle memory) and zig-zags weapon types so muscle memory never settles.
public sealed class GunGameConfig
{
// Kills to clear a rung, indexed by tier-1 (tier 1..5). The player curve grinds weak guns / breezes strong ones.
// Bots default to a FLAT 1 kill/rung — with BotSharedLadder (per-batch pooling) a batch then needs ~ladder-length
// collective kills to win, which only lands once a player's K/D falls into handicap territory.
[JsonPropertyName("PlayerKillsByTier")] public List<int> PlayerKillsByTier { get; set; } = new() { 10, 8, 5, 2, 1 };
[JsonPropertyName("BotKillsByTier")] public List<int> BotKillsByTier { get; set; } = new() { 1, 1, 1, 1, 1 };
// Pool bot ladder progress per BATCH of BotsPerHuman (true, default): bots are grouped by slot rank into batches
// of BotsPerHuman, and each batch shares ONE rung that advances on ANY of its members' kills — so a batch (≈ one
// player's worth of bots) climbs ~BotsPerHuman× faster and can actually win, and the horde scales with player
// count (1 player -> 1 batch, 2 -> 2, ... up to MaxBots). false = each bot climbs its own rung (rarely tops).
[JsonPropertyName("BotSharedLadder")] public bool BotSharedLadder { get; set; } = true;
// Max humans on CT for THIS mode (GG wants more than the "outnumbered" TDM default of 3). Per-mode so ONE shared
// config can run both; the driver feeds it to EnforceHumanTeam.
[JsonPropertyName("MaxHumansOnCt")] public int MaxHumansOnCt { get; set; } = 5;
// Append a final knife rung — the classic Gun Game capstone (the win is a knife kill; 1 kill for both sides).
[JsonPropertyName("KnifeFinale")] public bool KnifeFinale { get; set; } = true;
// Map pool for this mode (changelevel rotation on a win). EMPTY = fall back to Match.Maps. Put the small
// arms-race maps here once you've confirmed the exact names installed on your server (e.g. ar_shoots, ar_baggage).
[JsonPropertyName("Maps")] public List<string> Maps { get; set; } = new();
// Per-mode handicap override (null = inherit the base Handicap unchanged). GG: weights the ladder-position
// progress axis (climbing nerfs you; a bot-demotion eases it), runs a rougher sub-1 Curve (bites from the start),
// and DOUBLES MasterDifficulty so the nerf cap (0.1x deal / 8x taken) is actually reachable on small GG maps —
// it's hit at n≈0.5 (half-maxed factors) instead of needing K/D+HS+streak+level+ladder ALL maxed, so a good
// climber gets driven to the floor and can no longer headshot-suppress the horde. All live-tunable via !og_reload.
// (Heads-up: MasterDifficulty scales both sides, so a struggling low-K/D player also gets a stronger comeback buff.)
[JsonPropertyName("Handicap")] public HandicapOverride? Handicap { get; set; } = new() { MasterDifficulty = 2.0, ProgressWeight = 3.0, Curve = 0.8, MDealFloor = 0.1 };
// The ladder: weapon order (shared) + per-weapon tier. EMPTY -> code falls back to a knife-only ladder (safety).
// Order zig-zags categories (no two consecutive the same; cheese scattered, not clustered).
[JsonPropertyName("Ladder")]
public List<GunGameRung> Ladder { get; set; } = new()
{
new("weapon_usp_silencer", 1), new("weapon_famas", 4), new("weapon_mac10", 2), new("weapon_mag7", 4),
new("weapon_aug", 5), new("weapon_fiveseven", 3), new("weapon_ump45", 4), new("weapon_scar20", 5),
new("weapon_nova", 3), new("weapon_m4a1", 5), new("weapon_p250", 2), new("weapon_mp5sd", 4),
new("weapon_ssg08", 4), new("weapon_galilar", 4), new("weapon_mp7", 3), new("weapon_xm1014", 4),
new("weapon_deagle", 5), new("weapon_bizon", 2), new("weapon_sg556", 5), new("weapon_tec9", 4),
new("weapon_m249", 4), new("weapon_mp9", 1), new("weapon_g3sg1", 5), new("weapon_sawedoff", 4),
new("weapon_revolver", 4), new("weapon_m4a1_silencer", 5), new("weapon_p90", 3), new("weapon_negev", 4),
new("weapon_elite", 4), new("weapon_ak47", 5), new("weapon_hkp2000", 1), new("weapon_awp", 5),
};
}