initial commit
This commit is contained in:
commit
d701598350
67 changed files with 9351 additions and 0 deletions
292
Outnumbered/GunGame.cs
Normal file
292
Outnumbered/GunGame.cs
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue