980 lines
58 KiB
C#
980 lines
58 KiB
C#
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<ulong, SurvivalRun> _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;
|
|
|
|
// Directed bot spawn placement (replaces mp_randomspawn): map spawn-point origins gathered once per map, so each streamed
|
|
// -in bot lands away from the squad instead of on their heads. Entity names span the DM/arms-race/classic spawn types;
|
|
// whichever the map actually has is used. Empty (no such entities) => placement is skipped (bots keep their team spawn).
|
|
private static readonly string[] SpawnEntityNames =
|
|
["info_deathmatch_spawn", "info_player_terrorist", "info_player_counterterrorist", "info_armsrace_counter", "info_armsrace_terrorist"];
|
|
private readonly List<Vector> _spawnPoints = [];
|
|
|
|
// ---- Field Medic teammate-revive (card-only: no FieldMedic card => none of this runs) ----
|
|
private CounterStrikeSharp.API.Modules.Timers.Timer? _reviveTick; // 0.1s heartbeat: proximity detect + channel progress (finer than the 0.5s WaveTick for a smooth bar/locator)
|
|
private readonly Dictionary<ulong, DownedInfo> _downed = []; // downed player's SteamId -> death spot + world beacon (created only while the squad has a medic)
|
|
private readonly Dictionary<ulong, ChannelState> _channels = []; // medic's SteamId -> the revive they're currently channeling
|
|
private readonly HashSet<ulong> _revivedForXpThisWave = []; // victims already paid a support-XP bounty this wave (one per saved teammate; reset at wave start)
|
|
private const double ReviveEdgeFrac = 0.82; // within this fraction of the radius = fine; beyond it = the amber "about to exit" warn
|
|
|
|
// A downed, revivable teammate: the death spot (revive lands them here, next to the reviver) + the world beacon over it.
|
|
private sealed class DownedInfo
|
|
{
|
|
public required Vector Pos { get; init; }
|
|
public required string Name { get; init; }
|
|
public bool KilledByBot { get; init; } // combat death (bot kill) vs suicide/fall/world — gates the reviver's XP bounty
|
|
public CPointWorldText? Beacon; // field, not property, so WorldText.Destroy(ref ...) can null it
|
|
public string LastText = ""; // last beacon text pushed (skip redundant SetMessage)
|
|
public void DestroyBeacon() => WorldText.Destroy(ref Beacon);
|
|
}
|
|
|
|
// One medic's in-progress channel (time-based: progress = (now - StartedAt) / channelSeconds, so it's smooth regardless of tick jitter).
|
|
private struct ChannelState
|
|
{
|
|
public ulong TargetSid;
|
|
public double StartedAt;
|
|
public bool NearEdge; // drifting toward the radius edge -> HUD amber warn
|
|
public bool WarnedEdge; // edge warn sound already played this crossing (one-shot)
|
|
}
|
|
|
|
// ---- IMatchDriver: identity ----
|
|
public string Id => "survival";
|
|
public IReadOnlyList<string> 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).
|
|
// mp_randomspawn OFF: bot spawn placement is done ourselves (DirectedBotSpawn -> a map spawn point away from the squad),
|
|
// because mp_randomspawn ignores distance-to-player and drops clusters on your head once ~20 bots are alive at once.
|
|
public string ExtraCvars => "mp_randomspawn 0;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).
|
|
// Field Medic: if the squad has a medic, mark this body revivable — capture the death spot (same corpse-position read the
|
|
// explode-on-kill card does in the death hook) and drop a beacon so a teammate can channel a mid-wave revive. Then run
|
|
// wipe detection next frame (count after the pawn is actually down; a downed-awaiting-revive player counts as not-alive,
|
|
// so an all-down squad still wipes — you can't revive if nobody's up).
|
|
public void OnHumanDeath(CCSPlayerController victim, PlayerData pd, CCSPlayerController? attacker)
|
|
{
|
|
if (!_runActive) return;
|
|
// Field Medic: register a revivable beacon, but ONLY for a CT participant (gate on victim.Team==CT, read NOW since
|
|
// team can change by the deferred frame) — this excludes a bug-2 spectator-park suicide of a mid-run T-joiner, which
|
|
// would otherwise become a revivable entry that pulls a non-participant onto CT (fail-closed bypass). killedByBot
|
|
// (captured here) gates the reviver's XP bounty so a suicide/fall can't be farmed. Capture the death spot NOW (a safe
|
|
// read, like the explode-on-kill card in this Pre hook) but DEFER the beacon ENTITY creation out of the Pre hook.
|
|
bool killedByBot = OutnumberedPlugin.IsBot(attacker); // PvE: only bots kill humans (FF is off); null/self => not combat
|
|
if (_phase == WavePhase.Fighting && victim.Team == CsTeam.CounterTerrorist && SquadHasMedic()
|
|
&& !_downed.ContainsKey(pd.SteamId) && victim.PlayerPawn.Value?.AbsOrigin is { } corpse)
|
|
{
|
|
var pos = new Vector(corpse.X, corpse.Y, corpse.Z);
|
|
string name = victim.PlayerName;
|
|
ulong sid = pd.SteamId;
|
|
Server.NextFrame(() =>
|
|
{
|
|
// re-check the run is live + they didn't disconnect in the death->frame gap (else a ghost beacon wastes a charge)
|
|
if (_runActive && _phase == WavePhase.Fighting && !_downed.ContainsKey(sid) && ControllerFor(sid) is not null)
|
|
_downed[sid] = new DownedInfo { Pos = pos, Name = name, KilledByBot = killedByBot, Beacon = CreateBeacon(pos) };
|
|
});
|
|
}
|
|
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. Drop the run + any downed/channel state they own.
|
|
public void OnHumanDisconnect(ulong steamId, PlayerData pd)
|
|
{
|
|
_runs.Remove(steamId);
|
|
_channels.Remove(steamId); // if they were mid-channel as a medic
|
|
if (_downed.Remove(steamId, out var info)) info.DestroyBeacon(); // if they were the one downed
|
|
}
|
|
|
|
// ---- 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)
|
|
_reviveTick ??= _p.AddTimer(0.1f, ReviveTick, TimerFlags.REPEAT); // finer revive-channel heartbeat (proximity + progress)
|
|
}
|
|
|
|
// Plugin Unload / hot-reload: kill the heartbeats so they can't leak or double-fire against a torn-down instance
|
|
// (a fresh driver schedules its own on the next Load), and destroy any live beacon entities. The framework doesn't
|
|
// reliably auto-kill plugin timers or our entities.
|
|
// Also drop _runActive/_phase so any Server.NextFrame closure still queued at teardown (beacon-create / wipe-check) fails
|
|
// its guard against this discarded instance instead of spawning an orphan beacon a fresh driver never learns about.
|
|
public void OnDeactivated() { _tick?.Kill(); _tick = null; _reviveTick?.Kill(); _reviveTick = null; _runActive = false; _phase = WavePhase.Idle; ClearRevives(); }
|
|
|
|
// 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;
|
|
GatherSpawnPoints(); // map entities are up by now (SetupMap runs ~3s after map start) -> cache bot spawn origins
|
|
}
|
|
|
|
public void OnMatchReset()
|
|
{
|
|
_runs.Clear(); ResetTeamBuffs(); ClearRevives();
|
|
_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
|
|
RefreshMedicCharges(); // Field Medic revives refresh each wave (scaled by the medic's card level)
|
|
_revivedForXpThisWave.Clear(); // support-XP bounties reset per wave (one per saved teammate)
|
|
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<ulong>? 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();
|
|
ClearRevives(); // the wave is over: ReviveDead brought back the downed (non-hardcore); in hardcore they're out. Either way the beacons/channels go.
|
|
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(); ClearRevives();
|
|
_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");
|
|
}
|
|
|
|
// ---- Field Medic teammate-revive: the proximity channel machine ----
|
|
// Card-only: a downed teammate drops a world beacon; a medic (someone who drafted FieldMedic) who stands within
|
|
// ReviveRadius channels a revive over a level-scaled time — no keypress, proximity IS the input. Runs on the 0.1s
|
|
// _reviveTick so the bar/locator are smooth. All downed/beacon state is torn down at wave clear / run end / reset.
|
|
private readonly List<ulong> _channelReap = []; // reusable scratch for pruning stale channels (avoids a per-tick Keys alloc)
|
|
private const int BarCells = 10; // progress-bar width in glyph cells
|
|
private const float BeaconRise = 45f; // how far above the death spot the beacon floats
|
|
private const double MetersDivisor = 40.0; // units -> "m" for the locator readout (~player height per metre)
|
|
|
|
// The medic's FieldMedic card level (picks); 0 = not a medic.
|
|
private static int MedicLevel(SurvivalRun run) => run.Cards.GetValueOrDefault(CardKeys.FieldMedic);
|
|
|
|
// Anyone in the run drafted Field Medic? Gates beacon creation so a no-medic squad gets no useless "down" markers.
|
|
private bool SquadHasMedic()
|
|
{
|
|
foreach (var run in _runs.Values) if (MedicLevel(run) > 0) return true;
|
|
return false;
|
|
}
|
|
|
|
// Refresh every medic's per-wave revive charges from their card level (called at StartWave; use-them-or-lose-them).
|
|
private void RefreshMedicCharges()
|
|
{
|
|
foreach (var run in _runs.Values)
|
|
run.ReviveCharges = SurvivalEconomy.ReviveChargesForWave(MedicLevel(run), _p.Config.Survival);
|
|
}
|
|
|
|
// Tear down ALL downed/channel state + destroy every beacon entity (wave clear, run end, match reset, hot-reload).
|
|
private void ClearRevives()
|
|
{
|
|
foreach (var info in _downed.Values) info.DestroyBeacon();
|
|
_downed.Clear();
|
|
_channels.Clear();
|
|
}
|
|
|
|
private CPointWorldText? CreateBeacon(Vector pos)
|
|
{
|
|
// A world-ANCHORED panel (unlike the eye-relative HUD/shop panels) — squad-visible (no CheckTransmit hiding). The
|
|
// orientation is refreshed each tick in RefreshBeacons to face the nearest teammate.
|
|
var ent = WorldText.Create(48f, 0.35f, _p.Config.Hud.FontName,
|
|
System.Drawing.Color.FromArgb(255, 255, 80, 80), drawBackground: true, border: 0.15f,
|
|
PointWorldTextJustifyHorizontal_t.POINT_WORLD_TEXT_JUSTIFY_HORIZONTAL_CENTER);
|
|
if (ent is not null) WorldText.Place(ent, new Vector(pos.X, pos.Y, pos.Z + BeaconRise), new QAngle(0, 0, 90));
|
|
return ent;
|
|
}
|
|
|
|
// 0.1s heartbeat: advance every alive medic's proximity channel, then keep the beacons oriented + labelled.
|
|
private void ReviveTick()
|
|
{
|
|
if (!_ready) return;
|
|
if (!_runActive || _phase != WavePhase.Fighting || _downed.Count == 0)
|
|
{
|
|
if (_channels.Count > 0) _channels.Clear(); // no active reviving outside a fighting wave with downed players
|
|
return;
|
|
}
|
|
double now = Server.CurrentTime;
|
|
foreach (var medic in CtHumans())
|
|
if (medic.PawnIsAlive) ProcessMedic(medic, now);
|
|
PruneChannels();
|
|
RefreshBeacons();
|
|
}
|
|
|
|
// One medic's channel step: nearest downed teammate in range -> (re)start / advance / complete the channel, or drop it.
|
|
private void ProcessMedic(CCSPlayerController medic, double now)
|
|
{
|
|
var pd = _p.PdOf(medic);
|
|
if (pd is null) return;
|
|
var pawn = medic.PlayerPawn.Value;
|
|
if (!_runs.TryGetValue(pd.SteamId, out var run) || run.ReviveCharges <= 0 || MedicLevel(run) <= 0
|
|
|| pawn?.AbsOrigin is not { } mpos)
|
|
{ _channels.Remove(pd.SteamId); return; }
|
|
|
|
double radius = _p.Config.Survival.ReviveRadius, r2 = radius * radius;
|
|
var (best, bestD2) = NearestDowned(mpos, pd.SteamId);
|
|
if (best == 0 || bestD2 > r2) { _channels.Remove(pd.SteamId); return; } // nobody in range -> the channel drops
|
|
|
|
if (!_channels.TryGetValue(pd.SteamId, out var ch) || ch.TargetSid != best)
|
|
ch = new ChannelState { TargetSid = best, StartedAt = now }; // new target -> restart the channel
|
|
ch.NearEdge = bestD2 > r2 * ReviveEdgeFrac * ReviveEdgeFrac;
|
|
if (ch.NearEdge && !ch.WarnedEdge) { _p.PlayReviveWarn(medic); ch.WarnedEdge = true; } // one-shot edge beep
|
|
else if (!ch.NearEdge) ch.WarnedEdge = false;
|
|
_channels[pd.SteamId] = ch;
|
|
|
|
double dur = SurvivalEconomy.ReviveChannelSeconds(MedicLevel(run), _p.Config.Survival);
|
|
if (dur <= 0 || now - ch.StartedAt >= dur) CompleteRevive(medic, pd, run, best);
|
|
}
|
|
|
|
private void CompleteRevive(CCSPlayerController medic, PlayerData medicPd, SurvivalRun run, ulong targetSid)
|
|
{
|
|
_channels.Remove(medicPd.SteamId);
|
|
if (!_downed.Remove(targetSid, out var info)) return; // someone else revived them this tick -> don't spend a charge
|
|
run.ReviveCharges--;
|
|
double hpPct = SurvivalEconomy.ReviveHpFraction(MedicLevel(run), _p.Config.Survival);
|
|
var target = ControllerFor(targetSid);
|
|
// Land them on the REVIVER's spot (guaranteed-walkable ground the medic is standing on), not the raw death spot —
|
|
// which could be a hazard/kill-volume that would just re-drop and re-kill them, burning charges for nothing.
|
|
var landing = medic.PlayerPawn.Value?.AbsOrigin is { } mo ? new Vector(mo.X, mo.Y, mo.Z) : info.Pos;
|
|
_p.ReviveDownedAt(target, landing, hpPct); // respawn + teleport beside the reviver + set HP
|
|
info.DestroyBeacon();
|
|
_p.PlayReviveComplete(medic);
|
|
// Support-XP: a FLAT bounty (NOT routed through WaveXp, so it never rides the x24 wave multiplier), and ONLY for a
|
|
// genuine COMBAT death, ONCE per saved teammate per wave — rewards saving allies, not a death<->revive farm loop.
|
|
if (info.KilledByBot && _revivedForXpThisWave.Add(targetSid)) _p.GrantReviveXp(medicPd, medic);
|
|
string tname = target?.PlayerName ?? info.Name;
|
|
Server.PrintToChatAll($" {ChatColors.Green}[Survival] {ChatColors.Lime}{medic.PlayerName} {ChatColors.Default}revived {ChatColors.Lime}{tname}{ChatColors.Default}!");
|
|
_p.LogSurvival($"revive: {medic.PlayerName} -> {tname} @ {hpPct:P0} ({run.ReviveCharges} charge(s) left)");
|
|
}
|
|
|
|
// Drop channel state for medics who died / disconnected since the last tick (so a dead medic's channel can't keep a
|
|
// beacon falsely reading "reviving"). ProcessMedic already drops channels for out-of-range / out-of-charge medics.
|
|
private void PruneChannels()
|
|
{
|
|
if (_channels.Count == 0) return;
|
|
_channelReap.Clear();
|
|
foreach (var sid in _channels.Keys)
|
|
{
|
|
var c = ControllerFor(sid);
|
|
if (c is null || !c.PawnIsAlive) _channelReap.Add(sid);
|
|
}
|
|
foreach (var sid in _channelReap) _channels.Remove(sid);
|
|
}
|
|
|
|
// Keep each beacon facing the nearest teammate (mirrors the HUD's face-the-viewer angle convention) + reflect whether
|
|
// it's actively being revived. Beacons are shared entities, so they can only face ONE viewer — the nearest is the bet.
|
|
private void RefreshBeacons()
|
|
{
|
|
foreach (var kv in _downed)
|
|
{
|
|
var info = kv.Value;
|
|
if (info.Beacon is not { IsValid: true } b) continue;
|
|
var top = new Vector(info.Pos.X, info.Pos.Y, info.Pos.Z + BeaconRise);
|
|
// face the nearest teammate — the +270 convention is single-sourced in WorldText.FacePointAngle.
|
|
if (NearestAliveHuman(info.Pos)?.PlayerPawn.Value?.AbsOrigin is { } vp)
|
|
WorldText.Place(b, top, WorldText.FacePointAngle(top, vp));
|
|
string txt = ChannelTargets(kv.Key) ? $"⚕ {info.Name} — reviving…" : $"⚕ {info.Name}";
|
|
if (info.LastText != txt) { WorldText.SetText(b, txt); info.LastText = txt; }
|
|
}
|
|
}
|
|
|
|
private bool ChannelTargets(ulong downedSid)
|
|
{
|
|
foreach (var c in _channels.Values) if (c.TargetSid == downedSid) return true;
|
|
return false;
|
|
}
|
|
|
|
// The per-player HUD line (IMatchDriver): channel bar while reviving, edge warn while drifting, else the locator to the
|
|
// nearest downed teammate. "" for non-medics / no downed / out of charges. html = center-HTML (font tags) vs world text.
|
|
public string ReviveHudLine(CCSPlayerController p, PlayerData pd, bool html)
|
|
{
|
|
if (!_runActive || _phase != WavePhase.Fighting || _downed.Count == 0) return "";
|
|
if (!_runs.TryGetValue(pd.SteamId, out var run) || MedicLevel(run) <= 0) return ""; // not a medic
|
|
|
|
// channeling right now -> the bar (or the edge warning)
|
|
if (_channels.TryGetValue(pd.SteamId, out var ch) && _downed.TryGetValue(ch.TargetSid, out var tgt))
|
|
{
|
|
if (ch.NearEdge) return WarnLine(html);
|
|
double dur = SurvivalEconomy.ReviveChannelSeconds(MedicLevel(run), _p.Config.Survival);
|
|
double prog = dur <= 0 ? 1.0 : Math.Clamp((Server.CurrentTime - ch.StartedAt) / dur, 0.0, 1.0);
|
|
return BarLine(tgt.Name, prog, html);
|
|
}
|
|
if (run.ReviveCharges <= 0) return ""; // no charges left this wave -> nothing to prompt
|
|
|
|
// otherwise: locate the nearest downed teammate (arrow + distance)
|
|
if (p.PlayerPawn.Value is not { } pawn || pawn.AbsOrigin is not { } mpos) return "";
|
|
var (best, bestD2) = NearestDowned(mpos, pd.SteamId);
|
|
if (best == 0) return "";
|
|
var info = _downed[best];
|
|
int meters = SurvivalEconomy.LocatorMeters(Math.Sqrt(bestD2), MetersDivisor);
|
|
return LocatorLine(info.Name, CompassArrow(pawn, info.Pos), meters, html);
|
|
}
|
|
|
|
private static string BarLine(string name, double prog, bool html)
|
|
{
|
|
int filled = (int)Math.Round(Math.Clamp(prog, 0.0, 1.0) * BarCells);
|
|
string bar = new string('█', filled) + new string('░', BarCells - filled); // █ / ░
|
|
int pct = (int)Math.Round(prog * 100);
|
|
return html
|
|
? $"<font color='#7dff7d'>⚕ Reviving {Esc(name)} {bar} {pct}%</font>"
|
|
: $"⚕ Reviving {name} {bar} {pct}%";
|
|
}
|
|
|
|
private static string WarnLine(bool html) =>
|
|
html ? "<font color='#ffb000'>⚠ hold position — revive slipping</font>"
|
|
: "⚠ hold position — revive slipping";
|
|
|
|
private static string LocatorLine(string name, string arrow, int meters, bool html) =>
|
|
html ? $"<font color='#66ddff'>⚕ {Esc(name)} down {arrow} {meters}m</font>"
|
|
: $"⚕ {name} down {arrow} {meters}m";
|
|
|
|
// 8-way compass arrow from the medic's view toward a target. The bearing->sector math is pure + golden-tested in
|
|
// SurvivalEconomy.ArrowIndex; this wrapper only computes the live bearing from the pawn. (If left/right read mirrored
|
|
// in-engine, negate the (bearing - viewYaw) term.)
|
|
private static string CompassArrow(CCSPlayerPawn pawn, Vector target)
|
|
{
|
|
var o = pawn.AbsOrigin;
|
|
if (o is null) return "";
|
|
double bearing = Math.Atan2(target.Y - o.Y, target.X - o.X) * 180.0 / Math.PI;
|
|
string[] arrows = { "↑", "↖", "←", "↙", "↓", "↘", "→", "↗" }; // ↑ ↖ ← ↙ ↓ ↘ → ↗
|
|
return arrows[SurvivalEconomy.ArrowIndex(bearing - pawn.EyeAngles.Y)];
|
|
}
|
|
|
|
private static double Dist2(Vector a, Vector b) { double dx = a.X - b.X, dy = a.Y - b.Y, dz = a.Z - b.Z; return dx * dx + dy * dy + dz * dz; }
|
|
|
|
// Nearest downed teammate to `pos` (excluding `exceptSid` — you can't revive yourself); (0, +inf) if none.
|
|
// Shared by the channel machine (ProcessMedic) and the HUD locator so the "closest body" pick can't drift between them.
|
|
private (ulong sid, double dist2) NearestDowned(Vector pos, ulong exceptSid)
|
|
{
|
|
ulong best = 0; double bestD2 = double.MaxValue;
|
|
foreach (var kv in _downed)
|
|
{
|
|
if (kv.Key == exceptSid) continue;
|
|
double d2 = Dist2(pos, kv.Value.Pos);
|
|
if (d2 < bestD2) { bestD2 = d2; best = kv.Key; }
|
|
}
|
|
return (best, bestD2);
|
|
}
|
|
private static string Esc(string s) => s.Replace("&", "&").Replace("<", "<").Replace(">", ">");
|
|
|
|
private static CCSPlayerController? NearestAliveHuman(Vector pos)
|
|
{
|
|
CCSPlayerController? best = null; double bestD2 = double.MaxValue;
|
|
foreach (var h in CtHumans())
|
|
if (h.PawnIsAlive && h.PlayerPawn.Value?.AbsOrigin is { } o)
|
|
{
|
|
double d2 = Dist2(pos, new Vector(o.X, o.Y, o.Z));
|
|
if (d2 < bestD2) { bestD2 = d2; best = h; }
|
|
}
|
|
return best;
|
|
}
|
|
|
|
// ---- 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) { DirectedBotSpawn(b); alive++; } // respawn + teleport away from the squad (anti-on-head)
|
|
}
|
|
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");
|
|
}
|
|
|
|
// ---- directed spawn placement (anti-clump / anti-on-head, replaces mp_randomspawn) ----
|
|
// Cache every map spawn-point origin once per map. Whichever entity type this map uses is found; if NONE match, the list
|
|
// stays empty and DirectedBotSpawn falls back to the plain respawn (team spawn) — with a warning so the map's spawn entity
|
|
// name can be added. Spawn geometry is static, so gathering once at map setup is enough.
|
|
private void GatherSpawnPoints()
|
|
{
|
|
_spawnPoints.Clear();
|
|
foreach (var name in SpawnEntityNames)
|
|
foreach (var e in Utilities.FindAllEntitiesByDesignerName<CBaseEntity>(name))
|
|
if (e is { IsValid: true } && e.AbsOrigin is { } o)
|
|
_spawnPoints.Add(new Vector(o.X, o.Y, o.Z));
|
|
if (_spawnPoints.Count == 0)
|
|
_p.LogSurvival($"WARN: no bot spawn points found ({string.Join("/", SpawnEntityNames)}) — directed placement disabled, bots use team spawn");
|
|
else
|
|
_p.LogSurvival($"gathered {_spawnPoints.Count} bot spawn points for directed placement");
|
|
}
|
|
|
|
// Respawn a pool bot, then (deferred a frame so the spawn settles) teleport it to a spawn point away from the squad.
|
|
private void DirectedBotSpawn(CCSPlayerController bot)
|
|
{
|
|
bot.Respawn();
|
|
if (_spawnPoints.Count == 0 || _p.Config.Survival.MinSpawnDistance <= 0) return; // no candidates / disabled -> plain respawn
|
|
int slot = bot.Slot;
|
|
OutnumberedPlugin.NextFrameForSlot(slot, b =>
|
|
{
|
|
if (PickSpawnPoint() is { } pos && b.PlayerPawn.Value is { } pawn)
|
|
pawn.Teleport(pos, null, new Vector(0, 0, 0));
|
|
}, requireAlive: true);
|
|
}
|
|
|
|
// A spawn point at least MinSpawnDistance (HORIZONTAL) from every live survivor, picked at random among the valid ones so
|
|
// consecutive spawns scatter (bots approach from varied bearings, not one corner). If the map is too tight for any point
|
|
// to qualify, use the one that maximises the distance to the nearest survivor. null only if there are no spawn points.
|
|
private Vector? PickSpawnPoint()
|
|
{
|
|
if (_spawnPoints.Count == 0) return null;
|
|
var humans = CtHumans().Where(h => h.PawnIsAlive && h.PlayerPawn.Value?.AbsOrigin is not null)
|
|
.Select(h => h.PlayerPawn.Value!.AbsOrigin!).ToList();
|
|
if (humans.Count == 0) return _spawnPoints[Random.Shared.Next(_spawnPoints.Count)]; // nobody to avoid -> any point
|
|
double min2 = _p.Config.Survival.MinSpawnDistance * _p.Config.Survival.MinSpawnDistance;
|
|
var valid = new List<Vector>();
|
|
Vector? farthest = null;
|
|
double farthestNear2 = -1;
|
|
foreach (var sp in _spawnPoints)
|
|
{
|
|
double near2 = double.MaxValue; // squared distance to the CLOSEST survivor
|
|
foreach (var h in humans)
|
|
{
|
|
double dx = sp.X - h.X, dy = sp.Y - h.Y; // horizontal only (a point above/below is still "near")
|
|
double d2 = dx * dx + dy * dy;
|
|
if (d2 < near2) near2 = d2;
|
|
}
|
|
if (near2 >= min2) valid.Add(sp);
|
|
if (near2 > farthestNear2) { farthestNear2 = near2; farthest = sp; }
|
|
}
|
|
return valid.Count > 0 ? valid[Random.Shared.Next(valid.Count)] : farthest;
|
|
}
|
|
|
|
// 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";
|
|
}
|
|
// Field Medic scales off the Revive* knobs (charges/wave, channel time, revive HP%), not the card PerPick.
|
|
if (key == CardKeys.FieldMedic)
|
|
{
|
|
var cfg = _p.Config.Survival;
|
|
return $"revive allies · {SurvivalEconomy.ReviveChargesForWave(lvl, cfg)}/wave · {SurvivalEconomy.ReviveChannelSeconds(lvl, cfg):0.0}s · {SurvivalEconomy.ReviveHpFraction(lvl, cfg) * 100:0}% HP";
|
|
}
|
|
// 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<CCSPlayerController> 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}).");
|
|
}
|
|
|
|
// Shared revive core: respawn a dead CT ally, then (deferred, slot+SteamID-pinned so a within-frame slot-reuse can't hit
|
|
// the wrong player) optionally teleport them + set HP to hpPct of their max. mp_respawn_on_death_ct 0 keeps them down, so
|
|
// Respawn is manual. CT-only by construction (a non-CT target is refused, not force-switched) so a stray non-participant
|
|
// entry can never be pulled onto CT. The two revive flavors below own only their guard/pct/teleport specifics.
|
|
private void ReviveAt(CCSPlayerController p, double hpPct, Vector? teleportTo)
|
|
{
|
|
if (p is not { IsValid: true } || p.IsBot || p.PawnIsAlive || p.Team != CsTeam.CounterTerrorist) return;
|
|
if (p.AuthorizedSteamID?.SteamId64 is not { } sid) return;
|
|
var dest = teleportTo is { } t ? new Vector(t.X, t.Y, t.Z) : null;
|
|
p.Respawn();
|
|
NextFrameForSlot(p.Slot, sid, pl =>
|
|
{
|
|
var pd = PdOf(pl); var pawn = pl.PlayerPawn.Value;
|
|
if (pd is null || pawn is null) return;
|
|
if (dest is not null) pawn.Teleport(dest, null, new Vector(0, 0, 0));
|
|
ApplyMaxHpArmor(pl, pd);
|
|
PawnWriter.SetHealth(pawn, Math.Max(1, (int)(MaxHpOf(pd) * hpPct)));
|
|
}, requireAlive: true);
|
|
}
|
|
|
|
// The automatic wave-clear revive: respawn in place (at a spawn point), configured HP%.
|
|
internal void ReviveSurvivor(CCSPlayerController p) => ReviveAt(p, Config.Survival.ReviveHpPercent, null);
|
|
|
|
// The Field Medic teammate-revive: respawn + teleport beside the reviver at a medic-scaled HP%. No-op if the target left.
|
|
internal void ReviveDownedAt(CCSPlayerController? p, Vector pos, double hpPct)
|
|
{
|
|
if (p is not null) ReviveAt(p, hpPct, pos);
|
|
}
|
|
|
|
// A FLAT support-XP bounty for a Field Medic revive: straight into the main table (NOT the per-wave accumulator, so it
|
|
// never rides the wave multiplier), prestige/handicap excluded — a fixed reward for saving a teammate, not a farm.
|
|
internal void GrantReviveXp(PlayerData pd, CCSPlayerController medic)
|
|
{
|
|
long lump = (long)Config.Survival.ReviveXp;
|
|
if (lump > 0) AddConvertedXp(pd, lump, medic);
|
|
}
|
|
|
|
internal void PlayReviveComplete(CCSPlayerController p) => PlaySound(p, Config.Sounds.ReviveComplete);
|
|
internal void PlayReviveWarn(CCSPlayerController p) => PlaySound(p, Config.Sounds.ReviveWarn);
|
|
|
|
// 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;
|
|
}
|