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;
|
||||
}
|
||||
}
|
||||
}
|
||||
77
Services/Fmt.cs
Normal file
77
Services/Fmt.cs
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue