initial commit
All checks were successful
CI / build (push) Successful in 32s
CI / release (push) Successful in 32s
CI / lint (push) Successful in 30s

This commit is contained in:
Kamal Tufekcic 2026-07-05 13:28:35 +03:00
commit d701598350
67 changed files with 9351 additions and 0 deletions

111
Outnumbered/Players.cs Normal file
View file

@ -0,0 +1,111 @@
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<CCSPlayerController>();
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<CCSPlayerController> Humans() => Utilities.GetPlayers().Where(IsHuman);
internal static IEnumerable<CCSPlayerController> 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<CCSPlayerController> 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<CCSPlayerController> 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<CCSPlayerController> 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<T>(Dictionary<int, T> map, List<int> scratch, Action<int> 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<T>(CCheckTransmitInfoList infoList, Dictionary<int, T> map, Action<CCheckTransmitInfo, T> 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);
}
}
}