using CounterStrikeSharp.API; using CounterStrikeSharp.API.Core; using CounterStrikeSharp.API.Modules.Timers; using CounterStrikeSharp.API.Modules.Utils; using Microsoft.Extensions.Logging; using Outnumbered.Engine; namespace Outnumbered; // Shared match plumbing. Runs on the Deathmatch gamemode base (game_type 1 game_mode 2): native DM handles // respawn + weapon deployment (so bots don't get stuck trying to buy); we layer on forced teams (humans CT vs // bots T), squad loadouts, the human cap, objective removal, and kill-goal map rotation. public sealed partial class OutnumberedPlugin { private bool _mapEnding; internal bool MapEnding => _mapEnding; // drivers gate match-result writes on this (a win landing in the map-end grace window isn't a result) private bool _seenMapStart; // false only until the FIRST map start after plugin load — the restart-restore window OnMapStart_Driver protects private CounterStrikeSharp.API.Modules.Timers.Timer? _botTimer; private IMatchDriver _driver = null!; // the active mode driver, chosen from the launch flag / Config.Mode private IDraftDriver? Draft => _driver as IDraftDriver; // survival's draft/run capability; null in TDM/GG (no concrete casts) private bool _ggTimerActive; // GG speedrun clock arms on first damage — a plain bool because the damage hook checks it per hit // NOTE: we deliberately DON'T remove func_buyzone — bots' buy AI loops "trying to buy outside // buy zone" if it's gone. Buying is already dead via mp_buytime 0 / mp_maxmoney 0. private static readonly string[] ObjectiveEntities = { "func_bomb_target", "func_hostage_rescue", "hostage_entity", "weapon_c4", "planted_c4" }; // Mode REGISTRY — canonical id -> driver factory (the single place modes are wired). ResolveMode normalizes the // launch flag / JSON to one of these ids; an unrecognised id -> UnbuiltMode (TDM + a warning). Add a mode = one row. private static readonly Dictionary> ModeRegistry = new() { ["tdm"] = p => new TdmDriver(p), ["gungame"] = p => new GunGameDriver(p), ["survival"] = p => new SurvivalDriver(p), }; private void Initialize_Driver() { string mode = ResolveMode(); _driver = ModeRegistry.TryGetValue(mode, out var make) ? make(this) : UnbuiltMode(mode); // unknown id -> warn + TDM _ggTimerActive = _driver is GunGameDriver; RebuildEffectiveHandicap(); _driver.OnActivated(); // survival starts its wave state machine here; no-op for TDM/GG Logger.LogInformation("Outnumbered match driver: {Mode}", _driver.Id); // Every ModeRegistry id must round-trip through NormalizeMode — else a mode whose canonical id is itself an alias for // a DIFFERENT mode would be permanently unreachable (selection runs through NormalizeMode first). Adding a mode is one // ModeRegistry row + its numeric/short aliases in NormalizeMode; this catches the "added the row, forgot the alias collides" slip. foreach (var id in ModeRegistry.Keys) if (NormalizeMode(id) is var mapped && mapped != id) Logger.LogError("Outnumbered: ModeRegistry id '{Id}' does not round-trip through NormalizeMode (maps to '{Mapped}') — that mode is unreachable.", id, mapped); RegisterListener(OnMapStart_Driver); RegisterListener(_ => SyncBots()); RegisterEventHandler(OnRoundStart_Driver); RegisterEventHandler(OnPlayerDeath_Driver, HookMode.Pre); RegisterEventHandler(OnPlayerSpawn_Driver); RegisterEventHandler(OnPlayerTeam_Driver); _botTimer = AddTimer(5.0f, SyncBots, TimerFlags.REPEAT); AddTimer(1.0f, () => SetupMap(Server.MapName)); // covers a hot-reload mid-map } private void Shutdown_Driver() { _botTimer?.Kill(); _driver?.OnDeactivated(); // tear down driver-owned timers (survival's wave heartbeat) so they don't leak on hot-reload } // Mode selection: a launch flag wins (so ONE shared outnumbered.json can serve every instance — just vary // the flag per server), else the JSON "Mode", else tdm. Flag: `-outnumbered_mode ` (also accepts `+`), // parsed straight from the process command line. The id may be a NAME or a NUMBER (whichever you prefer): // 0|tdm, 1|gg|gungame, 2|survival. // NOTE on game_type/game_mode: we deliberately DON'T select off those. All our modes run on the SAME // Deathmatch base (game_type 1 game_mode 2) — they'd be identical — and launching CS2's native slots (e.g. // game_mode 0 = Arms Race) would boot the engine's own ruleset, which fights our custom one. So the base // stays DM for every mode and this dedicated flag does the picking. private string ResolveMode() { string raw = "tdm", src = "default"; try { var tag = ArgValue(CommandLineArgs(), "outnumbered_mode"); if (!string.IsNullOrWhiteSpace(tag)) { raw = tag; src = "launch flag"; } } catch (Exception ex) { Logger.LogWarning(ex, "Outnumbered: reading launch args failed; using JSON Mode"); } if (src == "default" && !string.IsNullOrWhiteSpace(Config.Mode)) { raw = Config.Mode; src = "config"; } string mode = NormalizeMode(raw); Logger.LogInformation("Outnumbered mode: {Mode} (from {Src}: '{Raw}')", mode, src, raw); return mode; } // Canonical mode id from a name or a number (so the flag/JSON can use either form). Unknown passes through // (Initialize_Driver's ModeRegistry lookup misses it -> UnbuiltMode -> TDM with a warning). private static string NormalizeMode(string s) => s.Trim().ToLowerInvariant() switch { "0" or "tdm" => "tdm", "1" or "gg" or "gungame" => "gungame", "2" or "survival" or "wave" => "survival", var other => other, }; // A recognised-but-not-yet-built (or unrecognised) mode: log loudly and run TDM so the server still comes up. private IMatchDriver UnbuiltMode(string requested) { Logger.LogWarning("Outnumbered: mode '{Mode}' is not available yet — running TDM instead.", requested); return new TdmDriver(this); } // ---- map / round setup ---- private void OnMapStart_Driver(string mapName) { bool roundEnded = _mapEnding; // true only if a kill-goal map change brought us here (the flag persists across changelevel) _mapEnding = false; // K/D + streak + ability state are per-match — reset everyone in memory across the changelevel. foreach (var pd in _players.Values) { pd.Kills = 0; pd.Deaths = 0; pd.Streak = 0; pd.HeadshotKills = 0; pd.GgRung = 0; pd.GgRungKills = 0; pd.GgRunStartedAtMs = 0; // new match, new speedrun clock Array.Clear(pd.AbilityReadyAt); Array.Clear(pd.AbilityActiveUntil); } _driver.OnMatchReset(); // clear per-match driver state (e.g. Gun Game bot ladder progress) // Every map change is a match boundary -> wipe the DB match table. The ONLY protected window is the first map // start after plugin load: that's the restart-restore case (a graceful shutdown dumped mid-match state for // reconnecting players). Without the _seenMapStart arm, a manual/external changelevel left leavers' stale rows // behind — restorable next match, complete with a cross-match GG speedrun clock. if (roundEnded || _seenMapStart) WipeMatch(); _seenMapStart = true; AddTimer(3.0f, () => SetupMap(mapName)); // entities/gamerules aren't ready at the instant of map start } private void SetupMap(string mapName) { if (!_live) return; // scheduled 1-3s out; skip if the plugin was unloaded/hot-reloaded in the meantime ApplyCvars(); RemoveObjectives(); SyncBots(); Rules.MakeRoundEndless(); _engineReady = true; // engine statics are safe from here on — opens the status-payload rebuild (Api.cs) _driver.OnMapSetup(); // map is up + simulating -> safe for the driver to touch the engine (survival arms its wave machine) Logger.LogInformation("Outnumbered {Mode} driver active on {Map}", _driver.Id, mapName); } private HookResult OnRoundStart_Driver(EventRoundStart ev, GameEventInfo info) { ApplyCvars(); // some cvars reset per round RemoveObjectives(); // objective entities respawn per round SyncBots(); Server.NextFrame(Rules.MakeRoundEndless); // after the game has set the round timer from the cvar, override it return HookResult.Continue; } private void ApplyCvars() { Server.ExecuteCommand( // Team deathmatch, never FFA; no friendly fire "mp_dm_teammode 1;mp_teammates_are_enemies 0;mp_friendlyfire 0;" + "ff_damage_reduction_bullets 0;ff_damage_reduction_grenade 0;ff_damage_reduction_other 0;" + "mp_autoteambalance 0;mp_limitteams 0;" + // solo human on CT = "team wiped" on death -> suppress round-win so play is continuous "mp_ignore_round_win_conditions 1;mp_roundtime 60;mp_roundtime_deployment 0;" + // native DM respawn handles respawning AND weapon deployment (so bots don't try to buy) $"mp_respawn_on_death_ct 1;mp_respawn_on_death_t 1;mp_respawn_immunitytime {Config.Match.SpawnProtectionSeconds};" + "mp_freezetime 0;mp_timelimit 0;mp_warmuptime 3;" + // no economy / no objectives / no dropped clutter / no DM bonus-weapon prompt // mp_buy_anywhere 1: whole map counts as a buy zone, so bots' buy AI never loops on // "outside buy zone" (they have $0 via maxmoney/startmoney, so they buy nothing anyway). "mp_buytime 0;mp_buy_during_immunity 0;mp_maxmoney 0;mp_startmoney 0;mp_buy_anywhere 1;mp_buy_allow_grenades 0;mp_free_armor 0;" + // raise the grenade carry cap (default 4) so all 5 ability-key grenades can be held at once "ammo_grenade_limit_total 5;" + "mp_give_player_c4 0;mp_hostages_max 0;mp_death_drop_gun 0;mp_death_drop_grenade 0;mp_death_drop_defuser 0;" + "mp_dm_bonus_length_max 0;mp_dm_bonus_length_min 0;mp_dm_time_between_bonus_max 9999;mp_dm_time_between_bonus_min 9999;" + // bots: forced to T, fixed difficulty, rogues allowed (lone-wolf instead of squadding up) "bot_quota_mode normal;bot_join_team t;sv_auto_adjust_bot_difficulty 0;bot_chatter off;bot_allow_rogues 1;" + $"bot_difficulty {Config.Match.BotDifficulty};custom_bot_difficulty {Config.Match.BotDifficulty};" + _driver.ExtraCvars); // mode-specific cvars (e.g. GG mp_randomspawn to scatter the horde) } private static void RemoveObjectives() { foreach (var name in ObjectiveEntities) foreach (var ent in Utilities.FindAllEntitiesByDesignerName(name)) if (ent is { IsValid: true }) ent.Remove(); } // ---- bots ---- private void SyncBots() { // Survival owns bot population (wave spawning) — the steady-state quota model steps aside. if (_driver.OwnsBotPopulation) { _driver.ManageBots(); return; } var players = Utilities.GetPlayers(); int humans = players.Count(IsHuman); int desired = Math.Clamp(humans * Config.Match.BotsPerHuman, 0, Config.Match.MaxBots); Server.ExecuteCommand($"bot_quota {desired}"); ForceBotsToTerrorist(players); // force any bots that landed on CT back to T (bot_join_team only affects new bots) } // ---- kill tracking for the map-end goal (respawn itself is native DM) ---- private HookResult OnPlayerDeath_Driver(EventPlayerDeath ev, GameEventInfo info) { var victim = ev.Userid; var attacker = ev.Attacker; // a "real" kill = a valid attacker killing a different player (not suicide / world death) bool realKill = attacker is { IsValid: true } && victim is { IsValid: true } && attacker.Slot != victim.Slot; // a bot died -> drop its burns so a fast slot-reuse (suicide/respawn within the burn window) can't inherit fire if (IsBot(victim)) ClearBurnsForBot(victim.Slot); // drop any crit flag the offense hook set but EventPlayerHurt never consumed (overkill collapse / 0-HP-damage hit) if (victim is { IsValid: true }) _critPending.Remove(victim.Slot); // victim death (human): deaths++, killstreak reset if (victim is { IsValid: true } && !victim.IsBot && PdOf(victim) is { } vpd) { vpd.Deaths++; vpd.Streak = 0; // streak-earned availability is lost on death; active effects end. Cooldowns keep ticking (spec §4). Array.Clear(vpd.AbilityActiveUntil); _driver.OnHumanDeath(victim, vpd); // survival: run wipe detection (no respawn; revived at wave-clear). No-op elsewhere. } // attacker kill: human -> kills++/streak/XP + mode result; bot -> mode result only (bots have no PlayerData) if (realKill && !attacker!.IsBot) { if (PdOf(attacker) is { } apd) // the cold death path resolves via the seam (the hot damage hook is the perf-exempt one) { apd.Kills++; apd.Streak++; if (ev.Headshot) apd.HeadshotKills++; // feeds the handicap's headshot-rate factor // ding when this kill makes an ability newly castable (streak hit its exact threshold) for (int i = 0; i < AbilityCount; i++) if (apd.Streak == AbilityCfg(i).StreakReq) { PlaySound(attacker, Config.Sounds.AbilityReady); break; } _driver.OnHumanKill(attacker, apd); // mode result: TDM kill-goal / Gun Game rung-advance + win // Explode-on-Kill card (survival): drop a real HE blast on the bot's corpse, deferred a frame OUT of // this death hook. Chains for free — a blast kill fires its own death event -> back here -> re-explodes. if (IsBot(victim) && EffectCardMag(apd, CardKeys.ExplodeKill) > 0 && victim.PlayerPawn.Value?.AbsOrigin is { } corpse && attacker.AuthorizedSteamID?.SteamId64 is { } sid) // pinned id for the deferred identity re-validation { var pos = new Vector(corpse.X, corpse.Y, corpse.Z); // defer a frame OUT of this death hook; re-validate the attacker's identity (slot reuse within the frame) NextFrameForSlot(attacker.Slot, sid, a => ExplodeAt(a, pos)); } } GrantKillXp(attacker); // step 4 progression (XP scaled by the handicap) } else if (realKill && attacker!.IsBot) { _driver.OnBotKill(attacker); // Gun Game: bots climb the ladder too (so players can lose); no-op in TDM } // headshot demotion: the victim loses a kill of ladder progress (Gun Game; no-op in TDM) if (realKill && ev.Headshot && victim is { IsValid: true }) _driver.OnHeadshotDeath(victim); return HookResult.Continue; } // ---- loadout (override DM's deployment with our loadout) ---- private HookResult OnPlayerSpawn_Driver(EventPlayerSpawn ev, GameEventInfo info) { var p = ev.Userid; if (p is not { IsValid: true } || p.IsHLTV) return HookResult.Continue; bool isBot = p.IsBot; NextFrameForSlot(p.Slot, pl => ApplyLoadout(pl, isBot), requireAlive: true); return HookResult.Continue; } // Spawn loadout. The bot branch + the knife/armor tail are shared across modes; the human weapons are the // mode driver's call (TDM: chosen primary+secondary+shop carriers; Gun Game: the current ladder rung). // Also reused mid-life by Gun Game to hand over the next rung's gun on a kill. // Ability-key grenades are NOT given here — they're granted only while an ability is castable // (see Abilities.cs ReconcileGrenades) so the key is live exactly when the ability is, and never throwable. internal void ApplyLoadout(CCSPlayerController p, bool isBot) { p.RemoveWeapons(); if (isBot) _driver.GiveBotLoadout(p); // TDM: fixed squad; Gun Game: the bot's current ladder rung else _driver.GiveHumanLoadout(p); p.GiveNamedItem(EngineNames.WeaponKnife); p.GiveNamedItem(EngineNames.ItemAssaultSuit); // kevlar + helmet — grants exactly 100 armor // item_assaultsuit forces armor to 100, so a player whose MaxArmor stat exceeds that must be topped back up to // their real cap right HERE, after the suit. (The separate spawn cap-applier races the suit and loses if it runs // first; doing it inline makes this the deterministic last writer.) Bots keep the suit's flat 100. if (!isBot && p.PlayerPawn.Value is { Health: > 0 } pawn && PdOf(p) is { } pd) PawnWriter.SetArmor(pawn, MaxArmorOf(pd)); // Vanilla-parity fire gate. The rebuild deploys a FRESH weapon entity whose initial-deploy gate opens later // than a native switch — and bots fire on the first legal tick, so a human loses every same-window draw race // (worst at a GG rank-up, where the kill triggers regives on both sides). One frame after the deploy settles, // open the gate: the draw still animates, but firing is allowed immediately (the cs2-gungame / CS2-Deathmatch // FastWeaponEquip pattern). Humans only — bots already fire at the gate. if (!isBot) NextFrameForSlot(p.Slot, OpenFireGate, requireAlive: true); } // Melee/zeus/carrier keep native timing: the gate only matters for guns, and the knife finale should stay a fair // knife fight. (Ability grenades can't be in hand here — they're granted later, only while castable.) private static void OpenFireGate(CCSPlayerController p) { var w = Inventory.ActiveWeapon(p); if (w is null) return; string name = w.DesignerName ?? ""; if (Inventory.IsMeleeOrZeus(name) || name == EngineNames.ShopCarrier) return; w.NextPrimaryAttackTick = Server.TickCount + 1; Utilities.SetStateChanged(w, EngineNames.CBasePlayerWeapon, EngineNames.NextPrimaryAttackTick); } // The two shop items every human spawns with when the quick-buy is on: the healthshot carrier (X = open menu) and the // zeus (key 3 + the rest anchor that stops grenades auto-deploying over the knife). One definition; each driver's human // loadout calls it at the same spot (so it stays bit-identical — Gun Game still skips it on an empty ladder via its own // earlier return). No-op when the shop is disabled. internal void GiveShopCarriers(CCSPlayerController p) { if (!Config.Shop.Enabled) return; p.GiveNamedItem(EngineNames.ShopCarrier); // healthshot (X) toggles the quick-buy shop / the survival draft p.GiveNamedItem(EngineNames.ShopMelee); // zeus — CT-legal, serves as shop key 3 + the grenade-rest anchor } // The chosen-loadout human spawn shared by every WeaponShopEnabled mode (TDM + Survival): the saved !guns primary/secondary // + the shop carriers. Gun Game overrides with its rung weapon instead, so it keeps its own GiveHumanLoadout. internal void GiveChosenLoadout(CCSPlayerController p) { var (primary, secondary) = ResolveLoadout(p); // saved !guns choice if unlocked, else config default if (!string.IsNullOrEmpty(primary)) p.GiveNamedItem(primary); if (!string.IsNullOrEmpty(secondary)) p.GiveNamedItem(secondary); GiveShopCarriers(p); } // The standard bot loadout (TDM + Survival): one pool weapon by stable per-bot index + the configured grenades. (Gun // Game bots use the ladder rung instead, so GunGameDriver keeps its own GiveBotLoadout.) One definition for the two that share it. internal void GiveStandardBotLoadout(CCSPlayerController bot) { var pool = Config.Match.BotWeapons; bot.GiveNamedItem(pool.Count > 0 ? pool[BotLoadoutIndex(bot) % pool.Count] : EngineNames.WeaponAk47); foreach (var g in Config.Match.BotGrenades) bot.GiveNamedItem(g); } // Stable per-bot weapon slot: a bot's rank in slot order (count of bots with a lower slot). Each bot keeps // its index across respawns -> always the same weapon. Guarantees the squad composition mirrors BotWeapons // exactly (e.g. 2 AR / 1 shotgun / 1 AWP per 4). internal static int BotLoadoutIndex(CCSPlayerController bot) => Utilities.GetPlayers().Count(b => IsBot(b) && b.Slot < bot.Slot); // ---- team enforcement: humans on CT (cap), bots forced to T ---- private HookResult OnPlayerTeam_Driver(EventPlayerTeam ev, GameEventInfo info) { var p = ev.Userid; if (!IsHuman(p)) return HookResult.Continue; int slot = p.Slot; Server.NextFrame(() => EnforceHumanTeam(slot)); return HookResult.Continue; } private void EnforceHumanTeam(int slot) { var p = Utilities.GetPlayerFromSlot(slot); if (p is not { IsValid: true } || p.IsBot) return; // Survival fail-closed: no mid-run joins. While a run is in progress, a joining human (lands on T/None first) // is parked in spectator; existing CT participants (alive or downed-awaiting-revive) are left untouched. if (Draft is { RunInProgress: true }) { if (p.Team is CsTeam.Terrorist or CsTeam.None) { p.SwitchTeam(CsTeam.Spectator); p.PrintToChat("[Outnumbered] A survival run is in progress — you'll join the next one."); } return; } int cap = _driver.MaxHumansOnCt; // per-mode (GG = 5, TDM = Match.MaxHumansOnCt) int ctHumans = Utilities.GetPlayers() .Count(x => IsHuman(x) && x.Team == CsTeam.CounterTerrorist && x.Slot != slot); switch (p.Team) { // an over-cap CT, or a T/None arrival when CT is already full -> spectator (both land on the same outcome) case CsTeam.CounterTerrorist when ctHumans >= cap: case CsTeam.Terrorist or CsTeam.None when ctHumans >= cap: p.SwitchTeam(CsTeam.Spectator); p.PrintToChat($"[Outnumbered] CT is full (max {cap}) — moved to spectator."); break; // a T/None arrival with room -> put them on CT (native DM spawns them on the new team) case CsTeam.Terrorist or CsTeam.None: p.SwitchTeam(CsTeam.CounterTerrorist); break; } } // ---- map rotation on kill goal ---- // Ends the match and rotates the map. reason = the chat headline (TDM kill goal / Gun Game win); null = default. internal void TriggerMapEnd(string? reason = null) { if (_mapEnding) return; _mapEnding = true; string next = NextMap(); Server.PrintToChatAll($"[Outnumbered] {reason ?? "Kill goal reached"} — next map: {next}"); Logger.LogInformation("Outnumbered match end on {Cur} ({Reason}); switching to {Next}", Server.MapName, reason ?? "kill goal", next); AddTimer(Config.Match.MapChangeDelaySeconds, () => { if (_live) Server.ExecuteCommand($"changelevel {next}"); }); } private string NextMap() { var maps = _driver.Maps; // mode map pool (falls back to Match.Maps when empty) if (maps.Count == 0) return Server.MapName; int i = -1; for (int k = 0; k < maps.Count; k++) if (string.Equals(maps[k], Server.MapName, StringComparison.OrdinalIgnoreCase)) { i = k; break; } return maps[(i + 1) % maps.Count]; // i == -1 -> first } }