cs2-outnumbered/Outnumbered/Effects.cs
Kamal Tufekcic d701598350
All checks were successful
CI / build (push) Successful in 32s
CI / release (push) Successful in 32s
CI / lint (push) Successful in 30s
initial commit
2026-07-05 13:28:35 +03:00

103 lines
6.1 KiB
C#

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<KeyValuePair<(int bot, int atk), (double until, ulong sid)>> _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
}
}