using CounterStrikeSharp.API; using CounterStrikeSharp.API.Core; using CounterStrikeSharp.API.Modules.Timers; using CounterStrikeSharp.API.Modules.Utils; using Microsoft.Extensions.Logging; using Outnumbered.Config; using Outnumbered.Data; using Outnumbered.Domain; using Outnumbered.Engine; namespace Outnumbered; // Wave Survival ("Last Stand") — co-op, escalating bot waves on the small arms-race maps. The whole RPG core rides // along unchanged. Design (research/survival-mode-design.md): VANILLA bots (100hp/100armor, never tougher) — the // difficulty curve is (a) MORE bots up to AliveCap, and (b) the inherited handicap floor tightening every wave; the // counter-pressure is the roguelite DRAFT (strong run-scoped cards via EffRun). You outscale until the floor + horde // finally win. Combat XP is banked RAW per wave and granted to the main table at EACH wave clear (x prestige x waveMult), // so players level + buy skills mid-run; an uncleared wave is forfeited on a wipe. // // State machine: Idle -> (humans present) -> Fighting wave N -> (kills == budget) -> ClearWave (grant wave XP) -> Break // (revive + draft) -> wave N+1 -> ... -> clear WaveCount = WIN; all humans dead / wave timeout = LOSE -> map end. public sealed class SurvivalDriver : IMatchDriver, IDraftDriver { private readonly OutnumberedPlugin _p; public SurvivalDriver(OutnumberedPlugin p) => _p = p; private enum WavePhase { Idle, Fighting, Break } private readonly Dictionary _runs = []; // per-player run state (RAM-only; run ends on disconnect) private WavePhase _phase = WavePhase.Idle; private int _wave; // current wave (0 = not started) private int _killsThisWave; private int _waveBudget; // kills needed to clear the current wave (locked at wave start) private int _highestWaveCleared; private bool _runActive; private bool _ready; // the server is up + a map is loaded (set from OnMatchReset/OnMapSetup, NOT Load) — gates WaveTick private bool _runEnded; // terminal latch: a run finished on THIS map -> no auto-restart until the next map private double _phaseUntil; // Server.CurrentTime the break ends private double _lastKillAt; // last credited kill — the stall-nudge + timeout measure PROGRESS, not wall-time private double _lastNudgeAt; // last stall-nudge, so stalled bots aren't re-pulled every tick private double _mapReadyAt; // don't start a run until the map has settled + players can spawn private CounterStrikeSharp.API.Modules.Timers.Timer? _tick; // TEAM cards (global_deal / global_take): ONE shared squad level each (0..Cap), incremented by ANY survivor's pick, // applied to EVERY survivor via the cached multipliers (read per-hit from Handicap.MDeal/MTake -> kept as fields, not // recomputed each call). Reset per run. RecomputeTeamMults runs on pick / run start (live PerPick edits take on next pick). private int _teamDealLevel; private int _teamTakeLevel; private double _teamDealMult = 1.0; private double _teamTakeMult = 1.0; // ---- IMatchDriver: identity ---- public string Id => "survival"; public IReadOnlyList Maps => _p.Config.Survival.Maps.Count > 0 ? _p.Config.Survival.Maps : _p.Config.Match.Maps; public bool WeaponShopEnabled => true; // players choose their guns; cards are a separate draft public HandicapOverride? Handicap => _p.Config.Survival.Handicap; public int MaxHumansOnCt => _p.Config.Survival.MaxHumansOnCt; // Nobody auto-respawns: humans are revived at wave-clear (ReviveSurvivor); bots are spawned/streamed entirely by the // wave machine (force-Respawn in UpdateBotQuota). Engine auto-respawn for bots is OFF so it can't fight the wave drain // AND so we don't depend on it spawning bots (which it refuses after repeated T-side wipes — the wave-3 stall). public string ExtraCvars => "mp_randomspawn 1;mp_respawn_on_death_ct 0;mp_respawn_on_death_t 0;"; public double HandicapProgress(PlayerData pd) => 0.0; // survival escalates via the wave FLOOR, not the progress axis public bool OwnsBotPopulation => true; // the wave machine drives bot_quota, not the core's SyncBots public bool RunInProgress => _runActive; // EnforceHumanTeam fail-closed: no mid-run joins // Status API extras: the wave machine at a glance (site server cards show "Wave X/Y"). public object? StatusExtra() => new { Wave = _wave, WaveCount = _p.Config.Survival.WaveCount, Phase = _phase.ToString(), RunActive = _runActive }; // ---- loadout ---- public void GiveHumanLoadout(CCSPlayerController p) => _p.GiveChosenLoadout(p); public void GiveBotLoadout(CCSPlayerController bot) => _p.GiveStandardBotLoadout(bot); // vanilla bots, vanilla HP — only the COUNT escalates // ---- IMatchDriver: per-kill / death results ---- // A human killed a bot — wave progress. (PvE: a human's victim is always a bot.) public void OnHumanKill(CCSPlayerController attacker, PlayerData apd) { if (!_runActive || _phase != WavePhase.Fighting) return; _killsThisWave++; _lastKillAt = Server.CurrentTime; // progress made -> reset the stall-nudge / timeout window UpdateBotQuota(); // shrink the quota immediately so drain-zone kills aren't spuriously re-spawned (WaveTick clears the wave) } public void OnBotKill(CCSPlayerController bot) { } // a bot killed a human -> no wave progress; wipe handled in OnHumanDeath public void OnHeadshotDeath(CCSPlayerController victim) { } // no ladder in survival // A human died: no team switch (mp_respawn_on_death_ct 0 keeps them dead = auto death-spectate; revived at wave-clear). // Run wipe detection next frame (count after the pawn is actually down). public void OnHumanDeath(CCSPlayerController victim, PlayerData pd) { if (!_runActive) return; Server.NextFrame(() => { if (_runActive && _phase == WavePhase.Fighting && AliveCtHumans() == 0) EndRun("the squad was wiped out", win: false); }); } // A human left mid-run: every CLEARED wave's XP is already in their main table (granted at each wave clear), so there's // nothing to bank here — the in-progress (uncleared) wave is forfeited. Just forget the run. public void OnHumanDisconnect(ulong steamId, PlayerData pd) => _runs.Remove(steamId); // ---- IMatchDriver: cards (EffRun overlay) ---- // The active run is the snapshot's card source; null outside a run (Snapshot.Cards then null -> Domain reads 0). public IStatBonusSource? CardSource(PlayerData pd) => _runActive && _runs.TryGetValue(pd.SteamId, out var run) ? run : null; // Per-player card magnitude for the non-snapshot effect checks (burn/explode/cdr presence). Mirrors CardSource. public double StatBonus(PlayerData pd, string key) => CardSource(pd)?.Bonus(key) ?? 0.0; // ---- TEAM cards (global_deal / global_take): squad-wide, folded into MDeal / MTake (Handicap.cs) ---- private bool IsTeamCard(string key) => CardDef(key)?.IsTeam == true; // data-driven (SurvivalCardDef.IsTeam) public double TeamDealMult() => _runActive ? _teamDealMult : 1.0; // +dmg dealt, compounding (>=1) public double TeamTakeMult() => _runActive ? _teamTakeMult : 1.0; // -dmg taken, compounding (<=1) // Current level of a card: team cards use the shared squad counter; everything else is the per-player pick count. private int CardCount(SurvivalRun run, string key) => key == CardKeys.GlobalDeal ? _teamDealLevel : key == CardKeys.GlobalTake ? _teamTakeLevel : run.Cards.GetValueOrDefault(key); // Recompute the cached team multipliers from the shared levels + the cards' live PerPick (compounding per level). private void RecomputeTeamMults() { _teamDealMult = SurvivalEconomy.TeamMult(_teamDealLevel, CardDef(CardKeys.GlobalDeal)?.PerPick ?? 0.0, increase: true); _teamTakeMult = SurvivalEconomy.TeamMult(_teamTakeLevel, CardDef(CardKeys.GlobalTake)?.PerPick ?? 0.0, increase: false); } private void ResetTeamBuffs() { _teamDealLevel = 0; _teamTakeLevel = 0; _teamDealMult = 1.0; _teamTakeMult = 1.0; } // ---- IMatchDriver: the monotonic, escalate-only handicap floor (in t-space) ---- public double HandicapFloor(PlayerData pd) => // -1 = no floor (idle / between runs); monotonic in wave _runActive ? SurvivalEconomy.HandicapFloor(_wave, _p.Config.Survival) : -1.0; // ---- IMatchDriver: lifecycle ---- public void OnActivated() { // Runs during plugin LOAD — the engine globals aren't ready yet, so touching the engine here (e.g. // Server.CurrentTime) SIGSEGVs the server. Only SCHEDULE the heartbeat (AddTimer is safe at Load); it stays // gated by _ready until the first map setup arms it. _ready/_mapReadyAt are set in OnMatchReset + OnMapSetup. _tick ??= _p.AddTimer(0.5f, WaveTick, TimerFlags.REPEAT); // the wave state machine heartbeat (also drives bot_quota) } // Plugin Unload / hot-reload: kill the heartbeat so it can't leak or double-fire against a torn-down instance // (a fresh driver schedules its own _tick on the next Load). The framework doesn't reliably auto-kill plugin timers. public void OnDeactivated() { _tick?.Kill(); _tick = null; } // Called once the map is set up (Driver.SetupMap — covers both normal map start and a hot-reload mid-map), i.e. the // server is simulating and engine access is safe. This is what actually arms the wave machine. public void OnMapSetup() { _ready = true; _mapReadyAt = Server.CurrentTime + _p.Config.Survival.StartGraceSeconds; } public void OnMatchReset() { _runs.Clear(); ResetTeamBuffs(); _phase = WavePhase.Idle; _wave = 0; _killsThisWave = 0; _waveBudget = 0; _highestWaveCleared = 0; _runActive = false; _runEnded = false; _phaseUntil = 0; _ready = true; // OnMatchReset only runs on a real map start (server up) -> safe to read the clock + arm _mapReadyAt = Server.CurrentTime + _p.Config.Survival.StartGraceSeconds; } // ---- the wave state machine ---- private void WaveTick() { if (!_ready) return; // don't touch the engine until a map is set up (the heartbeat can fire during boot) ManageBots(); // keep the quota in sync with the current phase (also the SyncBots delegate target) double now = Server.CurrentTime; if (!_runActive) { // _runEnded gates the restart: after a run ends, TriggerMapEnd only schedules the changelevel ~6s later, and // this heartbeat keeps firing on the dying map — without the latch a still-alive squad (a WIN) would spawn a // phantom wave 1 (and a fresh, re-bankable run accumulator). Cleared on the next map (OnMatchReset). if (!_runEnded && now >= _mapReadyAt && AliveCtHumans() > 0) StartRun(); return; } switch (_phase) { case WavePhase.Fighting: if (AliveCtHumans() == 0) { EndRun("the squad was wiped out", win: false); break; } if (_killsThisWave >= _waveBudget) { ClearWave(); break; } // No KILLS for a while = the last bot(s) can't reach the squad (or everyone's standing still). Pull the // stragglers to the players instead of failing the run. Only the genuine deadlock (still no kills // WaveTimeoutSeconds after the last one, despite repeated nudges) fails out. Both windows measure // time-since-last-KILL, not wall-clock. if (now - _lastKillAt > _p.Config.Survival.StallNudgeSeconds && now - _lastNudgeAt > _p.Config.Survival.StallNudgeSeconds) NudgeStalledBots(now); if (now - _lastKillAt > _p.Config.Survival.WaveTimeoutSeconds) EndRun($"wave {_wave} stalled out", win: false); break; case WavePhase.Break: // Unspent draft picks BANK to future breaks (RunPoints persists) — a missed break isn't a lost card; // the player can spend the backlog in any later break. No forced auto-pick. if (now >= _phaseUntil) StartWave(_wave + 1); break; } } private void StartRun() { _runActive = true; _highestWaveCleared = 0; _runs.Clear(); ResetTeamBuffs(); Server.PrintToChatAll($" {ChatColors.Gold}[Survival] {ChatColors.Default}LAST STAND — clear {ChatColors.Lime}{_p.Config.Survival.WaveCount}{ChatColors.Default} waves to win. Good luck."); StartWave(1); } private void StartWave(int n) { var cfg = _p.Config.Survival; _wave = n; _phase = WavePhase.Fighting; _killsThisWave = 0; _lastKillAt = _lastNudgeAt = Server.CurrentTime; int humans = Math.Max(1, AliveCtHumans()); _waveBudget = SurvivalEconomy.WaveBudget(n, humans, cfg); _p.LogSurvival($"StartWave {n}: aliveTarget={AliveForWave(n)} budget={_waveBudget} curBots={BotCount()} humans={humans}"); _p.CloseAllShops(); // nobody frozen in a menu when the wave starts UpdateBotQuota(); Server.PrintToChatAll($" {ChatColors.Red}[Survival] WAVE {n}/{cfg.WaveCount} {ChatColors.Default}— {ChatColors.LightYellow}{_waveBudget}{ChatColors.Default} hostiles inbound. Hold the line!"); } private void ClearWave() { var cfg = _p.Config.Survival; _highestWaveCleared = _wave; _p.LogSurvival($"ClearWave {_wave} (kills={_killsThisWave}/{_waveBudget}); break={cfg.WaveBreakSeconds}s -> next StartWave {_wave + 1}"); // Grant THIS cleared wave's XP now (per-wave): raw x prestige x waveMult(wave), straight to the main table, then // reset each accumulator. Players spend the resulting points in the break (!skills). A wipe/leave mid-wave forfeits // the in-progress wave — only a CLEARED wave banks. Iterate _runs by SteamId (not just CtHumans) so a participant // who slipped to spectator/T isn't dropped. List? cleared = null; foreach (var kv in _runs) { var run = kv.Value; if (run.WaveXp <= 0) continue; // Best-wave latch: same bound as the XP bank — you contributed damage THIS wave, you get wave credit. // (A spectator idling in _runs from earlier waves earns nothing; improve-only keeps their real peak.) (cleared ??= new(_runs.Count)).Add(kv.Key); if (_p.PdBySteamId(kv.Key) is { } pd) _p.BankWaveXp(pd, run.WaveXp, _wave, ControllerFor(kv.Key)); run.WaveXp = 0; } // Before the victory early-return, or the final wave's clear would never reach the leaderboard. if (cleared is not null) _p.RecordBestWaves(cleared, _wave); if (_wave >= cfg.WaveCount) { EndRun($"VICTORY — all {cfg.WaveCount} waves cleared!", win: true); return; } _phase = WavePhase.Break; _phaseUntil = Server.CurrentTime + cfg.WaveBreakSeconds; UpdateBotQuota(); // Break -> cull the field for the break (steady pool, no kick) ReviveDead(); GrantCards(); Server.PrintToChatAll($" {ChatColors.Green}[Survival] Wave {_wave} cleared! {ChatColors.Default}{cfg.WaveBreakSeconds:0}s to prep — the draft pops up ({ChatColors.Gold}X{ChatColors.Default}/crouch to close)."); _p.AddTimer(0.75f, _p.OpenDraftForAll); // auto-pop the card overlay once the revive-spawns have settled } private void EndRun(string reason, bool win) { _p.LogSurvival($"EndRun: {reason} (win={win}, highestWaveCleared={_highestWaveCleared}, wave={_wave}, kills={_killsThisWave}/{_waveBudget})"); // Per-wave XP was already granted into the main table at each ClearWave; an in-progress (uncleared) wave is // forfeited. So there's nothing to bank at run-end — just drop the runs. _runs.Clear(); _runActive = false; _runEnded = true; _phase = WavePhase.Idle; _wave = 0; UpdateBotQuota(); // quota 0 Server.PrintToChatAll($" {(win ? ChatColors.Gold : ChatColors.Red)}[Survival] {ChatColors.Default}{reason} (reached wave {_highestWaveCleared})."); _p.TriggerMapEnd(win ? "Survival: VICTORY" : "Survival: run over"); } // ---- bots: vanilla, count-throttled via bot_quota ---- public void ManageBots() { OutnumberedPlugin.ForceBotsToTerrorist(Utilities.GetPlayers()); // any bot on CT -> back to T (bot_join_team t only affects new bots) UpdateBotQuota(); } // Bots ALIVE at once this wave (the simultaneous pressure) — ramps from AliveBase up to AliveCap. Distinct from the // kill budget: you face this many at a time (they respawn) until the wave's total kills are reached. private int AliveForWave(int wave) => SurvivalEconomy.AliveForWave(wave, _p.Config.Survival); // Keep a STEADY connected pool for the whole run: bot_quota stays at AliveCap and is NEVER toggled back to 0 between // waves. Repeatedly kicking (quota 0) + re-adding bots churns the engine's fake-client slots, which run out after a // few waves and then wedge ALL further adds. The pool stays connected (mostly dead at low waves); the per-wave ALIVE // count is set by Respawn (refill) / CommitSuicide (cull). bot_add_t only bootstraps the pool ONCE at run start // (0 -> AliveCap, near round start). DEADLOCK-FREE: _killsThisWave advances only on credited human kills, // suicides/respawns don't, and ClearWave fires on kills >= budget. private void UpdateBotQuota() { int cap = _p.Config.Survival.AliveCap; Server.ExecuteCommand($"bot_quota {(_runActive ? cap : 0)}"); if (!_runActive) return; var bots = OutnumberedPlugin.Bots().ToList(); if (bots.Count < cap) // bootstrap / replace any lost pool members — should fire ~once per run, not per wave { _p.LogSurvival($"pool: connected={bots.Count} cap={cap} -> +{cap - bots.Count} bot_add_t"); for (int i = bots.Count; i < cap; i++) Server.ExecuteCommand("bot_add_t"); } int want = _phase == WavePhase.Fighting ? Math.Clamp(Math.Min(AliveForWave(_wave), _waveBudget - _killsThisWave), 0, cap) : 0; // break / idle: clear the field int alive = bots.Count(p => p.PawnIsAlive); if (alive < want) // refill: spawn dead pool bots up to `want` (also the in-wave streaming respawn) foreach (var b in bots) { if (alive >= want) break; if (!b.PawnIsAlive) { b.Respawn(); alive++; } } else if (alive > want) // cull: kill excess down to `want` (break clear / over-count). Suicide => no kill credit foreach (var b in bots) { if (alive <= want) break; if (b.PawnIsAlive) { b.CommitSuicide(false, true); alive--; } } } // The wave stalled (no kills for a while) — teleport the alive bots next to a random alive CT human so the wave can // always be finished without the squad having to chase the last stragglers. Avoids "the match ended but bots are alive". private void NudgeStalledBots(double now) { _lastNudgeAt = now; var targets = CtHumans().Where(h => h.PawnIsAlive && h.PlayerPawn.Value?.AbsOrigin is not null).ToList(); if (targets.Count == 0) return; int moved = 0; foreach (var b in Utilities.GetPlayers()) { if (!OutnumberedPlugin.IsLiveBot(b)) continue; var botPawn = b.PlayerPawn.Value; var dest = targets[Random.Shared.Next(targets.Count)].PlayerPawn.Value?.AbsOrigin; if (botPawn is null || dest is null) continue; // a modest offset around the player so they don't all telefrag the same point (small maps are open enough) var pos = new Vector(dest.X + Random.Shared.Next(-128, 128), dest.Y + Random.Shared.Next(-128, 128), dest.Z + 24); botPawn.Teleport(pos, null, new Vector(0, 0, 0)); moved++; } if (moved > 0) _p.LogSurvival($"wave {_wave} stalled (no kill {_p.Config.Survival.StallNudgeSeconds:0}s) — pulled {moved} bot(s) to the squad"); } // HUD line for the active run: wave number + bots remaining to clear it (or the break state). "" when no run. // Surfaced to the core via IMatchDriver.HudStatusLine so the HUD never type-checks the concrete driver. public string HudStatusLine(PlayerData pd) => WaveHud(); private string WaveHud() { if (!_runActive) return ""; int cap = _p.Config.Survival.WaveCount; if (_phase == WavePhase.Break) return $"WAVE {_highestWaveCleared}/{cap} CLEARED — break"; return $"WAVE {_wave}/{cap} — {Math.Max(0, _waveBudget - _killsThisWave)} bots left"; } // ---- revive ---- private void ReviveDead() { if (!_p.Config.Survival.ReviveOnWaveClear) return; foreach (var p in CtHumans()) if (!p.PawnIsAlive) _p.ReviveSurvivor(p); } // ---- the draft ---- private void GrantCards() { int per = Math.Max(1, _p.Config.Survival.CardsPerWave); bool reviveOn = _p.Config.Survival.ReviveOnWaveClear; foreach (var p in CtHumans()) { // Hardcore (no revive): a permanently-dead player can never open/spend a pick — don't bank it or spam the chat. // (In revive mode ReviveDead ran just above, so the dead are revived; gating on reviveOn covers the respawn lag.) if (!p.PawnIsAlive && !reviveOn) continue; var pd = _p.PdOf(p); if (pd is null) continue; var run = Run(pd); if (!HasDraftableCard(run)) continue; // every card already maxed -> don't bank a pick that can never be spent run.RunPoints += per; EnsureDraw(run); // keep the existing offer if still valid (anti-cheese: skipping a wave must NOT reroll the draft) // Show the running total (incl. any banked from missed breaks) so the player knows what's waiting. p.PrintToChat($" {ChatColors.Gold}[Survival] {ChatColors.Lime}{run.RunPoints}{ChatColors.Default} card pick(s) available — press {ChatColors.Gold}X{ChatColors.Default} to draft (unspent picks carry over)."); } } // Any card still under its cap (per-player OR the shared team level) — gates the draft so it never auto-pops a // frozen, empty overlay once everything's maxed. private bool HasDraftableCard(SurvivalRun run) => _p.Config.Survival.Cards.Any(c => !string.IsNullOrWhiteSpace(c.Key) && CardCount(run, c.Key) < c.Cap); private void Redraw(SurvivalRun run) { var cfg = _p.Config.Survival; var pool = cfg.Cards.Where(c => !string.IsNullOrWhiteSpace(c.Key) && CardCount(run, c.Key) < c.Cap).ToList(); int n = Math.Min(Math.Min(Math.Max(1, cfg.DraftSize), 3), pool.Count); // cap at 3 — the overlay only renders 3 panels run.DrawnThisBreak = pool.OrderBy(_ => Random.Shared.Next()).Take(n).Select(c => c.Key).ToList(); } // Ensure the player has a draftable hand WITHOUT rerolling a still-valid one. ANTI-CHEESE: banking a pick across a // wave must NOT reroll the offer (else a player skips waves to fish for the best cards). So only (re)draw when the // current hand has no still-draftable card left — empty, or every drawn card is now maxed (e.g. a team card others // maxed). The drawn hand persists on SurvivalRun across breaks, alongside the banked RunPoints. private void EnsureDraw(SurvivalRun run) { if (run.RunPoints <= 0) return; bool hasValid = run.DrawnThisBreak.Any(k => CardDef(k) is { } d && CardCount(run, k) < d.Cap); if (!hasValid && HasDraftableCard(run)) Redraw(run); } // ---- shop-facing draft API ---- public bool DraftPending(CCSPlayerController p) { if (!_runActive || _phase != WavePhase.Break) return false; var pd = _p.PdOf(p); return pd is not null && _runs.TryGetValue(pd.SteamId, out var run) && run.RunPoints > 0 && HasDraftableCard(run); } // Unspent draft picks the player has banked (spendable in any break) — surfaced in the draft menu header. public int PendingCards(CCSPlayerController p) { var pd = _p.PdOf(p); return pd is not null && _runs.TryGetValue(pd.SteamId, out var run) ? run.RunPoints : 0; } // View data for one draft card (CardView lives in Modes.cs alongside IDraftDriver): name, current/cap picks, and // either the current->next % value (the original stat cards) OR a custom Detail line (the effect cards). public CardView? CardInfo(CCSPlayerController p, string key) { var pd = _p.PdOf(p); if (pd is null || !_runs.TryGetValue(pd.SteamId, out var run)) return null; var def = CardDef(key); if (def is null) return null; int have = CardCount(run, key); // per-player picks, or the shared team level bool flat = def.Flat; // flat points (HP/armor caps + flat regen) vs % — data-driven double next = (have + 1) * def.PerPick; string? detail = EffectCardDetail(key, def, have); // null for the original stat cards -> Now/Next % shown return new CardView(def.Name, have, def.Cap, have * def.PerPick, next, flat, detail); } // A human-readable effect line for the new logic cards (the Now/Next % display is meaningless for these). Values are // shown AFTER the next pick (level have+1). Team cards COMPOUND (match RecomputeTeamMults), so their detail shows the // real compounded total, not the additive sum. Returns null for the 12 stat cards (they keep the +Now% -> +Next% panel). private string? EffectCardDetail(string key, SurvivalCardDef def, int have) { int lvl = have + 1; // value after the next pick double add = lvl * def.PerPick; // linear per-pick total (the per-player leveled cards) // Team cards COMPOUND, so show the real compounded total (not the additive sum); the deal/take direction is the // only inherently-2-card distinction left. if (def.IsTeam) return key == CardKeys.GlobalTake ? $"squad -{(1.0 - SurvivalEconomy.TeamMult(lvl, def.PerPick, increase: false)) * 100.0:0}% dmg taken" : $"squad +{(SurvivalEconomy.TeamMult(lvl, def.PerPick, increase: true) - 1.0) * 100.0:0}% dmg dealt"; // Burn's magnitude comes from the Burn* knobs (not the card PerPick), so it can't be a {0} template. if (key == CardKeys.Burn) { var cfg = _p.Config.Survival; return $"{cfg.BurnDamagePerSecond:0} HP/s for {cfg.BurnDurationSeconds:0}s"; } // Everything else: the data-driven Detail template ({0} = level x PerPick). Empty -> stat card -> Now/Next % panel. return string.IsNullOrEmpty(def.Detail) ? null : string.Format(def.Detail, add); } // The current offered draw as (cardKey, displayLabel) — stable across a reopen within the same break (anti-reroll). public List<(string key, string label)> CurrentDraw(CCSPlayerController p) { var res = new List<(string, string)>(); var pd = _p.PdOf(p); if (pd is null || !_runs.TryGetValue(pd.SteamId, out var run)) return res; foreach (var key in run.DrawnThisBreak) { var def = CardDef(key); if (def is null) continue; res.Add((key, $"{def.Name} [{CardCount(run, key)}/{def.Cap}]")); } return res; } public void PickCard(CCSPlayerController p, string key) { var pd = _p.PdOf(p); if (pd is null || !_runs.TryGetValue(pd.SteamId, out var run) || run.RunPoints <= 0) return; if (!run.DrawnThisBreak.Contains(key)) return; var def = CardDef(key); if (def is null || CardCount(run, key) >= def.Cap) return; // already maxed (per-player OR the shared team level) run.RunPoints--; if (IsTeamCard(key)) // squad-wide: bump the shared level, recompute the team multiplier, tell everyone { if (key == CardKeys.GlobalDeal) _teamDealLevel++; else _teamTakeLevel++; RecomputeTeamMults(); int lvl = CardCount(run, key); Server.PrintToChatAll($" {ChatColors.Gold}[Survival] {ChatColors.Lime}{p.PlayerName} {ChatColors.Default}raised {ChatColors.Lime}{def.Name} {ChatColors.Default}-> team {lvl}/{def.Cap}"); if (lvl >= def.Cap) foreach (var r in _runs.Values) r.DrawnThisBreak.RemoveAll(k => k == key); // pull the now-maxed team card from every hand } else { run.Cards[key] = run.Cards.GetValueOrDefault(key) + 1; p.PrintToChat($" {ChatColors.Gold}[Survival] {ChatColors.Lime}{def.Name} {ChatColors.Default}-> {run.Cards[key]}/{def.Cap} ({run.RunPoints} pick(s) left)"); // ONLY a Max-HP/Max-Armor card touches the live pawn: raise the cap + top up by the card's bonus. NO full heal // on any pick (that would erase the 50% revive penalty and reset the squad to full HP every wave). if (key == StatKeys.MaxHp || key == StatKeys.MaxArmor) _p.GrantCardCapBonus(p, key, (int)def.PerPick); } if (run.RunPoints > 0) Redraw(run); else run.DrawnThisBreak.Clear(); } // ---- helpers ---- private SurvivalRun Run(PlayerData pd) { if (!_runs.TryGetValue(pd.SteamId, out var r)) { r = new SurvivalRun(CardDef); _runs[pd.SteamId] = r; } return r; } // Bank raw combat XP into the player's CURRENT-wave accumulator (granted at wave clear). Called from GrantCombatXp. // The xp_mult card (+% run-XP, per-player) scales the RAW accumulation here, so it compounds with the per-wave // prestige x waveMult chain applied at grant time. public void AccumulateWaveXp(PlayerData pd, double amount) { if (!_runActive || amount <= 0) return; Run(pd).WaveXp += SurvivalEconomy.AccrueWaveXp(amount, StatBonus(pd, CardKeys.XpMult)); } private SurvivalCardDef? CardDef(string key) { foreach (var c in _p.Config.Survival.Cards) if (c.Key == key) return c; return null; } private static IEnumerable CtHumans() => Utilities.GetPlayers().Where(p => OutnumberedPlugin.IsHuman(p) && p.Team == CsTeam.CounterTerrorist); private static int AliveCtHumans() => CtHumans().Count(p => p.PawnIsAlive); private static int BotCount() => Utilities.GetPlayers().Count(OutnumberedPlugin.IsBot); // The connected controller for a SteamId (any team) — for the run-end chat line; null if they've left. private static CCSPlayerController? ControllerFor(ulong sid) => Utilities.GetPlayers().FirstOrDefault(p => OutnumberedPlugin.IsHuman(p) && p.AuthorizedSteamID?.SteamId64 == sid); } // Plugin-side survival helpers (per-wave XP grant, revive, HP reapply) — here so they can reach the private XP/stat math. public sealed partial class OutnumberedPlugin { // Diagnostic logging for the survival wave machine (console: "[Survival] ..."). internal void LogSurvival(string msg) => Logger.LogInformation("[Survival] {Msg}", msg); // Grant ONE cleared wave's XP into the main table: rawWaveXp x prestige x waveMult(wave), NO cap, handicap-mult // EXCLUDED (anti-launder: run XP must not re-couple to gameable K/D; anti-carry is automatic since waveXp is each // player's OWN attributed damage). Per-wave so players level + earn skill points mid-run, and the XP lands in the main // table immediately (a disconnect keeps every CLEARED wave — there's no separate run accumulator to restore). internal void BankWaveXp(PlayerData pd, double waveXp, int wave, CCSPlayerController? p) { if (waveXp <= 0) return; var cfg = Config.Survival; long lump = SurvivalEconomy.WaveXpLump(waveXp, wave, pd.Prestige, cfg, Config.Progression); if (lump <= 0) return; AddConvertedXp(pd, lump, p); p?.PrintToChat($" {ChatColors.Gold}[Survival] Wave {wave} XP: {ChatColors.Lime}+{lump:n0} {ChatColors.Default}(x{SurvivalEconomy.WaveMult(wave, cfg):F1})."); } // Revive a downed survivor at the configured HP% (used between waves). mp_respawn_on_death_ct 0 keeps them down, // so this is a manual Respawn; they're still on CT (never moved to spectator), so no team churn. internal void ReviveSurvivor(CCSPlayerController p) { if (p is not { IsValid: true } || p.IsBot || p.PawnIsAlive || p.Team != CsTeam.CounterTerrorist) return; if (p.AuthorizedSteamID?.SteamId64 is not { } sid) return; double pct = Config.Survival.ReviveHpPercent; p.Respawn(); // Defer through the seam: re-resolve by slot + pin SteamID so a within-frame slot-reuse can't revive a different player. NextFrameForSlot(p.Slot, sid, pl => { var pd = PdOf(pl); if (pd is null) return; ApplyMaxHpArmor(pl, pd); var pawn = pl.PlayerPawn.Value; if (pawn is not null) PawnWriter.SetHealth(pawn, Math.Max(1, (int)(MaxHpOf(pd) * pct))); }, requireAlive: true); } // A survival Max-HP / Max-Armor card just took effect: raise the live cap and top current HP/armor up by the card's // bonus ONLY — never a full heal (a full heal on any pick would erase the 50% revive penalty and reset the squad to // full HP every wave). Other cards never touch current HP/armor. internal void GrantCardCapBonus(CCSPlayerController p, string statKey, int bonus) { if (bonus <= 0) return; var pd = PdOf(p); var pawn = p.PlayerPawn.Value; if (pd is null || pawn is null || !p.PawnIsAlive) return; if (statKey == StatKeys.MaxHp) { int maxHp = MaxHpOf(pd); // already includes the just-picked card (run.Cards incremented first) PawnWriter.SetMaxHealth(p, pawn, maxHp); PawnWriter.SetHealth(pawn, Math.Min(maxHp, pawn.Health + bonus)); } else if (statKey == StatKeys.MaxArmor) { PawnWriter.SetArmor(pawn, Math.Min(MaxArmorOf(pd), pawn.ArmorValue + bonus)); } } // PlayerData for a SteamId (the survival run-end bank resolves participants by id, not current team). internal PlayerData? PdBySteamId(ulong sid) => _players.TryGetValue(sid, out var pd) ? pd : null; }