using System.Diagnostics.CodeAnalysis; using CounterStrikeSharp.API; using CounterStrikeSharp.API.Core; using CounterStrikeSharp.API.Modules.Utils; using Outnumbered.Data; namespace Outnumbered; // The one front door from a controller/slot to PlayerData, plus the shared player predicates/enumerators and the // slot-deferral helper. Handlers route through these so the engine's controller/SteamID surface (the part most likely // to shift across CSSharp builds) and the slot-reuse-after-NextFrame contract live in one place. Deliberate exceptions: // the hot OnEntityTakeDamagePre resolves inline (perf), and a few sites keep the raw SteamID where it's the actual key // (the _hudOff/_dmgReadout HashSets, LoadPlayer, identity re-validation) rather than a PlayerData lookup. public sealed partial class OutnumberedPlugin { // The canonical controller -> PlayerData lookup (by SteamID). Every other resolver routes through it. internal PlayerData? PdOf(CCSPlayerController p) { var sid = p.AuthorizedSteamID?.SteamId64; return sid is not null && _players.TryGetValue(sid.Value, out var pd) ? pd : null; } internal PlayerData? PdOf(int slot) { var p = Utilities.GetPlayerFromSlot(slot); return p is { IsValid: true } ? PdOf(p) : null; } // [NotNullWhen(true)] lets `if (!IsHuman(p)) return;` flow non-null through to the body. // Resolve a pawn back to its owning controller (the schema dance the damage hook does for attacker + victim). internal static CCSPlayerController? ControllerOfPawn(CCSPlayerPawn? pawn) => pawn?.Controller.Value?.As(); internal static bool IsHuman([NotNullWhen(true)] CCSPlayerController? p) => p is { IsValid: true, IsBot: false, IsHLTV: false }; internal static bool IsLiveHuman([NotNullWhen(true)] CCSPlayerController? p) => p is { IsValid: true, IsBot: false, IsHLTV: false } && p.PawnIsAlive; // Bot-side mirror. IsHLTV:false excludes a SourceTV/GOTV proxy (the engine marks it IsBot too) — so a relay can never // be treated as a combat bot. internal static bool IsBot([NotNullWhen(true)] CCSPlayerController? p) => p is { IsValid: true, IsBot: true, IsHLTV: false }; internal static bool IsLiveBot([NotNullWhen(true)] CCSPlayerController? p) => p is { IsValid: true, IsBot: true, IsHLTV: false } && p.PawnIsAlive; // Per-tick callers iterate Utilities.GetPlayers() with the IsHuman/IsLiveHuman/IsBot predicates inline (no Where-iterator // alloc); Humans()/Bots() are the allocating convenience for cold paths (e.g. the hot-reload seed, the bot pool walk). internal static IEnumerable Humans() => Utilities.GetPlayers().Where(IsHuman); internal static IEnumerable Bots() => Utilities.GetPlayers().Where(IsBot); // Force every CT-side bot back to T (bot_join_team only affects NEWLY-added bots). Shared by the steady-state SyncBots // and survival's ManageBots; the caller passes its already-materialized roster so there's no extra GetPlayers() walk. internal static void ForceBotsToTerrorist(IEnumerable players) { foreach (var b in players) if (IsBot(b) && b.Team == CsTeam.CounterTerrorist) b.SwitchTeam(CsTeam.Terrorist); } // Run `body` next frame for the player still occupying `slot`, re-resolved and re-validated (the slot may have been // reused). The overload also pins the SteamID so a within-frame disconnect+reuse by a different person can't inherit. internal static void NextFrameForSlot(int slot, Action body, bool requireAlive = false) => Server.NextFrame(() => { var p = Utilities.GetPlayerFromSlot(slot); if (p is { IsValid: true } && (!requireAlive || p.PawnIsAlive)) body(p); }); internal static void NextFrameForSlot(int slot, ulong expectSid, Action body, bool requireAlive = false) => Server.NextFrame(() => { var p = Utilities.GetPlayerFromSlot(slot); if (p is { IsValid: true } && p.AuthorizedSteamID?.SteamId64 == expectSid && (!requireAlive || p.PawnIsAlive)) body(p); }); // Defer a HP/armor cap re-apply (optionally re-locking the default loadout) to next frame — re-resolved by slot and // SteamID-pinned so a within-frame disconnect+slot-reuse can't apply this player's caps to whoever inherits the slot. // No-op if dead now / no SteamID / not alive next frame. Used after a stat buy / prestige reset / admin grant. internal void DeferReapplyCaps(CCSPlayerController p, bool relockLoadout = false) { if (!p.PawnIsAlive || p.AuthorizedSteamID?.SteamId64 is not { } sid) return; NextFrameForSlot(p.Slot, sid, pl => { if (PdOf(pl) is not { } pd) return; if (relockLoadout) ApplyLoadout(pl, false); ApplyMaxHpArmor(pl, pd); }, requireAlive: true); } // Reap entries keyed by player slot whose controller has vanished WITHOUT a clean disconnect (map change / // bot-replaces-human / kick race). Collect-then-invoke because onOrphan typically mutates `map`. The caller owns the // reused `scratch` list and passes the concrete Dictionary (struct enumerator) + a CACHED onOrphan delegate, so the // per-tick walk allocates nothing. Used by the Hud (world-text) and Shop (session) per-tick reaps. internal static void ReapOrphanSlots(Dictionary map, List scratch, Action onOrphan) { scratch.Clear(); foreach (var slot in map.Keys) { var pl = Utilities.GetPlayerFromSlot(slot); if (pl is null || !pl.IsValid || pl.IsBot) scratch.Add(slot); } foreach (var slot in scratch) onOrphan(slot); } // CheckTransmit shared shape: a per-player world-text panel keyed by slot must be hidden from EVERY OTHER player (each // sees only their own). The Hud + Shop transmit handlers differ only in which entities a slot owns — pass a STATIC // `remove` (no captures, so it's cached, no per-call alloc on this hot listener) that strips one slot's entities. internal static void HideForeignSlotEntities(CCheckTransmitInfoList infoList, Dictionary map, Action remove) { if (map.Count == 0) return; foreach (var (info, receiver) in infoList) { if (receiver is null) continue; foreach (var (slot, entry) in map) if (slot != receiver.Slot) remove(info, entry); } } }