using System.Net.Sockets; using System.Text; using System.Text.Json; namespace CsWeb.Services; // ---- wire DTOs (mirror the plugin's Api.cs payloads, PascalCase, schema-versioned) ---- public sealed class HumanRow { public string Name { get; init; } = ""; public int Level { get; init; } public int Prestige { get; init; } public int Kills { get; init; } public int Deaths { get; init; } public int Streak { get; init; } public int Rung { get; init; } } public sealed class StatusPayload { public int V { get; init; } public long GeneratedAtMs { get; init; } public string ServerId { get; init; } = ""; public string Mode { get; init; } = ""; public string Map { get; init; } = ""; public string Hostname { get; init; } = ""; public int Port { get; init; } public int MaxHumans { get; init; } public int Bots { get; init; } public List Humans { get; init; } = []; public JsonElement Extra { get; init; } // mode-specific block (survival wave, GG ladder, TDM kill goal) } public sealed class CurvesDto { public double[] T { get; init; } = []; public double[] Deal { get; init; } = []; public double[] Take { get; init; } = []; public double[] Xp { get; init; } = []; } public sealed class BalancePayload { public int V { get; init; } public long GeneratedAtMs { get; init; } public string ServerId { get; init; } = ""; public string Mode { get; init; } = ""; public JsonElement Match { get; init; } public JsonElement Stats { get; init; } public JsonElement Progression { get; init; } public JsonElement Abilities { get; init; } public JsonElement GunGame { get; init; } public JsonElement Survival { get; init; } public JsonElement EffectiveHandicap { get; init; } public CurvesDto? Curves { get; init; } } public sealed class TopLevelRow { public string Name { get; init; } = ""; public int Level { get; init; } public int Prestige { get; init; } public long Xp { get; init; } } public sealed class TopWaveRow { public string Name { get; init; } = ""; public int BestWave { get; init; } } public sealed class TopGgRow { public string Name { get; init; } = ""; public long BestMs { get; init; } } public sealed class TopPayload { public int V { get; init; } public long GeneratedAtMs { get; init; } public List Levels { get; init; } = []; public List Waves { get; init; } = []; public List GgTimes { get; init; } = []; } // A cached fetch: the site is stateless by design, so RAM-last-good is the ONLY persistence. A dead socket serves // the previous payload with Online=false instead of a blank page (the servers being briefly down must not blank the site). public sealed class Cached where T : class { public T? Data { get; set; } public DateTimeOffset FetchedAt { get; set; } // when Data was last refreshed successfully public DateTimeOffset LastAttempt { get; set; } public bool Online { get; set; } public TimeSpan Age => DateTimeOffset.UtcNow - FetchedAt; } // The one data source of the whole site: the per-instance Unix domain sockets served by the plugin // (verbs status/balance/top -> one JSON payload, connection closes). Instance discovery = globbing the socket dir, // so a stopped server simply drops off the fleet. All caches are per-verb TTLs with last-good retention; a duplicate // concurrent fetch is acceptable (traffic is tiny) so there is deliberately no per-key locking. public sealed class Fleet(IConfiguration cfg, ILogger log) { private static readonly TimeSpan StatusTtl = TimeSpan.FromSeconds(3); private static readonly TimeSpan BalanceTtl = TimeSpan.FromSeconds(30); private static readonly TimeSpan TopTtl = TimeSpan.FromSeconds(30); private static readonly TimeSpan IoTimeout = TimeSpan.FromSeconds(1); private readonly string _socketDir = cfg["Outnumbered:SocketDir"] ?? "/run/outnumbered"; private readonly Dictionary> _status = new(); private readonly Dictionary> _balance = new(); private readonly Cached _top = new(); public string PublicHost { get; } = cfg["Outnumbered:PublicHost"] ?? "localhost"; // The live fleet = the socket files. Sorted for a stable card order. public IReadOnlyList Instances() { try { return Directory.Exists(_socketDir) ? Directory.GetFiles(_socketDir, "*.sock").Select(Path.GetFileNameWithoutExtension).Where(n => n is not null).Select(n => n!).Order().ToList() : []; } catch (Exception ex) { log.LogWarning(ex, "socket dir scan failed"); return []; } } public Task> Status(string id) => Get(_status, id, "status", StatusTtl); public Task> Balance(string id) => Get(_balance, id, "balance", BalanceTtl); // Leaderboards are global DB data — any live instance answers identically, so ask down the list until one does. public async Task> Top() { lock (_top) { if (DateTimeOffset.UtcNow - _top.LastAttempt < TopTtl) return _top; _top.LastAttempt = DateTimeOffset.UtcNow; // stamped before the fetch: a failing socket is retried at TTL pace, not per request } foreach (var id in Instances()) { var p = await Query(id, "top"); if (p is not null) { lock (_top) { _top.Data = p; _top.FetchedAt = DateTimeOffset.UtcNow; _top.Online = true; } return _top; } } lock (_top) { _top.Online = false; } return _top; } // Balance is per-instance (mode-effective config): for a mode page, prefer an instance actually RUNNING that mode. // Pick order: live mode match > stale mode match (right CONTENT beats fresh-but-wrong-mode) > any live instance > // any instance with a last-good cache > any socket at all. A dead socket file (crash leftover, sorts first) can // never shadow a live server, and a crashed right-mode instance still serves its cached balance. public async Task<(string? Id, Cached? Balance)> BalanceForMode(string mode) { string? first = null, firstOnline = null, firstWithData = null, staleModeMatch = null; foreach (var id in Instances()) { first ??= id; var s = await Status(id); if (s.Data is null) continue; // never answered — dead socket file firstWithData ??= id; bool match = string.Equals(s.Data.Mode, mode, StringComparison.OrdinalIgnoreCase); if (s.Online) { firstOnline ??= id; if (match) return (id, await Balance(id)); } else if (match) staleModeMatch ??= id; } var pick = staleModeMatch ?? firstOnline ?? firstWithData ?? first; return pick is null ? (null, null) : (pick, await Balance(pick)); } private async Task> Get(Dictionary> map, string id, string verb, TimeSpan ttl) where T : class { Cached c; lock (map) { if (!map.TryGetValue(id, out c!)) map[id] = c = new Cached(); } lock (c) { if (DateTimeOffset.UtcNow - c.LastAttempt < ttl) return c; c.LastAttempt = DateTimeOffset.UtcNow; // stamped before the fetch: a dead socket is retried at TTL pace, not per request } var p = await Query(id, verb); lock (c) { c.Online = p is not null; if (p is not null) { c.Data = p; c.FetchedAt = DateTimeOffset.UtcNow; } } return c; } // One request/response round trip: verb line in, full payload out, peer closes. An {"Err":...} payload (server // booting, DB down) counts as a miss — last-good keeps serving. private async Task Query(string id, string verb) where T : class { try { using var cts = new CancellationTokenSource(IoTimeout); using var sock = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); await sock.ConnectAsync(new UnixDomainSocketEndPoint(Path.Combine(_socketDir, id + ".sock")), cts.Token); await sock.SendAsync(Encoding.ASCII.GetBytes(verb + "\n"), SocketFlags.None, cts.Token); using var ms = new MemoryStream(); var buf = new byte[16384]; int n; while ((n = await sock.ReceiveAsync(buf, SocketFlags.None, cts.Token)) > 0) ms.Write(buf, 0, n); if (ms.Length == 0) return null; using var doc = JsonDocument.Parse(ms.GetBuffer().AsMemory(0, (int)ms.Length)); if (doc.RootElement.TryGetProperty("Err", out _)) return null; return doc.RootElement.Deserialize(); } catch (Exception ex) { log.LogDebug(ex, "query {Verb} on {Id} failed", verb, id); return null; } } }