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

419
Outnumbered/Driver.cs Normal file
View file

@ -0,0 +1,419 @@
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<string, Func<OutnumberedPlugin, IMatchDriver>> 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<Listeners.OnMapStart>(OnMapStart_Driver);
RegisterListener<Listeners.OnClientPutInServer>(_ => SyncBots());
RegisterEventHandler<EventRoundStart>(OnRoundStart_Driver);
RegisterEventHandler<EventPlayerDeath>(OnPlayerDeath_Driver, HookMode.Pre);
RegisterEventHandler<EventPlayerSpawn>(OnPlayerSpawn_Driver);
RegisterEventHandler<EventPlayerTeam>(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 <id>` (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<CEntityInstance>(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
}
}