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,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),
};
}