216 lines
9.8 KiB
C#
216 lines
9.8 KiB
C#
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");
|
|
}
|
|
}
|
|
}
|