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

216
Outnumbered/Api.cs Normal file
View file

@ -0,0 +1,216 @@
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 (<SocketDir>/<ServerId>.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<byte[]> 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<byte[]> 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<object>(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<byte[]> 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");
}
}
}