cs2-web/Services/Fleet.cs
Kamal Tufekcic 2d966b8198 initial commit
2026-07-05 12:14:39 +03:00

217 lines
9.1 KiB
C#

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<HumanRow> 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<TopLevelRow> Levels { get; init; } = [];
public List<TopWaveRow> Waves { get; init; } = [];
public List<TopGgRow> 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<T> 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<Fleet> 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<string, Cached<StatusPayload>> _status = new();
private readonly Dictionary<string, Cached<BalancePayload>> _balance = new();
private readonly Cached<TopPayload> _top = new();
public string PublicHost { get; } = cfg["Outnumbered:PublicHost"] ?? "localhost";
// The live fleet = the socket files. Sorted for a stable card order.
public IReadOnlyList<string> 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<Cached<StatusPayload>> Status(string id) => Get(_status, id, "status", StatusTtl);
public Task<Cached<BalancePayload>> 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<Cached<TopPayload>> 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<TopPayload>(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<BalancePayload>? 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<Cached<T>> Get<T>(Dictionary<string, Cached<T>> map, string id, string verb, TimeSpan ttl) where T : class
{
Cached<T> c;
lock (map) { if (!map.TryGetValue(id, out c!)) map[id] = c = new Cached<T>(); }
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<T>(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<T?> Query<T>(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<T>();
}
catch (Exception ex)
{
log.LogDebug(ex, "query {Verb} on {Id} failed", verb, id);
return null;
}
}
}