using CounterStrikeSharp.API; using CounterStrikeSharp.API.Core; using CounterStrikeSharp.API.Modules.Utils; using Outnumbered.Config; using Outnumbered.Data; using Outnumbered.Engine; namespace Outnumbered; // Gun Game: climb a weapon ladder by kills; the FULL RPG kit (stats, abilities, handicap, XP) rides along. // BOTS climb the same ladder, so players can actually lose (a bot topping the ladder ends the map). A headshot // kill demotes the victim by one kill of ladder progress (whoever it is) — the arms-race knife-steal, generalised. // Your gun is dictated by your rung, so weapon selection is off and the shop is skills-only. Top rung = the knife // finale. The handicap keeps a steamrolling player honest (a dominant climber deals ~0.1x / takes ~8x). // (IMatchDriver/IDraftDriver/CardView + TdmDriver live in Modes.cs.) public sealed class GunGameDriver(OutnumberedPlugin p) : IMatchDriver { private readonly OutnumberedPlugin _p = p; public string Id => "gungame"; public IReadOnlyList Maps => _p.Config.GunGame.Maps.Count > 0 ? _p.Config.GunGame.Maps : _p.Config.Match.Maps; public bool WeaponShopEnabled => false; public HandicapOverride? Handicap => _p.Config.GunGame.Handicap; // GunGame.Handicap overrides the base, if set public int MaxHumansOnCt => _p.Config.GunGame.MaxHumansOnCt; public string ExtraCvars => "mp_randomspawn 1;"; // scatter spawns on the small arms-race maps -> the horde clumps less // Per-bot ladder progress (humans carry theirs on PlayerData.GgRung/GgRungKills). Keyed by slot, but CS2 // reuses slots when a bot is kicked (bot_quota drops as humans leave) and a new one is added — so we also // stamp the occupant's UserId and reset the slot's progress when a different bot takes it (EnsureBot). Without // that, a fresh bot could inherit a near-top rung and win spuriously. All cleared on match reset. private readonly Dictionary _botRung = []; // per-bot mode (BotSharedLadder=false): slot -> rung private readonly Dictionary _botRungKills = []; private readonly Dictionary _botUserId = []; private readonly Dictionary _batchRung = []; // shared mode (default): batchId -> rung private readonly Dictionary _batchRungKills = []; private readonly Dictionary _botBatch = []; // slot -> STABLE batch assignment (+ occupant UserId) public void OnMatchReset() { _botRung.Clear(); _botRungKills.Clear(); _botUserId.Clear(); _batchRung.Clear(); _batchRungKills.Clear(); _botBatch.Clear(); _ladder = null; } private bool Pooled => _p.Config.GunGame.BotSharedLadder; // A bot's batch, assigned ONCE (to the smallest current batch) and cached by slot+UserId, so a bot KEEPS its // batch for the whole match across respawns/roster churn — a slot-rank formula would jitter as bots respawn/leave, // hopping batches and showing mismatched weapons. Batches stay ≈BotsPerHuman-sized; solo (4 bots) = one stable // batch. Cleared on map start. 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; // same bot -> same batch, always 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); // ≈ one batch per player's worth of bots var counts = new int[batches]; foreach (var v in _botBatch.Values) if (v.batch < batches) counts[v.batch]++; int pick = 0; // assign the new bot to the smallest batch for (int i = 1; i < batches; i++) if (counts[i] < counts[pick]) pick = i; _botBatch[slot] = (pick, uid); return pick; } // Re-equip EVERY alive bot IN A BATCH to that batch's current rung, so the batch is ALWAYS in sync (same weapon). // Called on any batch rung change. Without it only the killer upgrades and its batch-mates lag a rung behind until // respawn — so a lagging bot could land the winning/visible kill on the wrong gun. private void RegiveBatch(int batch) => Server.NextFrame(() => { foreach (var p in Utilities.GetPlayers()) if (OutnumberedPlugin.IsLiveBot(p) && BotBatch(p) == batch) _p.ApplyLoadout(p, true); }); // Ladder position in 0..1 (0 = bottom rung, 1 = top), fed to the handicap so climbing nerfs the player harder // and a headshot-demotion eases it. Counts rungs directly (no full BuildLadder) since this runs per damage/tick. public double HandicapProgress(PlayerData pd) { int count = Ladder().Count; // cached; == the old non-whitespace-rungs + knife count for every meaningful ladder if (count <= 1) return 0.0; return Math.Clamp(pd.GgRung / (double)(count - 1), 0.0, 1.0); } // The shop info-panel ladder line: current rung / total + kills toward advancing + the rung weapon. null = no ladder. public string? LadderStatusLine(PlayerData pd) { var ladder = Ladder(); if (ladder.Count == 0) return null; int r = Math.Clamp(pd.GgRung, 0, ladder.Count - 1); return $"Rung {r + 1}/{ladder.Count} ({pd.GgRungKills}/{ladder[r].PlayerKills}): {OutnumberedPlugin.WeaponDisplayName(ladder[r].Weapon)}"; } // Detect a new bot occupying a (possibly reused) slot and zero its ladder progress. UserId is stable across a // bot's respawns but differs for a freshly-added bot, so same-bot respawns keep progress; slot reuse resets it. private void EnsureBot(CCSPlayerController bot) { int slot = bot.Slot, uid = bot.UserId ?? -1; if (_botUserId.GetValueOrDefault(slot, int.MinValue) != uid) { _botUserId[slot] = uid; _botRung[slot] = 0; _botRungKills[slot] = 0; } } // A ladder rung: the weapon you hold + how many kills clear it (advance / win), separately for players and bots. public readonly record struct Rung(string Weapon, int PlayerKills, int BotKills); // Cached ladder — BuildLadder allocates a List + per-rung records, and it's read per kill/headshot/loadout/HUD call. // Rebuild only when the GunGame config INSTANCE changes (!og_reload swaps the whole Config, flipping the ref) // or on match reset. CONTRACT: an in-place edit to GunGame.Ladder would NOT invalidate this — only a Config swap does. // Callers read the list read-only, so sharing the cached instance is safe. private List? _ladder; private GunGameConfig? _ladderCfg; private List Ladder() { var gg = _p.Config.GunGame; if (_ladder is null || !ReferenceEquals(_ladderCfg, gg)) { _ladder = BuildLadder(); _ladderCfg = gg; } return _ladder; } // Built (and cached by Ladder()) so live config edits (!og_reload) take effect. The weapon ORDER is shared; the kills // to clear a rung come from the weapon's TIER via the two inverse curves (players grind weak / breeze strong; bots // the reverse). The knife finale (if enabled) wins on 1 kill for both sides. public List BuildLadder() { var gg = _p.Config.GunGame; var rungs = new List(gg.Ladder.Count + 1); foreach (var e in gg.Ladder) { if (string.IsNullOrWhiteSpace(e.Weapon)) continue; rungs.Add(new Rung(e.Weapon, KillsForTier(gg.PlayerKillsByTier, e.Tier), KillsForTier(gg.BotKillsByTier, e.Tier))); } if (gg.KnifeFinale) rungs.Add(new Rung(EngineNames.WeaponKnife, 1, 1)); if (rungs.Count == 0) rungs.Add(new Rung(EngineNames.WeaponKnife, 1, 1)); // safety: always a reachable win return rungs; } // Status API extras: the ladder as display names, so the site maps each player's Rung -> current weapon without a // per-player driver call (players carry their rung in the shared payload). Rebuilt per status refresh (~2s) — cold. public object? StatusExtra() { var ladder = Ladder(); var names = new string[ladder.Count]; for (int i = 0; i < ladder.Count; i++) names[i] = OutnumberedPlugin.WeaponDisplayName(ladder[i].Weapon); return new { Ladder = names }; } // Kills for a tier (1-based) from a curve list; clamps to the curve's bounds and floors at 1. private static int KillsForTier(List curve, int tier) { if (curve is null || curve.Count == 0) return 1; return Math.Max(1, curve[Math.Clamp(tier - 1, 0, curve.Count - 1)]); } public void GiveHumanLoadout(CCSPlayerController p) { var ladder = Ladder(); if (ladder.Count == 0) return; int rung = Math.Clamp(_p.PdOf(p)?.GgRung ?? 0, 0, ladder.Count - 1); GiveRungWeapon(p, ladder[rung].Weapon); _p.GiveShopCarriers(p); // healthshot + zeus (the zeus rest anchor also fixes the knife-rung grenade-auto-deploy bug) } public void GiveBotLoadout(CCSPlayerController bot) { var ladder = Ladder(); if (ladder.Count == 0) return; int rung; if (Pooled) rung = Math.Clamp(_batchRung.GetValueOrDefault(BotBatch(bot)), 0, ladder.Count - 1); else { EnsureBot(bot); rung = Math.Clamp(_botRung.GetValueOrDefault(bot.Slot), 0, ladder.Count - 1); } GiveRungWeapon(bot, ladder[rung].Weapon); } // The knife rung adds nothing — the core always gives the knife, so the finale is a pure knife fight. private static void GiveRungWeapon(CCSPlayerController p, string weapon) { if (!OutnumberedPlugin.WeapEq(weapon, EngineNames.WeaponKnife)) p.GiveNamedItem(weapon); } public void OnHumanKill(CCSPlayerController attacker, PlayerData apd) { var ladder = Ladder(); if (ladder.Count == 0) return; int rung = Math.Clamp(apd.GgRung, 0, ladder.Count - 1); if (++apd.GgRungKills < ladder[rung].PlayerKills) return; // still climbing this rung apd.GgRungKills = 0; apd.GgRung = rung + 1; if (apd.GgRung >= ladder.Count) // cleared the final (knife) rung -> win { apd.GgRung = ladder.Count - 1; // Kills keep processing through the map-end grace window (changelevel is delayed a few seconds). A ladder // topped in that window — after a bot win or an earlier human winner — is NOT a match result: no record. if (_p.MapEnding) return; long? runMs = _p.RecordGgWin(apd); // improve-only leaderboard write; null if the clock never armed _p.TriggerMapEnd(runMs is { } t ? $"{attacker.PlayerName} climbed the whole ladder in {OutnumberedPlugin.FormatRunTime(t)} — GUN GAME!" : $"{attacker.PlayerName} climbed the whole ladder — GUN GAME!"); return; } RegiveLive(attacker.Slot, isBot: false); // hand over the next rung's gun live var next = ladder[apd.GgRung]; attacker.PrintToChat($" {ChatColors.Gold}[Gun Game] {ChatColors.Default}Rung {apd.GgRung + 1}/{ladder.Count}: " + $"{ChatColors.Lime}{OutnumberedPlugin.WeaponDisplayName(next.Weapon)} {ChatColors.Default}({next.PlayerKills} to advance)"); } public void OnBotKill(CCSPlayerController bot) { var ladder = Ladder(); if (ladder.Count == 0) return; if (Pooled) // the bot's BATCH shares one rung; any member's kill advances it and the WHOLE batch re-equips { int batch = BotBatch(bot); int rung = Math.Clamp(_batchRung.GetValueOrDefault(batch), 0, ladder.Count - 1); int kills = _batchRungKills.GetValueOrDefault(batch) + 1; if (kills < ladder[rung].BotKills) { _batchRungKills[batch] = kills; return; } _batchRungKills[batch] = 0; rung++; if (rung >= ladder.Count) // a batch topped the ladder -> the humans lose { _batchRung[batch] = ladder.Count - 1; _p.TriggerMapEnd("The bot horde climbed the whole ladder — bots win!"); return; } _batchRung[batch] = rung; RegiveBatch(batch); // the whole batch jumps to the new rung weapon together (no lagging-bot desync) return; } EnsureBot(bot); // per-bot mode (BotSharedLadder=false): each bot climbs alone int slot = bot.Slot; int r = Math.Clamp(_botRung.GetValueOrDefault(slot), 0, ladder.Count - 1); int k = _botRungKills.GetValueOrDefault(slot) + 1; if (k < ladder[r].BotKills) { _botRungKills[slot] = k; return; } _botRungKills[slot] = 0; r++; if (r >= ladder.Count) { _botRung[slot] = ladder.Count - 1; _p.TriggerMapEnd($"The bots won — {bot.PlayerName} topped the ladder!"); return; } _botRung[slot] = r; RegiveLive(slot, isBot: true); } // Headshot death = lose one kill of ladder progress; underflow drops a rung (landing one kill from re-advancing). public void OnHeadshotDeath(CCSPlayerController victim) { var ladder = Ladder(); if (ladder.Count == 0) return; if (victim.IsBot) { if (Pooled) // a headshot on any bot knocks its WHOLE batch down a weapon (your suppression tool) { int batch = BotBatch(victim); int cur = Math.Clamp(_batchRung.GetValueOrDefault(batch), 0, ladder.Count - 1); var (br, bk) = Demote(cur, _batchRungKills.GetValueOrDefault(batch), ladder, bot: true); bool changed = br != cur; _batchRung[batch] = br; _batchRungKills[batch] = bk; if (changed) RegiveBatch(batch); // every bot in the batch drops to the lower rung weapon together return; } EnsureBot(victim); int slot = victim.Slot; var (nr, nk) = Demote(_botRung.GetValueOrDefault(slot), _botRungKills.GetValueOrDefault(slot), ladder, bot: true); _botRung[slot] = nr; _botRungKills[slot] = nk; // respawn gives the demoted weapon (GiveBotLoadout reads it) } else { var pd = _p.PdOf(victim); if (pd is null) return; var (nr, nk) = Demote(pd.GgRung, pd.GgRungKills, ladder, bot: false); bool droppedRung = nr != Math.Clamp(pd.GgRung, 0, ladder.Count - 1); pd.GgRung = nr; pd.GgRungKills = nk; // victim is dead; respawn gives the demoted weapon if (droppedRung) victim.PrintToChat($" {ChatColors.Red}[Gun Game] Headshot! {ChatColors.Default}Dropped to " + $"{ChatColors.LightYellow}{OutnumberedPlugin.WeaponDisplayName(ladder[nr].Weapon)}"); } } // Lose one kill of progress; underflow drops a rung, landing one kill short of re-advancing (per-side kills). private static (int rung, int kills) Demote(int rung, int kills, List ladder, bool bot) { rung = Math.Clamp(rung, 0, ladder.Count - 1); if (kills > 0) return (rung, kills - 1); if (rung > 0) { int need = bot ? ladder[rung - 1].BotKills : ladder[rung - 1].PlayerKills; return (rung - 1, Math.Max(0, need - 1)); } return (0, 0); // already rock bottom } private void RegiveLive(int slot, bool isBot) => OutnumberedPlugin.NextFrameForSlot(slot, pl => { if (pl.IsBot == isBot) _p.ApplyLoadout(pl, isBot); }, requireAlive: true); }