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 ` 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 (/.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 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 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 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 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 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 Rifles { get; set; } = new() { "weapon_ak47", "weapon_m4a1_silencer", "weapon_m4a1", "weapon_aug", "weapon_sg556", "weapon_galilar", "weapon_famas" }; [JsonPropertyName("Smgs")] public List Smgs { get; set; } = new() { "weapon_mp9", "weapon_mp7", "weapon_mp5sd", "weapon_ump45", "weapon_p90", "weapon_mac10", "weapon_bizon" }; [JsonPropertyName("Snipers")] public List Snipers { get; set; } = new() { "weapon_awp", "weapon_ssg08", "weapon_scar20", "weapon_g3sg1" }; [JsonPropertyName("Shotguns")] public List Shotguns { get; set; } = new() { "weapon_nova", "weapon_xm1014", "weapon_mag7", "weapon_sawedoff" }; [JsonPropertyName("Heavy")] public List Heavy { get; set; } = new() { "weapon_m249", "weapon_negev" }; [JsonPropertyName("Pistols")] public List 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 BotWeapons { get; set; } = new() { "weapon_ak47", "weapon_ak47", "weapon_awp", "weapon_nova" }; [JsonPropertyName("BotGrenades")] public List 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 PlayerKillsByTier { get; set; } = new() { 10, 8, 5, 2, 1 }; [JsonPropertyName("BotKillsByTier")] public List 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 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 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), }; }