292 lines
15 KiB
C#
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);
|
|
}
|