using CounterStrikeSharp.API; using CounterStrikeSharp.API.Core; using Outnumbered.Config; using Outnumbered.Data; using Outnumbered.Domain; namespace Outnumbered; // The match-driver seam (the mode split). The whole RPG core — stats, progression, handicap, abilities, the // shop UI, persistence — is mode-agnostic. Only the match RULESET differs per mode. ResolveMode (Driver.cs) // picks the driver at Load. The shared match plumbing (cvars, bots, team enforcement, map setup, the endless // round timer, per-kill bookkeeping, map rotation) stays on OutnumberedPlugin (Driver.cs); a driver supplies // only the variant points below. Adding a mode = a new IMatchDriver, not a core rewrite. public interface IMatchDriver { string Id { get; } // Map pool for changelevel rotation. Empty -> the core falls back to Match.Maps. IReadOnlyList Maps { get; } // Whether weapon selection (the shop's Weapons screen + !guns) is offered. Off in modes that dictate your gun. bool WeaponShopEnabled { get; } // Per-mode handicap override (null = use the base Handicap block unchanged). Resolved by RebuildEffectiveHandicap. HandicapOverride? Handicap { get; } // The mode's per-player "progress" axis in 0..1, fed into the handicap nerf (weighted by Handicap.ProgressWeight). // 0 in TDM (no axis); in Gun Game it's ladder position so climbing nerfs you and a demotion eases it. double HandicapProgress(PlayerData pd); // Max humans allowed on CT for this mode (EnforceHumanTeam cap). int MaxHumansOnCt { get; } // Extra cvars appended to the shared ApplyCvars batch (mode-specific tweaks, e.g. GG's mp_randomspawn). "" = none. string ExtraCvars { get; } // Give a human's / a bot's per-spawn weapons. The shared core adds the knife + armor afterwards. void GiveHumanLoadout(CCSPlayerController p); void GiveBotLoadout(CCSPlayerController bot); // Mode result, run AFTER the shared per-kill bookkeeping (kills/streak/headshots/ability-ding) and BEFORE kill XP. void OnHumanKill(CCSPlayerController attacker, PlayerData apd); // A bot got a kill (bots have no PlayerData). No-op in TDM; in Gun Game the bot climbs the ladder. void OnBotKill(CCSPlayerController bot); // The victim died to a headshot (attacker != victim). No-op in TDM; in Gun Game the victim loses a kill of progress. void OnHeadshotDeath(CCSPlayerController victim); // New match (map start) — clear any per-match driver state (e.g. bot ladder progress). void OnMatchReset(); // ---- survival-mode seams (default no-ops; only SurvivalDriver overrides them) ---- // Called once right after the driver is selected (Initialize_Driver), during plugin Load — the engine isn't ready // yet, so don't touch it here (Server.CurrentTime etc. will crash). Survival only SCHEDULES its heartbeat here. void OnActivated() { } // Called from SetupMap once the map is up and the server is simulating (normal start AND hot-reload) — safe to touch // the engine. Survival arms its wave machine here. No-op for TDM/GG. void OnMapSetup() { } // Called from Shutdown_Driver (plugin Unload / hot-reload) — tear down any long-lived driver timers so they don't // leak or double-fire against a torn-down instance. Survival kills its wave heartbeat here. No-op for TDM/GG. void OnDeactivated() { } // True if the driver owns bot population (wave spawning) — the core's quota-based SyncBots then steps aside to ManageBots. bool OwnsBotPopulation => false; // Drive bot population (called from SyncBots when OwnsBotPopulation): survival sets bot_quota from the wave kill-budget. void ManageBots() { } // A human died (victim). Survival: move to spectator + run wipe detection. No-op elsewhere (native DM respawn handles it). void OnHumanDeath(CCSPlayerController victim, PlayerData pd) { } // A human left mid-run. Survival: bank their accumulated run-XP into the main table before the pd is dropped. void OnHumanDisconnect(ulong steamId, PlayerData pd) { } // Extra run-scoped stat bonus (survival cards) added on top of Eff() at every stat site via EffRun. 0 in TDM/GG. double StatBonus(PlayerData pd, string key) => 0.0; // The per-player run-card bonus source fed into PlayerSnapshot.Cards (so the pure Domain reads cards without a // pawn). null in TDM/GG and outside a survival run; the survival run itself implements IStatBonusSource. IStatBonusSource? CardSource(PlayerData pd) => null; // A monotonic, escalate-only handicap floor in t-space [0..1] for the active mode; -1 = "no floor" (TDM/GG, so buffs work). double HandicapFloor(PlayerData pd) => -1.0; // Team-wide survival card multipliers folded into MDeal / MTake (so they apply to every survivor and every damage // path). 1.0 = no team buff (TDM/GG, and survival before any global_deal/global_take is drafted). double TeamDealMult() => 1.0; double TeamTakeMult() => 1.0; // Optional mode status line for the HUD (survival shows the wave / bots-left line). "" = no line. string HudStatusLine(PlayerData pd) => ""; // Optional weapon-ladder status line for the shop info panel (Gun Game shows the current rung). null = N/A. string? LadderStatusLine(PlayerData pd) => null; // Mode-specific block for the local status API (Api.cs), serialized as-is into the payload's "Extra" field. // Called on the game thread against live driver state (the ~2s status rebuild). null = no extras. object? StatusExtra() => null; } // Capability: a mode that runs the survival between-wave card DRAFT + the per-player run-XP accumulator. ONLY // SurvivalDriver implements it; the core reaches it via `Draft` (= _driver as IDraftDriver) so it never type-checks the // concrete driver. null everywhere else, so TDM/GG transparently skip every draft/run path. public interface IDraftDriver { bool RunInProgress { get; } // a run is live -> EnforceHumanTeam blocks mid-run joins void AccumulateWaveXp(PlayerData pd, double amount); // bank raw combat XP into THIS wave's accumulator (granted at wave clear) bool DraftPending(CCSPlayerController p); // an unspent, spendable pick is waiting (break + draftable) int PendingCards(CCSPlayerController p); // unspent banked picks (menu header) List<(string key, string label)> CurrentDraw(CCSPlayerController p); // the offered hand (stable within a break) CardView? CardInfo(CCSPlayerController p, string key); // one card's view data (name/have/cap/values/detail) void PickCard(CCSPlayerController p, string key); // spend a pick on a drawn card } // View data for one draft card (the 3-card overlay): name, current/cap picks, and either the current->next % value // (the stat cards) OR a custom Detail line (the effect cards, where a raw "+N%" would mislead). public sealed record CardView(string Name, int Have, int Cap, double Now, double Next, bool Flat, string? Detail); // TDM (the original mode): per-player chosen loadout, fixed bot squad; the map ends when any one player reaches the // kill goal — OR when a bot BATCH does. Bots have no ladder, but their kills POOL per player-sized batch toward the same // KillGoal (mirroring Gun Game's batch sharing) so the horde can actually win. Headshots don't demote. public sealed class TdmDriver(OutnumberedPlugin p) : IMatchDriver { private readonly OutnumberedPlugin _p = p; public string Id => "tdm"; public IReadOnlyList Maps => _p.Config.Match.Maps; public bool WeaponShopEnabled => true; public HandicapOverride? Handicap => null; // TDM uses the base handicap as-is public void GiveHumanLoadout(CCSPlayerController p) => _p.GiveChosenLoadout(p); public void GiveBotLoadout(CCSPlayerController bot) => _p.GiveStandardBotLoadout(bot); public void OnHumanKill(CCSPlayerController attacker, PlayerData apd) { if (apd.Kills >= _p.Config.Match.KillGoal) _p.TriggerMapEnd(); } // The bot horde wins by reaching the SAME KillGoal, but pooled PER BATCH (≈BotsPerHuman bots), not per individual bot // and not globally: each batch races one human's worth of kills, so the pace scales with player count (N humans ↔ N // batches) and a lone player faces exactly one batch. Pooling globally would let a 3v12 horde hit the goal ~4x too // fast. (Batch assignment mirrors Gun Game's; kept self-contained here so it can't perturb the working GG path.) private readonly Dictionary _botBatch = new(); // slot -> stable batch + occupant UserId private readonly Dictionary _batchKills = new(); // batch -> kills pooled toward KillGoal public void OnBotKill(CCSPlayerController bot) { int batch = BotBatch(bot); int kills = _batchKills.GetValueOrDefault(batch) + 1; _batchKills[batch] = kills; if (kills >= _p.Config.Match.KillGoal) _p.TriggerMapEnd("The bot horde reached the kill goal — bots win!"); } public object? StatusExtra() => new { KillGoal = _p.Config.Match.KillGoal }; // A bot's batch: assigned ONCE to the smallest current batch and cached by slot+UserId, so it stays put across // respawns/roster churn (a slot-rank formula would jitter as bots come and go). Batches stay ≈BotsPerHuman-sized; // solo (4 bots) = one batch. Cleared on match reset. private int BotBatch(CCSPlayerController bot) { int slot = bot.Slot, uid = bot.UserId ?? -1; if (_botBatch.TryGetValue(slot, out var e) && e.uid == uid) return e.batch; int per = Math.Max(1, _p.Config.Match.BotsPerHuman); int botCount = Utilities.GetPlayers().Count(OutnumberedPlugin.IsBot); int batches = Math.Max(1, (botCount + per - 1) / per); var counts = new int[batches]; foreach (var v in _botBatch.Values) if (v.batch < batches) counts[v.batch]++; int pick = 0; for (int i = 1; i < batches; i++) if (counts[i] < counts[pick]) pick = i; _botBatch[slot] = (pick, uid); return pick; } public void OnHeadshotDeath(CCSPlayerController victim) { } public void OnMatchReset() { _botBatch.Clear(); _batchKills.Clear(); } public double HandicapProgress(PlayerData pd) => 0.0; // TDM has no progress axis public int MaxHumansOnCt => _p.Config.Match.MaxHumansOnCt; public string ExtraCvars => ""; }