initial commit
This commit is contained in:
commit
2d966b8198
28 changed files with 2901 additions and 0 deletions
217
Services/Fleet.cs
Normal file
217
Services/Fleet.cs
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue