using System.Reflection; using CounterStrikeSharp.API; using CounterStrikeSharp.API.Core; using CounterStrikeSharp.API.Modules.Timers; using CounterStrikeSharp.API.Modules.Utils; using Microsoft.Extensions.Logging; using Outnumbered.Engine; namespace Outnumbered; // Survival "effect" card subsystems that don't belong to a single hook: the Burn DoT registry + tick, and the // Explode-on-Kill real-grenade spawn. Both are survival-only by gating (the cards only exist there) but the code is // mode-agnostic. The native projectile-create + the raw attributed hit live in Engine (GrenadeSpawner / DamageDealer); // this owns only the burn registry + per-tick bookkeeping. Wired from Outnumbered.cs (Initialize_Effects / Shutdown_Effects). public sealed partial class OutnumberedPlugin { // Burn DoT: keyed by (bot slot, attacker slot) so each attacker burns a bot INDEPENDENTLY (different players STACK; // a player's own re-hit just refreshes their entry). Value = (when this attacker's burn expires, attacker SteamID for // identity-revalidation against slot reuse). Flat DPS from config; the tick deals it via a RAW (armor-skipping) hit. private readonly Dictionary<(int bot, int atk), (double until, ulong sid)> _burns = new(); private readonly List> _burnScratch = new(); // reused snapshot for BurnTickAll private CounterStrikeSharp.API.Modules.Timers.Timer? _burnTimer; private void Initialize_Effects() { // Every effect-card key must have a catalog entry in Config.Survival.Cards, or the draft can never offer it // (a typo'd/renamed key on the shared JSON silently disables that card on all 8 servers). Reflect over CardKeys // so a newly-added constant is covered automatically. var cardKeys = Config.Survival.Cards.Select(c => c.Key).ToHashSet(StringComparer.Ordinal); foreach (var f in typeof(CardKeys).GetFields(BindingFlags.Public | BindingFlags.Static)) if (f.IsLiteral && f.GetRawConstantValue() is string key && !cardKeys.Contains(key)) Logger.LogError("Outnumbered: CardKeys.{Name} ('{Key}') has no entry in Config.Survival.Cards — that effect card can never be drafted.", f.Name, key); // One steady tick (no-op when the registry is empty, i.e. in TDM/GG or a survival run with no Burn cards). _burnTimer = AddTimer(Math.Max(0.1f, Config.Survival.BurnTickSeconds), BurnTickAll, TimerFlags.REPEAT); } private void Shutdown_Effects() { _burnTimer?.Kill(); _burns.Clear(); } // A human with the Burn card hit a bot — (re)apply this attacker's burn on that bot. private void RegisterBurn(int botSlot, int atkSlot, ulong atkSid) { _burns[(botSlot, atkSlot)] = (Server.CurrentTime + Config.Survival.BurnDurationSeconds, atkSid); } // A bot died — drop all its burns so a fast slot-reuse (suicide/respawn within the burn window) can't inherit fire. private void ClearBurnsForBot(int botSlot) { if (_burns.Count == 0) return; List<(int, int)>? gone = null; foreach (var k in _burns.Keys) if (k.bot == botSlot) (gone ??= new()).Add(k); if (gone is not null) foreach (var k in gone) _burns.Remove(k); } // Apply one tick of burn to every live burning bot, per attacker (so 5 shooters = 5x the DPS). Flat, armor-skipping, // still attributed (a burn KILL credits the player -> wave count / XP / explode-on-kill chaining). Prune expired or // now-invalid entries (bot dead/gone, attacker gone or slot reused). private void BurnTickAll() { if (_burns.Count == 0) return; double now = Server.CurrentTime; float dmg = (float)(Config.Survival.BurnDamagePerSecond * Config.Survival.BurnTickSeconds); // Iterate a SNAPSHOT: a LETHAL raw tick fires player_death synchronously inside DamageDealer.Deal's Invoke -> // OnPlayerDeath_Driver (Pre, inline) -> ClearBurnsForBot -> _burns.Remove, which would corrupt a live foreach // ("Collection was modified"). The snapshot makes that mid-loop mutation harmless; ContainsKey skips any entry // already cleared this tick (e.g. a co-attacker's burn on a bot an earlier entry just killed). Reused scratch // (BurnTickAll isn't re-entrant + game-thread) avoids a per-tick List alloc. _burnScratch.Clear(); foreach (var kv in _burns) _burnScratch.Add(kv); foreach (var kv in _burnScratch) { if (!_burns.ContainsKey(kv.Key)) continue; var (botSlot, atkSlot) = kv.Key; var (until, sid) = kv.Value; var bot = Utilities.GetPlayerFromSlot(botSlot); if (now >= until || !IsLiveBot(bot)) { _burns.Remove(kv.Key); continue; } // safe to mutate _burns here: we're iterating the _burnScratch snapshot var atk = Utilities.GetPlayerFromSlot(atkSlot); if (atk is not { IsValid: true } || atk.AuthorizedSteamID?.SteamId64 != sid) // attacker left / slot reused { _burns.Remove(kv.Key); continue; } // safe to mutate _burns here: we're iterating the _burnScratch snapshot if (dmg > 0) DamageDealer.Deal(bot, atk, dmg, raw: true); } } // Explode-on-Kill: a REAL HE blast at the corpse, attributed to `attacker` so its kills credit them (wave count + // chaining: a blast kill fires its own death event -> re-triggers this). The blast re-enters our offense hook (attacker // = the thrower) so it SCALES with the killer's build + handicap (owner's call); CT team means it never hurts survivors // (ff off). The native projectile-create + the manual blast fallback both live in Engine.GrenadeSpawner. internal void ExplodeAt(CCSPlayerController attacker, Vector pos) { var cfg = Config.Survival; GrenadeSpawner.Explode(attacker, pos, (float)cfg.ExplodeBaseDamage, (float)cfg.ExplodeRadius, cfg.ExplodeFuseSeconds, (msg, ex) => { if (ex is null) LogSurvival(msg); else Logger.LogWarning(ex, "[Survival] {Msg}", msg); }); // sig/invoke breaks log full stack at WARNING } }