217 lines
9.1 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|