using System.Text.Json; using CounterStrikeSharp.API; using CounterStrikeSharp.API.Modules.Cvars; using CounterStrikeSharp.API.Modules.Timers; using Microsoft.Extensions.Logging; using Outnumbered.Domain; using Outnumbered.Engine; namespace Outnumbered; // The local API: one Unix domain socket per instance (/.sock). Three READ verbs — // status : live server snapshot (map, counts, humans, mode extras), rebuilt on a ~2s game-thread timer // balance : the EFFECTIVE in-memory config (mode-resolved handicap) + dense neutral curves, rebuilt at load + !og_reload // top : the three leaderboards, queried per request (repository methods are contractually off-thread, DB-only) // — plus ONE operator verb, drain (announce + kick all humans so a pending SIGINT "shutdown when empty" completes // cleanly; the deploy flow's kick-before-stop). The socket's 0660 file mode is the drain verb's entire access control: // only the cs2 user (the site + the deploy scripts) can connect. The socket thread NEVER touches game state directly: // reads serve pre-serialized byte[]; drain marshals through Server.NextFrame. Every payload carries V + GeneratedAtMs. public sealed partial class OutnumberedPlugin { private const int ApiSchemaVersion = 1; private const float StatusRebuildSeconds = 2.0f; private const int ApiTopN = 50; private const int CurveSteps = 200; // t = -1..+1 inclusive -> 201 samples at 0.01 private UdsServer? _api; private CounterStrikeSharp.API.Modules.Timers.Timer? _statusTimer; private volatile byte[] _statusJson = ApiErr("not-ready"); // served until the first game-thread rebuild lands private volatile byte[] _balanceJson = ApiErr("not-ready"); private bool _engineReady; // set once SetupMap has run — engine statics (Server.MapName etc.) crash before that private bool _apiErrLogged; // one socket-error log line per load, not one per request private string _apiServerId = ""; private int _apiPort; private static byte[] ApiErr(string code) => JsonSerializer.SerializeToUtf8Bytes(new { Err = code }); private void Initialize_Api() { if (!Config.Api.Enabled) return; _apiServerId = ServerId(); var args = CommandLineArgs(); // same -port -> +hostport fallback chain ServerId() resolves with _apiPort = int.TryParse(ArgValue(args, "port") ?? ArgValue(args, "hostport"), out int port) ? port : 0; RebuildBalancePayload(); // config + domain math only — engine-safe at Load string path = Path.Combine(Config.Api.SocketDir, _apiServerId + ".sock"); try { _api = new UdsServer(path, HandleApiRequest, ex => { if (_apiErrLogged) return; _apiErrLogged = true; Logger.LogWarning(ex, "Outnumbered API socket error (further ones suppressed this load)"); }); } catch (Exception ex) { // Missing/unwritable socket dir (dev box without the tmpfiles.d entry) — the API is optional, the game isn't. Logger.LogWarning(ex, "Outnumbered API disabled: cannot bind {Path}", path); return; } _statusTimer = AddTimer(StatusRebuildSeconds, RebuildStatusPayload, TimerFlags.REPEAT); Logger.LogInformation("Outnumbered API listening on {Path}", path); } private void Shutdown_Api() { _statusTimer?.Kill(); _api?.Dispose(); // closes the listener + unlinks the socket file, so a hot-reload can re-bind _api = null; } // Socket-thread dispatch: cached bytes for status/balance; top is the one per-request worker (DB-only). private Task HandleApiRequest(string verb) => verb switch { "status" => Task.FromResult(_statusJson), "balance" => Task.FromResult(_balanceJson), "top" => BuildTopPayload(), "drain" => Drain(), _ => Task.FromResult(ApiErr("unknown-verb")), }; private const float DrainKickDelaySeconds = 5.0f; // long enough to read the announce, short enough for a deploy // Deploy-flow kick: announce, then kick every human, so the engine's SIGINT "shutdown when empty" completes with a // CLEAN plugin unload (persistence flush) instead of the stop timeout's SIGKILL. Bots don't block shutdown. // Replies immediately; the caller sleeps past the delay before systemctl stop. private Task Drain() { Server.NextFrame(() => { if (!_live) return; Server.PrintToChatAll(" [Outnumbered] Server is restarting for an update — reconnect in a minute!"); AddTimer(DrainKickDelaySeconds, () => { if (!_live) return; foreach (var p in Utilities.GetPlayers()) if (IsHuman(p) && p.UserId is { } uid) Server.ExecuteCommand($"kickid {uid} \"Server updating - back in a minute!\""); }); }); return Task.FromResult(JsonSerializer.SerializeToUtf8Bytes(new { Ok = true, KickInSeconds = (int)DrainKickDelaySeconds })); } // Game-thread timer (~2s): mirrors the HUD gather (materialized roster -> IsHuman -> _players). Allocation per // rebuild is fine at this cadence. Per-player handicap bands are deliberately NOT exposed. private void RebuildStatusPayload() { if (!_engineReady) return; // pre-first-map: keep serving not-ready instead of crashing on engine statics var players = Utilities.GetPlayers(); var humans = new List(players.Count); int bots = 0; foreach (var p in players) { if (IsBot(p)) { bots++; continue; } if (!IsHuman(p)) continue; var sid = p.AuthorizedSteamID?.SteamId64; if (sid is null || !_players.TryGetValue(sid.Value, out var pd)) continue; humans.Add(new { pd.Name, pd.Level, pd.Prestige, pd.Kills, pd.Deaths, pd.Streak, Rung = pd.GgRung, // site maps Rung -> weapon via the GG StatusExtra ladder; harmless zero in other modes }); } _statusJson = JsonSerializer.SerializeToUtf8Bytes(new { V = ApiSchemaVersion, GeneratedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), ServerId = _apiServerId, Mode = _driver.Id, Map = Server.MapName, Hostname = ConVar.Find("hostname")?.StringValue ?? "", Port = _apiPort, MaxHumans = _driver.MaxHumansOnCt, Bots = bots, Humans = humans, Extra = _driver.StatusExtra(), }); } // The effective balance: the LIVE Config (post-!og_reload) with the mode-resolved handicap, plus neutral curves // sampled through the same compiled HandicapModel code that scales damage. EXPLICIT allowlist of blocks — never // the root Config (Database carries credentials). Called at Initialize_Api and from Cmd_ReloadConfig. internal void RebuildBalancePayload() { // Guarded end-to-end: pathological config (e.g. Curve <= 0 -> Pow(0, neg) = Infinity -> the serializer throws // on non-finite doubles) must degrade the API verb, never the plugin — the live damage path tolerates the same // config without throwing, and this runs unguarded inside Load. try { _balanceJson = BuildBalanceJson(); } catch (Exception ex) { Logger.LogWarning(ex, "Outnumbered API balance payload rebuild failed — serving 'balance-unavailable'"); _balanceJson = ApiErr("balance-unavailable"); } } private byte[] BuildBalanceJson() { var t = new double[CurveSteps + 1]; var deal = new double[CurveSteps + 1]; var take = new double[CurveSteps + 1]; var xp = new double[CurveSteps + 1]; for (int i = 0; i <= CurveSteps; i++) { t[i] = -1.0 + i * (2.0 / CurveSteps); HandicapModel.BandsFromT(t[i], _hcap, out deal[i], out take[i], out xp[i]); } return JsonSerializer.SerializeToUtf8Bytes(new { V = ApiSchemaVersion, GeneratedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), ServerId = _apiServerId, Mode = _driver?.Id ?? Config.Mode, // driver not yet selected only if called before Initialize_Driver Match = Config.Match, Stats = Config.Stats, Progression = Config.Progression, Abilities = Config.Abilities, GunGame = Config.GunGame, Survival = Config.Survival, EffectiveHandicap = _hcap.Config, Curves = new { T = t, Deal = deal, Take = take, Xp = xp }, }); } // Socket thread: repository calls only (no game objects), mirroring the Cmd_Top precedent. A failure (boot race // with the fire-and-forget EnsureSchema, DB down) degrades to an err payload — the site renders last-good. private async Task BuildTopPayload() { try { var levels = await _repo.GetTopAsync(ApiTopN); var waves = await _repo.GetTopWavesAsync(ApiTopN); var ggTimes = await _repo.GetTopGgAsync(ApiTopN); return JsonSerializer.SerializeToUtf8Bytes(new { V = ApiSchemaVersion, GeneratedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), Levels = levels, Waves = waves, GgTimes = ggTimes, }); } catch (Exception ex) { if (!_apiErrLogged) { _apiErrLogged = true; Logger.LogWarning(ex, "Outnumbered API top query failed"); } return ApiErr("db"); } } }