initial commit

This commit is contained in:
Kamal Tufekcic 2026-07-05 12:14:39 +03:00
commit 2d966b8198
28 changed files with 2901 additions and 0 deletions

217
Services/Fleet.cs Normal file
View 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;
}
}
}

77
Services/Fmt.cs Normal file
View file

@ -0,0 +1,77 @@
using System.Text.Json;
namespace CsWeb.Services
{
// View helpers: tiny, dependency-free formatting shared by the pages. Content-only — no markup decisions here.
public static class Fmt
{
public static string ModeName(string? mode) => mode?.ToLowerInvariant() switch
{
"tdm" => "TDM",
"gungame" => "Gun Game",
"survival" => "Survival",
_ => mode ?? "?",
};
// Matches the plugin's win-banner format (m:ss.fff).
public static string RunTime(long ms) => $"{ms / 60000}:{ms / 1000 % 60:D2}.{ms % 1000:D3}";
public static string Age<T>(Cached<T> c) where T : class =>
c.Data is null ? "no data yet"
: c.Online ? "live"
: $"last seen {(int)c.Age.TotalSeconds}s ago";
// One-line mode summary for a server card, from the status payload's mode-specific Extra block.
public static string ExtraLine(StatusPayload s)
{
var e = s.Extra;
if (e.ValueKind != JsonValueKind.Object) return "";
return s.Mode switch
{
"survival" when e.TryGetProperty("RunActive", out var run) && run.GetBoolean()
&& e.TryGetProperty("Wave", out var w) && e.TryGetProperty("WaveCount", out var wc)
=> $"Wave {w.GetInt32()}/{wc.GetInt32()}",
"survival" => "Between runs",
"gungame" when e.TryGetProperty("Ladder", out var l) => $"{l.GetArrayLength()}-rung ladder",
"tdm" when e.TryGetProperty("KillGoal", out var kg) => $"First to {kg.GetInt32()} kills",
_ => "",
};
}
// A player's current GG weapon: their rung indexed into the ladder the GG driver ships in Extra.
public static string? RungWeapon(StatusPayload s, HumanRow h)
{
if (s.Mode != "gungame" || s.Extra.ValueKind != JsonValueKind.Object) return null;
if (!s.Extra.TryGetProperty("Ladder", out var l) || l.ValueKind != JsonValueKind.Array) return null;
int n = l.GetArrayLength();
if (n == 0) return null;
return l[Math.Clamp(h.Rung, 0, n - 1)].GetString();
}
// Best-effort display for raw weapon designer names in balance payloads (the site has no engine name table;
// status payloads carry pre-rendered display names, this is only for the guides' config-derived lists).
public static string PrettyWeapon(string w) =>
w.StartsWith("weapon_", StringComparison.Ordinal) ? w[7..].Replace('_', ' ').ToUpperInvariant() : w;
public static int IntOf(JsonElement obj, string prop, int fallback = 0) =>
obj.ValueKind == JsonValueKind.Object && obj.TryGetProperty(prop, out var v) && v.TryGetInt32(out int i) ? i : fallback;
public static double NumOf(JsonElement obj, string prop, double fallback = 0) =>
obj.ValueKind == JsonValueKind.Object && obj.TryGetProperty(prop, out var v) && v.ValueKind == JsonValueKind.Number ? v.GetDouble() : fallback;
public static bool BoolOf(JsonElement obj, string prop, bool fallback = false) =>
obj.ValueKind == JsonValueKind.Object && obj.TryGetProperty(prop, out var v) && (v.ValueKind == JsonValueKind.True || v.ValueKind == JsonValueKind.False) ? v.GetBoolean() : fallback;
public static string StrOf(JsonElement obj, string prop, string fallback = "") =>
obj.ValueKind == JsonValueKind.Object && obj.TryGetProperty(prop, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() ?? fallback : fallback;
// Survival card Detail strings are FORMAT TEMPLATES ("{0} = level x PerPick" per the card defs) that the in-game
// draft fills at render time. The guide shows the per-pick line, so format with one pick's worth.
public static string CardDetail(string template, double perPick)
{
if (string.IsNullOrEmpty(template)) return "";
try { return string.Format(System.Globalization.CultureInfo.InvariantCulture, template, perPick); }
catch (FormatException) { return template; } // malformed template -> show raw rather than nothing
}
}
}