cs2-outnumbered/Outnumbered/GunGame.cs
Kamal Tufekcic d701598350
All checks were successful
CI / build (push) Successful in 32s
CI / release (push) Successful in 32s
CI / lint (push) Successful in 30s
initial commit
2026-07-05 13:28:35 +03:00

292 lines
15 KiB
C#

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<string> 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<int, int> _botRung = []; // per-bot mode (BotSharedLadder=false): slot -> rung
private readonly Dictionary<int, int> _botRungKills = [];
private readonly Dictionary<int, int> _botUserId = [];
private readonly Dictionary<int, int> _batchRung = []; // shared mode (default): batchId -> rung
private readonly Dictionary<int, int> _batchRungKills = [];
private readonly Dictionary<int, (int batch, int uid)> _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<Rung>? _ladder;
private GunGameConfig? _ladderCfg;
private List<Rung> 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<Rung> BuildLadder()
{
var gg = _p.Config.GunGame;
var rungs = new List<Rung>(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<int> 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<Rung> 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);
}