From 3cb66187bc415e79e04b3027666a6a2a10c0fda1 Mon Sep 17 00:00:00 2001 From: Harry Cordewener Date: Fri, 31 Jul 2026 09:32:40 -0500 Subject: [PATCH] Publish what the catalogue runs, as shares that name what they are shares of MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec §9's ecosystem dashboard and rankings, both computed from measured data and neither able to say the thing a directory is usually asked for. Shares, never totals. §15.7 withholds the absolute "how many people play MU*" figure because a ratio over the games we reached survives the ones we cannot and the ones nobody claimed, and a headcount survives neither — so EcosystemDashboard has nowhere to put one, and a test sums the fixture's counts and requires the number never to appear. A share carries its denominator as a field, not as a caption. MeasuredShare holds the count and the set it was counted in, and Fraction can be computed from nothing else, so a percentage cannot travel to a renderer without the set it is a fraction of. Protocols carry two denominators because they are two sets: games whose handshake we completed, and games whose MSSP report we hold. Three things the data could not honestly support, said on the page rather than filled in: - There is no adoption curve. game_field holds a current value with transitions beside it, so a curve has to be reconstructed and there are not yet enough transitions to plot. The available alternative — plotting each observation's first sighting — would draw a confident rising line measuring the crawl reaching more games and nothing about anybody adopting anything. The transition count is on the page instead, which is the number that says when the curve becomes real. - TLS is unmeasured, and says so rather than reading 0%. The probe dials plain telnet and TLS is not a telnet option, so no capability.tls.measured row can exist; "0% of games offer TLS" would be a limit of our crawler published as a fact about the hobby. The rule is derived from the tally rather than compiled in, so the column starts reporting a share the day the first measurement lands. - The measured column is a floor and is labelled one. The crawler writes a capability down when it observes it and otherwise writes nothing, because it requests MSSP alone and declines MCCP outright — MSSP is the one protocol whose silence is an answer, and it is the only one whose absence is counted as a no. The protocol denominator is read off availability rather than off capability rows: a reachable interval is a session of ours that completed, which is what a measured capability is a capability of. Counting games that have capability rows instead would define the denominator out of the numerator and quietly raise every share. Rankings are busiest-by-median over a stated week among games with enough samples to have a median, and the current unbroken reachable spell, which carries the date it began because a spell cannot be longer than we have been watching. Archived games are out of both and of nothing else. An uncountable probe is not a zero in either, and a measured zero is a count and ranks. The §5.1 ladder is resolved in SQL for the first time, because the dashboard reads every game at once. It is passed as a parameter generated from the enum's declared order, so the ladder still has exactly one spelling. Also: the fixture's demo week is scaled to each game's own count, so a demo ranking is a ranking rather than five ties; and the test container's client limit is lifted, because a database per test met Postgres's default hundred on the run that took the suite past a hundred and thirty tests and reported it as a flake in twenty unrelated places. --- src/MUI.Catalog/Ecosystem.cs | 250 ++++++++++++ src/MUI.Catalog/Persistence/FieldRegistry.cs | 46 ++- .../Persistence/NpgsqlGameQueries.cs | 359 ++++++++++++++++++ src/MUI.Catalog/Views.cs | 8 + src/MUI.Web/Components/EcosystemCopy.cs | 149 ++++++++ .../Components/Layout/MainLayout.razor | 2 + src/MUI.Web/Components/Pages/Ecosystem.razor | 161 ++++++++ .../Components/Pages/RankingsPage.razor | 144 +++++++ src/MUI.Web/Components/PlainText.cs | 126 ++++++ src/MUI.Web/Fixtures/FixtureGameQueries.cs | 137 ++++++- src/MUI.Web/wwwroot/app.css | 62 +++ .../MUI.Catalog.Tests/CodebaseFamilyTests.cs | 49 +++ .../EcosystemQueriesPostgresTests.cs | 342 +++++++++++++++++ .../Persistence/Support/PostgresFixture.cs | 10 + tests/MUI.Web.Tests/EcosystemSurfaceTests.cs | 215 +++++++++++ 15 files changed, 2053 insertions(+), 7 deletions(-) create mode 100644 src/MUI.Catalog/Ecosystem.cs create mode 100644 src/MUI.Web/Components/EcosystemCopy.cs create mode 100644 src/MUI.Web/Components/Pages/Ecosystem.razor create mode 100644 src/MUI.Web/Components/Pages/RankingsPage.razor create mode 100644 tests/MUI.Catalog.Tests/CodebaseFamilyTests.cs create mode 100644 tests/MUI.Catalog.Tests/Persistence/EcosystemQueriesPostgresTests.cs create mode 100644 tests/MUI.Web.Tests/EcosystemSurfaceTests.cs diff --git a/src/MUI.Catalog/Ecosystem.cs b/src/MUI.Catalog/Ecosystem.cs new file mode 100644 index 0000000..82722f3 --- /dev/null +++ b/src/MUI.Catalog/Ecosystem.cs @@ -0,0 +1,250 @@ +namespace MUI.Catalog; + +/// +/// A count over the set it was counted in. There is no way to hold a proportion here without the +/// denominator it is a proportion of. +/// +/// +/// +/// The structural half of spec §15.7. "62% of games offer UTF-8" is not a fact — it is a fact only +/// once "of the 431 games whose handshake we have completed" is attached to it, and a reader who +/// cannot see the denominator cannot tell a ratio over four hundred measurements from a ratio over +/// four. So the denominator is a field rather than a caption: a share cannot travel to a renderer +/// without it, and cannot be computed from anything else. +/// +/// +/// is null on an empty denominator rather than zero, for the same reason +/// is not : nothing +/// measured is not nought per cent. +/// +/// +public sealed record MeasuredShare(string Label, int Count, int Denominator) +{ + public double? Fraction => Denominator == 0 ? null : (double)Count / Denominator; +} + +/// +/// Which codebases the listed games run, as shares of the games that told us (spec §9). +/// +/// +/// is carried beside the shares rather than folded into them. A game +/// whose codebase we could not read is not a game running "Other" — it is a game we have no answer +/// for, and rolling those into a residual bar would publish our own gap as somebody's market share. +/// +public sealed record CodebaseUsage( + IReadOnlyList Families, + int Identified, + int NotIdentified); + +/// +/// One protocol, measured beside declared, each with its own denominator (spec §9). +/// +/// +/// +/// Two denominators because they are two different sets, and this is exactly the place where one +/// denominator for both would be a lie: is the games whose session our +/// crawler completed, and is the games whose MSSP report we hold. A game +/// can be in either, both or neither. +/// +/// +/// is nullable, and the null is load-bearing. It is null when the store +/// holds no measured observation of this protocol from any listed game — which is the state TLS is +/// in today, because the probe dials plain telnet and TLS is not a telnet option. Rendering that as +/// "0% of games offer TLS" would state a limit of our crawler as a fact about the hobby, which is +/// rule 5. Deriving it from the data rather than from a list compiled here means the column starts +/// reporting a share on its own the day the first measurement lands, and it fails in the safe +/// direction: a protocol genuinely nobody offers reads as unmeasured, which understates a claim +/// rather than manufacturing one. +/// +/// +/// counts only games with an explicit measured false, and the crawler +/// writes one for exactly one protocol: MSSP, which every probe asks for by name, so silence there +/// is an answer. For every other protocol the remainder is — we did not see +/// it, which is not the same fact as the game not having it. Every surface rendering this has to say +/// so; is a floor and not a measurement of absence. +/// +/// +public sealed record ProtocolAdoption( + string Protocol, + int? Offered, + int Declined, + int Handshakes, + int Declared, + int MsspReports) +{ + /// + /// Games whose handshake completed and in which we saw neither a yes nor a no. + /// + /// + /// Floored at nought rather than left to go negative. The two sides come from different tables — + /// the denominator from availability and the numerator from stored fields — so a capability row + /// on a game with no reachable interval, which a staff correction can write, would otherwise put + /// a negative count on a public page. The residual is the wrong place to surface that: it is a + /// number about the games, and an inconsistency of ours does not belong in it. + /// + public int Unobserved => Math.Max(0, Handshakes - (Offered ?? 0) - Declined); + + /// The measured side, or null where nothing has ever been measured. + public MeasuredShare? Measured => + Offered is { } offered ? new MeasuredShare(Protocol, offered, Handshakes) : null; + + /// The declared side. Always a share, because absence of a claim is not a claim. + public MeasuredShare DeclaredShare => new(Protocol, Declared, MsspReports); +} + +/// +/// The ecosystem dashboard: codebase share and protocol adoption over the measured set (spec §9). +/// +/// +/// +/// Shares, not totals. There is no player figure on this record and there is deliberately +/// nowhere to put one. §15.7 withholds the absolute "how many people play MU*" number because a +/// ratio over the measured set survives the unclaimed and unreachable biases and a count does not, +/// and a total that exists on the view model is a total somebody will render. +/// +/// +/// A snapshot, and it says so. The spec asks for adoption curves, and the store +/// cannot yet honestly draw one: game_field holds a current value with transitions beside it +/// in field_change, so a curve has to be reconstructed from transitions — +/// is how many exist so far, and it is the count that says when +/// the curve becomes worth drawing. The tempting alternative is to plot each observation's +/// first_seen_at, which would draw a beautiful rising line that is a picture of our crawl +/// reaching more games and not of anybody adopting anything. +/// +/// +public sealed record EcosystemDashboard( + DateTimeOffset AsOf, + int ListedGames, + int Handshakes, + int MsspReports, + DateTimeOffset? OldestHandshake, + int CapabilityTransitions, + CodebaseUsage Codebases, + IReadOnlyList Protocols) +{ + public static EcosystemDashboard Empty(DateTimeOffset asOf) => new( + asOf, 0, 0, 0, null, 0, new CodebaseUsage([], 0, 0), []); +} + +/// +/// The protocols §9 names as the dashboard's headline four. +/// +/// +/// Listed even when nothing is known about them, because "we have not measured TLS yet" is one of +/// the more useful things this page can say and it is only visible if the row exists. Every other +/// capability appears when there is something to report and is left out when there is not. +/// +public static class EcosystemProtocols +{ + public static IReadOnlyList Headline { get; } = ["TLS", "UTF-8", "GMCP", "MXP"]; +} + +/// +/// The family a CODEBASE value names, with a trailing version folded away. +/// +/// +/// +/// Market share is a question about codebases and not about point releases: PennMUSH 1.8.8p0 and +/// PennMUSH 1.8.7 are one answer, and reporting them as two would spread one codebase's share across +/// as many rows as there are patch levels in the wild. MSSP's own convention is name-then-version, +/// which is what makes the fold possible at all. +/// +/// +/// Exactly one trailing token is folded, and only when the whole of it looks like a version — it +/// starts with a digit or a v before one, and contains nothing but letters, digits and the +/// separators a version number uses. So Midnight Sun keeps both its words and +/// Rhost 4.0.4 (patchlevel 1) keeps its parenthesis rather than being truncated mid-phrase. +/// The value as the game reported it is still on the game's own page; this is the dashboard's +/// grouping key and nothing else. +/// +/// +public static class CodebaseFamily +{ + public static string Of(string codebase) + { + ArgumentNullException.ThrowIfNull(codebase); + + var trimmed = codebase.Trim(); + var space = trimmed.LastIndexOf(' '); + + if (space <= 0) + { + return trimmed; + } + + return LooksLikeAVersion(trimmed[(space + 1)..]) + ? trimmed[..space].TrimEnd() + : trimmed; + } + + private static bool LooksLikeAVersion(string token) + { + if (token.Length == 0) + { + return false; + } + + var starts = char.IsAsciiDigit(token[0]) + || (token[0] is 'v' or 'V' && token.Length > 1 && char.IsAsciiDigit(token[1])); + + return starts && token.All(c => char.IsAsciiLetterOrDigit(c) || c is '.' or '-' or '_'); + } +} + +/// +/// One game in the busiest ranking, with the measurements the rank is computed from beside it. +/// +/// +/// The median rather than the peak, because a peak is one sample and a game that had forty players +/// for a minute is not busier than one that has thirty all day. Both are carried anyway, with the +/// number of samples they were taken over, so the basis is on the page rather than in a footnote — +/// a ranking whose arithmetic a reader cannot check is the kind of ranking §2 refuses. +/// is an observed value and never an average of two, so it is a number some +/// probe actually read. +/// +public sealed record BusiestGame(string Slug, string Name, int Median, int Peak, int Samples); + +/// +/// A game's current unbroken run of measured reachability. +/// +/// +/// is carried rather than a duration alone because the duration means nothing +/// without it: a spell cannot be longer than we have been watching, so the honest sentence is +/// "reachable on every probe since 12 March" and never "reachable for four years". Reachable, not +/// up (spec §5.8) — we measured a socket from one vantage point. +/// +public sealed record ReachableSpell(string Slug, string Name, DateTimeOffset Since) +{ + public TimeSpan LengthAt(DateTimeOffset now) => now - Since; +} + +/// +/// The rankings (spec §9), computed from measured data only. +/// +/// +/// +/// There is no voting affordance on this site and there never will be — that is what reduced Top Mud +/// Sites to a link graveyard, and it is the first thing on §2's permanent non-goals. So every +/// ranking here has to be a measurement with a stated basis, which rules out "best" and rules in +/// "busiest by measured concurrent players over a named window, among games that produced enough +/// samples to have a median". +/// +/// +/// beside is the denominator rule applied to a +/// league table: a top ten drawn from twelve eligible games and a top ten drawn from four hundred +/// are different claims, and the difference is invisible unless the page says which it is. Archived +/// games are excluded here and from nothing else (spec §7.5). +/// +/// +public sealed record Rankings( + DateTimeOffset AsOf, + TimeSpan Window, + int MinimumSamples, + int ListedGames, + int Eligible, + IReadOnlyList Busiest, + IReadOnlyList LongestUnbroken) +{ + public static Rankings Empty(DateTimeOffset asOf, TimeSpan window, int minimumSamples) => + new(asOf, window, minimumSamples, 0, 0, [], []); +} diff --git a/src/MUI.Catalog/Persistence/FieldRegistry.cs b/src/MUI.Catalog/Persistence/FieldRegistry.cs index cb2ba96..1b2353c 100644 --- a/src/MUI.Catalog/Persistence/FieldRegistry.cs +++ b/src/MUI.Catalog/Persistence/FieldRegistry.cs @@ -35,14 +35,37 @@ public static class CapabilityFields /// public static string? CapabilityOf(string field) { - if (!field.StartsWith(Prefix, StringComparison.Ordinal)) + if (SuffixOf(field) is not { } suffix) { return null; } - var suffix = field.EndsWith(MeasuredSuffix, StringComparison.Ordinal) ? MeasuredSuffix - : field.EndsWith(DeclaredSuffix, StringComparison.Ordinal) ? DeclaredSuffix - : null; + var slug = field[Prefix.Length..^suffix.Length]; + + return Names.FirstOrDefault(name => Normalise(name) == slug); + } + + /// + /// The capability a field names, spelled the way a reader should see it, or null if it names no + /// capability at all. + /// + /// + /// answers only for capabilities this class declares, which is the + /// right answer where a column of the matrix is at stake. An aggregate over the whole catalogue + /// needs the other one: a server naming a protocol nobody listed here is still measured and + /// still storedFieldObservations refuses to drop an observation because no column + /// renders it — so a dashboard that knew only the declared set would silently discard real + /// measurements. The name the server used is returned in that case, folded back out of the slug, + /// so capability.mccp2.measured reads as MCCP2. + /// + public static string? NameOf(string field) + { + if (CapabilityOf(field) is { } known) + { + return known; + } + + var suffix = SuffixOf(field); if (suffix is null) { @@ -51,13 +74,26 @@ public static class CapabilityFields var slug = field[Prefix.Length..^suffix.Length]; - return Names.FirstOrDefault(name => Normalise(name) == slug); + return slug.Length == 0 ? null : slug.Replace('-', ' ').ToUpperInvariant(); } public static bool IsMeasured(string field) => field.StartsWith(Prefix, StringComparison.Ordinal) && field.EndsWith(MeasuredSuffix, StringComparison.Ordinal); + /// Which side of the matrix a field name is on, or null if it is on neither. + private static string? SuffixOf(string field) + { + if (!field.StartsWith(Prefix, StringComparison.Ordinal)) + { + return null; + } + + return field.EndsWith(MeasuredSuffix, StringComparison.Ordinal) ? MeasuredSuffix + : field.EndsWith(DeclaredSuffix, StringComparison.Ordinal) ? DeclaredSuffix + : null; + } + private static string Normalise(string capability) => capability.Trim().ToLowerInvariant().Replace(' ', '-'); } diff --git a/src/MUI.Catalog/Persistence/NpgsqlGameQueries.cs b/src/MUI.Catalog/Persistence/NpgsqlGameQueries.cs index 76487e2..b2d231e 100644 --- a/src/MUI.Catalog/Persistence/NpgsqlGameQueries.cs +++ b/src/MUI.Catalog/Persistence/NpgsqlGameQueries.cs @@ -19,6 +19,14 @@ namespace MUI.Catalog.Persistence; /// picks, so the ladder has exactly one spelling — the declared /// order of — and a `CASE source WHEN …` in a query cannot drift from it. /// +/// +/// The two aggregate reads are the one exception, and they keep the rule while breaking the shape: +/// the ecosystem dashboard resolves a winner per (game, field) across the whole catalogue, and +/// dragging every capability row of every game into memory to do it would be a scan for a page. They +/// use DISTINCT ON … ORDER BY array_position(@ladder, source) instead — where +/// is generated from the enum's declared order, so the ladder still has +/// exactly one spelling and a hand-written CASE still cannot drift from it. +/// /// public sealed class NpgsqlGameQueries(NpgsqlDataSource source, IFieldRegistry? registry = null) : IGameQueries @@ -32,10 +40,39 @@ public sealed class NpgsqlGameQueries(NpgsqlDataSource source, IFieldRegistry? r /// §5.2's "active this week". public static readonly TimeSpan ThisWeek = TimeSpan.FromDays(7); + /// + /// The window the busiest ranking is measured over, and named on the page. + /// + /// + /// A week, because a MU* has a weekly shape — §5.2's heatmap is a day × hour grid for exactly that + /// reason — and a ranking over anything shorter would rank Saturday's games above Tuesday's. + /// + public static readonly TimeSpan RankingWindow = TimeSpan.FromDays(7); + + /// + /// How many counted samples a game needs before it can be ranked. + /// + /// + /// A day's worth of hourly probes. A median over three samples is not a median, and a game found + /// on Friday would otherwise take the top of the table off one lucky evening probe — which is + /// ranking our crawl schedule rather than the game. + /// + public const int MinimumRankingSamples = 24; + private const int FeedLimit = 10; private const int ChangeLimit = 20; + private const int RankingLimit = 20; + + /// + /// The §5.1 ladder as a SQL parameter, generated from the enum so it cannot drift from it. + /// + private static readonly string[] SourceLadder = Enum.GetValues() + .OrderBy(FieldPrecedence.RankOf) + .Select(SqlEnums.ToDb) + .ToArray(); + private readonly IFieldRegistry _registry = registry ?? FieldRegistry.Instance; /// @@ -236,6 +273,282 @@ LIMIT @limit cameBack.Select(r => new FeedEntry(r.Slug, r.Name, r.At, "answered again")).ToList()); } + /// + /// Codebase share and protocol adoption over the listed games (spec §9). + /// + /// + /// + /// Every figure here is a count of games, and nothing sums a player count. §15.7 withholds + /// the absolute "how many people play MU*" number because a share over the measured set survives + /// the unclaimed and unreachable biases and a total does not — so this method has no access to + /// presence at all, which is the cheapest way to keep a total from ever being computed here. + /// + /// + /// The protocol denominator is games we have completed a session with, and it is read off + /// availability rather than off the capability rows themselves. A reachable interval + /// means a probe of ours got in and finished (a session that answered and could not finish is + /// degraded, §5.3), which is exactly the set a protocol share is a share of. Counting games + /// that have capability rows instead would define the denominator out of the numerator: + /// a game whose handshake completed and offered nothing measurable would drop out of the bottom of + /// the fraction and quietly raise every share on the page. + /// + /// + /// Archived games are excluded, which is the one presentation change archiving makes (§7.5). A + /// game that stopped answering in 2019 is a fact about 2019 and its handshake is not evidence + /// about what the hobby runs now. + /// + /// + public async Task EcosystemAsync(CancellationToken cancellationToken = default) + { + var now = Clock(); + + await using var connection = await source.OpenConnectionAsync(cancellationToken); + + var totals = await connection.QuerySingleAsync(new CommandDefinition( + """ + SELECT + (SELECT count(*)::int FROM game WHERE state <> 'archived') AS Listed, + + -- A completed session, which is what a measured capability is a capability of. + (SELECT count(DISTINCT a.game_id)::int + FROM availability_interval a + JOIN game g ON g.id = a.game_id + WHERE g.state <> 'archived' AND a.state = 'reachable') AS Handshakes, + + -- Games whose MSSP report we hold. A different set from the one above, and the whole + -- reason the declared column carries its own denominator. + (SELECT count(DISTINCT f.game_id)::int + FROM game_field f + JOIN game g ON g.id = f.game_id + WHERE g.state <> 'archived' AND f.source = 'mssp') AS MsspReports, + + -- How stale the stalest handshake in this snapshot is, so the page can say how old the + -- picture is rather than implying it is of this minute. + (SELECT min(f.last_confirmed_at) + FROM game_field f + JOIN game g ON g.id = f.game_id + WHERE g.state <> 'archived' AND f.source = 'handshake' + AND f.field LIKE 'capability.%.measured') AS OldestHandshake, + + -- The raw material of the curve this page cannot yet draw (§5.1's change ledger). + (SELECT count(*)::int + FROM field_change c + JOIN game g ON g.id = c.game_id + WHERE g.state <> 'archived' + AND c.field LIKE 'capability.%.measured') AS CapabilityTransitions + """, + cancellationToken: cancellationToken)); + + var codebases = (await connection.QueryAsync(new CommandDefinition( + """ + SELECT DISTINCT ON (f.game_id) f.value + FROM game_field f + JOIN game g ON g.id = f.game_id + WHERE g.state <> 'archived' AND f.field = 'CODEBASE' AND f.value <> '' + ORDER BY f.game_id, array_position(@ladder::text[], f.source), f.last_confirmed_at DESC + """, + new { ladder = SourceLadder }, + cancellationToken: cancellationToken))).ToList(); + + var capabilities = (await connection.QueryAsync(new CommandDefinition( + """ + SELECT winner.field AS Field, winner.value AS Value, count(*)::int AS Games + FROM (SELECT DISTINCT ON (f.game_id, f.field) f.field, f.value + FROM game_field f + JOIN game g ON g.id = f.game_id + WHERE g.state <> 'archived' AND f.field LIKE 'capability.%' + ORDER BY f.game_id, f.field, + array_position(@ladder::text[], f.source), f.last_confirmed_at DESC) winner + GROUP BY winner.field, winner.value + """, + new { ladder = SourceLadder }, + cancellationToken: cancellationToken))).ToList(); + + return new EcosystemDashboard( + now, + totals.Listed, + totals.Handshakes, + totals.MsspReports, + totals.OldestHandshake, + totals.CapabilityTransitions, + CodebasesOf(codebases, totals.Listed), + ProtocolsOf(capabilities, totals.Handshakes, totals.MsspReports)); + } + + /// + /// The rankings (spec §9) — measured data only, and every basis stated on the record so the page + /// and the plain surface cannot describe the same table two ways. + /// + /// + /// + /// The busiest table ranks on a median of measured concurrent counts over + /// . A NULL count is a probe that got in and could not read a number + /// (§5.4) and is excluded rather than read as a zero, which is rule 4 in the one place it would be + /// most tempting to break: a game whose DOING header we cannot parse would otherwise sink + /// to the bottom of a league table while running perfectly well. A measured zero is a count and + /// stays in. + /// + /// + /// The second table is the current unbroken run of reachability, which is one open interval per + /// game and therefore arithmetic over a handful of rows (§5.3). It carries the date the spell + /// began rather than a duration, because a spell cannot be longer than we have been watching. + /// + /// + public async Task RankingsAsync(CancellationToken cancellationToken = default) + { + var now = Clock(); + + await using var connection = await source.OpenConnectionAsync(cancellationToken); + + var listed = await connection.ExecuteScalarAsync(new CommandDefinition( + "SELECT count(*)::int FROM game WHERE state <> 'archived'", + cancellationToken: cancellationToken)); + + var busiest = (await connection.QueryAsync(new CommandDefinition( + """ + WITH counted AS ( + SELECT g.slug, g.name, + percentile_disc(0.5) WITHIN GROUP (ORDER BY p.count) AS median, + max(p.count) AS peak, + count(*)::int AS samples + FROM presence_sample p + JOIN game g ON g.id = p.game_id + WHERE p.at >= @from AND p.count IS NOT NULL AND g.state <> 'archived' + GROUP BY g.slug, g.name + HAVING count(*) >= @minimum) + SELECT slug AS Slug, name AS Name, median AS Median, peak AS Peak, samples AS Samples, + (count(*) OVER ())::int AS Eligible + FROM counted + ORDER BY median DESC, peak DESC, name + LIMIT @limit + """, + new + { + from = (now - RankingWindow).ToUniversalTime(), + minimum = MinimumRankingSamples, + limit = RankingLimit, + }, + cancellationToken: cancellationToken))).ToList(); + + var spells = (await connection.QueryAsync(new CommandDefinition( + """ + SELECT g.slug AS Slug, g.name AS Name, a.from_at AS Since + FROM availability_interval a + JOIN game g ON g.id = a.game_id + WHERE a.to_at IS NULL AND a.state = 'reachable' AND g.state <> 'archived' + ORDER BY a.from_at + LIMIT @limit + """, + new { limit = RankingLimit }, + cancellationToken: cancellationToken))).ToList(); + + return new Rankings( + now, + RankingWindow, + MinimumRankingSamples, + listed, + busiest.Count == 0 ? 0 : busiest[0].Eligible, + busiest.Select(r => new BusiestGame(r.Slug, r.Name, r.Median, r.Peak, r.Samples)).ToList(), + spells.Select(r => new ReachableSpell(r.Slug, r.Name, r.Since)).ToList()); + } + + /// + /// Codebase values folded to families and counted. The denominator is the games that told us, + /// never the listing — a game we could not identify is not a game running something else. + /// + private static CodebaseUsage CodebasesOf(IReadOnlyList values, int listed) + { + var families = values + .Select(CodebaseFamily.Of) + .Where(family => family.Length > 0) + .GroupBy(family => family, StringComparer.OrdinalIgnoreCase) + .Select(group => new MeasuredShare( + // The spelling the most games used, so one game's stray capitalisation does not name + // the family. Ordinal breaks the tie, so the label is the same on every render. + group.GroupBy(spelling => spelling, StringComparer.Ordinal) + .OrderByDescending(spellings => spellings.Count()) + .ThenBy(spellings => spellings.Key, StringComparer.Ordinal) + .First().Key, + group.Count(), + values.Count)) + .OrderByDescending(share => share.Count) + .ThenBy(share => share.Label, StringComparer.Ordinal) + .ToList(); + + return new CodebaseUsage(families, values.Count, listed - values.Count); + } + + /// + /// One row per capability worth reporting, measured beside declared. + /// + /// + /// + /// A capability nothing offered is reported as unmeasured rather than as nought per cent, + /// and that is derived from the tally rather than compiled in: if no listed game has ever been + /// observed to offer a protocol, the honest reading is that our handshake does not reach it — TLS + /// is the standing example, because the probe dials plain telnet and TLS is not a telnet option. + /// The day the first measurement lands the column starts reporting a share on its own. + /// + /// + /// §9's headline four are listed whether or not anything is known about them, because "we have not + /// measured TLS yet" is only visible if the row exists. Everything else appears when there is + /// something to say, including capabilities no registry lists — a server naming a protocol we do + /// not carry a column for is still a measurement (see FieldObservations). + /// + /// + private static IReadOnlyList ProtocolsOf( + IReadOnlyList tallies, + int handshakes, + int msspReports) + { + var offered = new Dictionary(StringComparer.Ordinal); + var declined = new Dictionary(StringComparer.Ordinal); + var declared = new Dictionary(StringComparer.Ordinal); + + foreach (var tally in tallies) + { + if (CapabilityFields.NameOf(tally.Field) is not { } name) + { + continue; + } + + var bucket = (CapabilityFields.IsMeasured(tally.Field), tally.Value) switch + { + (true, "true") => offered, + (true, "false") => declined, + (false, "true") => declared, + _ => null, + }; + + if (bucket is null) + { + continue; + } + + bucket[name] = bucket.GetValueOrDefault(name) + tally.Games; + } + + // A protocol we have observed at all — in either direction — has a measurable column. One we + // have never observed has none, and saying "0%" of it would be our own reach reported as the + // hobby's. + var measurable = offered.Keys.Concat(declined.Keys).ToHashSet(StringComparer.Ordinal); + + var names = EcosystemProtocols.Headline + .Concat(measurable.Concat(declared.Keys).Order(StringComparer.Ordinal)) + .Distinct(StringComparer.Ordinal) + .ToList(); + + return names + .Select(name => new ProtocolAdoption( + name, + measurable.Contains(name) ? offered.GetValueOrDefault(name) : null, + declined.GetValueOrDefault(name), + handshakes, + declared.GetValueOrDefault(name), + msspReports)) + .ToList(); + } + private static ChangeEntry Describe(FieldChange change) => new( change.At, change.OldValue is null @@ -519,6 +832,52 @@ private sealed class ActivityRow public double? Mean { get; init; } } + private sealed class EcosystemTotalsRow + { + public int Listed { get; init; } + + public int Handshakes { get; init; } + + public int MsspReports { get; init; } + + public DateTimeOffset? OldestHandshake { get; init; } + + public int CapabilityTransitions { get; init; } + } + + private sealed class CapabilityTallyRow + { + public string Field { get; init; } = string.Empty; + + public string Value { get; init; } = string.Empty; + + public int Games { get; init; } + } + + private sealed class BusiestRow + { + public string Slug { get; init; } = string.Empty; + + public string Name { get; init; } = string.Empty; + + public int Median { get; init; } + + public int Peak { get; init; } + + public int Samples { get; init; } + + public int Eligible { get; init; } + } + + private sealed class SpellRow + { + public string Slug { get; init; } = string.Empty; + + public string Name { get; init; } = string.Empty; + + public DateTimeOffset Since { get; init; } + } + private sealed class FeedRow { public string Slug { get; init; } = string.Empty; diff --git a/src/MUI.Catalog/Views.cs b/src/MUI.Catalog/Views.cs index 55e1f3d..a9aefbc 100644 --- a/src/MUI.Catalog/Views.cs +++ b/src/MUI.Catalog/Views.cs @@ -158,6 +158,14 @@ public interface IGameQueries /// The three liveness feeds (spec §9) — the differentiator no incumbent can publish. Task FeedsAsync(CancellationToken cancellationToken = default); + + /// + /// Codebase share and protocol adoption over the measured set (spec §9). Shares, never totals. + /// + Task EcosystemAsync(CancellationToken cancellationToken = default); + + /// Rankings computed from measured data only (spec §9). There is no vote anywhere. + Task RankingsAsync(CancellationToken cancellationToken = default); } public sealed record LivenessFeeds( diff --git a/src/MUI.Web/Components/EcosystemCopy.cs b/src/MUI.Web/Components/EcosystemCopy.cs new file mode 100644 index 0000000..717943f --- /dev/null +++ b/src/MUI.Web/Components/EcosystemCopy.cs @@ -0,0 +1,149 @@ +using MUI.Catalog; + +namespace MUI.Web.Components; + +/// +/// Every sentence the ecosystem dashboard and the rankings say, in one place. +/// +/// +/// +/// The graphical page and the plain surface render the same numbers, and on these two pages the +/// numbers are almost entirely made of their qualifications: a share is meaningless without its +/// denominator, and a protocol column is misleading without the sentence saying that a protocol we +/// did not see is not a protocol a game lacks. Sentences that load-bearing cannot be written twice — +/// the plain copy would drift into being the honest one and the graphic into being the quotable one, +/// which is precisely the failure mode §9 says plain mode exists to catch. +/// +/// +/// So the wording lives here and both surfaces read it. What differs between them is layout: one has +/// a bar beside the number and the other does not, and the bar is an illustration of a sentence that +/// is already complete without it. +/// +/// +public static class EcosystemCopy +{ + /// + /// A share as a number over the set it was counted in, always in that order. + /// + /// + /// The count and the denominator come first and the percentage second, because the percentage is + /// the derived figure and the one that travels when somebody quotes the page. An empty + /// denominator reads as nothing measured rather than as nought per cent — 0 of 0 is not 0%. + /// + public static string Share(MeasuredShare share) + { + ArgumentNullException.ThrowIfNull(share); + + return share.Fraction is { } fraction + ? $"{share.Count} of {share.Denominator} ({Wording.Percent(fraction)})" + : $"{share.Count} of {share.Denominator} — nothing measured yet"; + } + + /// What the measured column is a fraction of, spelled out wherever it is used. + public static string Handshakes(int games) => + $"{Games(games)} whose handshake we have completed"; + + /// What the declared column is a fraction of. A different set, deliberately named apart. + public static string MsspReports(int games) => + $"{Games(games)} whose MSSP report we hold"; + + /// The measured side of one protocol, including the case where there is no measurement. + public static string Measured(ProtocolAdoption protocol) + { + ArgumentNullException.ThrowIfNull(protocol); + + if (protocol.Measured is not { } share) + { + // Never "0%". Nothing has ever been observed to offer this, which is a statement about + // our reach and not about the hobby — TLS is the standing case, because the crawler dials + // plain telnet and TLS is not a telnet option. + return "not measured — nothing has been observed to offer it"; + } + + var rest = protocol.Declined > 0 + ? $" · {Games(protocol.Declined)} declined it when asked" + : string.Empty; + + return protocol.Unobserved > 0 + ? $"{Share(share)}{rest} · {Games(protocol.Unobserved)} neither offered nor were asked" + : $"{Share(share)}{rest}"; + } + + /// The declared side of one protocol. Always a share; a missing claim is not a claim. + public static string Declared(ProtocolAdoption protocol) + { + ArgumentNullException.ThrowIfNull(protocol); + + return Share(protocol.DeclaredShare); + } + + /// + /// The sentence without which the measured column is a lie by omission. + /// + /// + /// The crawler writes a capability down when it observes one and otherwise writes nothing, and it + /// is right to: it requests MSSP alone, declines MCCP outright, and has been measured against + /// live servers that plainly implement protocols they never offered our handshake. So the only + /// honest reading of the measured column is a floor, and a page that renders it without saying so + /// publishes our own instrumentation as a fact about somebody's game. + /// + public const string Floor = + "A protocol we did not see is not a protocol a game lacks. MSSP is asked for by name on " + + "every probe, so silence there is an answer and is counted as one; nothing else on this " + + "list is requested at all, and a server is free to support a protocol and never offer it. " + + "Read every measured figure below as a floor."; + + /// Why there is a snapshot here and not the curve §9 asks for. + /// + /// The alternative was available and is the reason this paragraph exists: every observation + /// carries a first_seen_at, and plotting those would draw a confident rising line that + /// measures our crawler reaching more games and nothing whatever about adoption. + /// + public const string NoCurve = + "This is a snapshot of what we can measure now, not a trend. A protocol adoption curve is a " + + "plot of games changing their minds, and the catalogue records a change only when it " + + "happens — so the curve becomes drawable once enough transitions have been recorded, and " + + "not before. Plotting when we first reached each game instead would draw a rising line " + + "measuring the crawl rather than the hobby."; + + /// Why there is no headline population figure, said where somebody might look for one. + public const string NoTotals = + "Shares, never totals. How many people play MU* is a number this site deliberately does not " + + "publish: a ratio over the games we measured survives the ones we cannot reach and the ones " + + "nobody has claimed, and a headcount does not survive either."; + + /// How many capability transitions have been recorded, and what that means for the curve. + public static string Transitions(int transitions) => transitions == 0 + ? "No measured capability has changed since we started watching, so there is nothing to plot yet." + : $"{Recorded(transitions)} recorded so far. That is the material a curve is drawn from."; + + /// The basis of the busiest table, stated on the page rather than in a footnote. + public static string BusiestBasis(Rankings rankings) + { + ArgumentNullException.ThrowIfNull(rankings); + + return $"Ranked on the median of the player counts we measured over the last " + + $"{(int)rankings.Window.TotalDays} days — {rankings.Eligible} of the " + + $"{Games(rankings.ListedGames)} listed produced the {rankings.MinimumSamples} counted " + + "samples a median needs. A probe that got in and could not read a number is not a " + + "zero and is not among them; a measured zero is a count and is."; + } + + /// What the second table is, and the limit it cannot be read past. + public const string SpellBasis = + "Every probe since the date given found the game reachable. Reachable, not up — we measured " + + "a socket from one vantage point, and a game with a routing problem to our host is " + + "unreachable and perfectly alive. A spell cannot be longer than we have been watching, " + + "which is why the date is the fact and the duration is derived from it."; + + /// Said on the rankings page, because §2 makes it permanent rather than pending. + public const string NoVote = + "Computed from measured data only. There is no vote, star or rating anywhere on this site " + + "and there never will be: vote-gaming is what reduced the last directory that tried it to " + + "a link graveyard. Nothing here ranks games by better or best, because we have not " + + "measured that and nobody can."; + + private static string Games(int n) => n == 1 ? "1 game" : $"{n} games"; + + private static string Recorded(int n) => n == 1 ? "1 capability change" : $"{n} capability changes"; +} diff --git a/src/MUI.Web/Components/Layout/MainLayout.razor b/src/MUI.Web/Components/Layout/MainLayout.razor index 79592f5..a68a4d2 100644 --- a/src/MUI.Web/Components/Layout/MainLayout.razor +++ b/src/MUI.Web/Components/Layout/MainLayout.razor @@ -24,6 +24,8 @@ every game here was checked by a machine, and every fact says when diff --git a/src/MUI.Web/Components/Pages/Ecosystem.razor b/src/MUI.Web/Components/Pages/Ecosystem.razor new file mode 100644 index 0000000..987d284 --- /dev/null +++ b/src/MUI.Web/Components/Pages/Ecosystem.razor @@ -0,0 +1,161 @@ +@page "/ecosystem" +@inject IGameQueries Queries +@inject TimeProvider Clock + +@* + The ecosystem dashboard (spec §9): what the measured catalogue runs, and what it offers. + + Two rules hold this page up, and both are load-bearing rather than stylistic. + + Shares, never totals. §15.7 withholds the absolute "how many people play MU*" figure because a + ratio over the games we reached survives the ones we cannot reach and the ones nobody claimed, + and a headcount survives neither. There is nothing on this page that sums a player count, and + the view model it renders has nowhere to put one. + + And a share is nothing without its denominator. "62% of games offer UTF-8" is not a fact until + "of the 431 games whose handshake we have completed" is attached to it, so the count and the + set come first on every line here and the percentage comes second. The bars are illustrations + of sentences that are already complete: a reader in plain mode loses the seeing-at-once and no + figure at all. +*@ + +@if (Plain) +{ +
@PlainText.RenderEcosystem(Dashboard, Now)
+} +else +{ + The ecosystem — mu*index + +

The ecosystem

+

@EcosystemCopy.NoTotals

+ +
    +
  • @Dashboard.ListedGames games listed
  • +
  • @Dashboard.Handshakes whose handshake we have completed
  • +
  • @Dashboard.MsspReports whose MSSP report we hold
  • +
+ + @if (Dashboard.OldestHandshake is { } oldest) + { +

The oldest handshake in this picture was last confirmed + @Relative.Format(Now - oldest) ago.

+ } + +
+

Codebases

+

+ Share of the @Dashboard.Codebases.Identified listed games that told us what they run. A + game whose codebase we could not read is counted as nothing at all, and never as + something else. +

+ + @if (Dashboard.Codebases.Families.Count == 0) + { +

No listed game has told us its codebase yet.

+ } + +
    + @foreach (var family in Dashboard.Codebases.Families) + { +
  • + + @* The bar carries no fact of its own, so it is hidden from assistive technology + rather than announced twice beside the figure that does. *@ + + +
  • + } +
+ + @if (Dashboard.Codebases.NotIdentified > 0) + { +

+ @Dashboard.Codebases.NotIdentified listed + @(Dashboard.Codebases.NotIdentified == 1 ? "game has" : "games have") not told us one. + They are outside the denominator above, not a share of it. +

+ } +
+ +
+

Protocols

+

@EcosystemCopy.Floor

+ +
+ + + + + + + + + + + @foreach (var protocol in Dashboard.Protocols) + { + + + + + + } + +
+ Protocol adoption. Measured is what a server offered our crawler in a completed + handshake; declared is what its MSSP claims. The two columns have different + denominators because they are different sets of games. +
protocolmeasured — of @EcosystemCopy.Handshakes(Dashboard.Handshakes)declared — of @EcosystemCopy.MsspReports(Dashboard.MsspReports)
@protocol.Protocol + @if (protocol.Measured is { Fraction: { } fraction }) + { + + } + + @EcosystemCopy.Measured(protocol) + + @EcosystemCopy.Declared(protocol)
+
+
+ +
+

Why this is a snapshot and not a curve

+

@EcosystemCopy.NoCurve

+

@EcosystemCopy.Transitions(Dashboard.CapabilityTransitions)

+
+ +

+ the rankings · + browse every game · + read this page as plain text +

+} + +@code { + [SupplyParameterFromQuery(Name = "plain")] private string? PlainFlag { get; set; } + + private bool Plain => Truthy.Is(PlainFlag); + + private DateTimeOffset Now => Clock.GetUtcNow(); + + private EcosystemDashboard Dashboard = EcosystemDashboard.Empty(default); + + protected override async Task OnParametersSetAsync() => Dashboard = await Queries.EcosystemAsync(); + + /// + /// A bar width, formatted by hand and under the invariant culture. + /// + /// + /// Neither half of that is fussiness. P1 writes a space before the sign, and + /// width: 39.4 % is a declaration the browser discards; a locale that writes a decimal + /// comma produces width: 39,4%, which it also discards. Both fail by making the bar vanish + /// on somebody else's machine, which is the class of defect a rendered frame here never shows. + /// + private static string Percent(double? fraction) => fraction is { } f + ? FormattableString.Invariant($"{f * 100:0.0}%") + : "0%"; +} diff --git a/src/MUI.Web/Components/Pages/RankingsPage.razor b/src/MUI.Web/Components/Pages/RankingsPage.razor new file mode 100644 index 0000000..c51e965 --- /dev/null +++ b/src/MUI.Web/Components/Pages/RankingsPage.razor @@ -0,0 +1,144 @@ +@page "/rankings" +@inject IGameQueries Queries +@inject TimeProvider Clock + +@* + The rankings (spec §9), computed from measured data only. + + What a ranking on this site can honestly be is a short list. "Busiest" is defensible once the + window, the statistic and the eligible set are stated: it is the median of counts we actually + read, over seven days, among the games that produced enough samples to have a median. "Best" is + not defensible at any length, because nobody measured it — and the affordance that would let + readers assert it is the one thing §2 rules out permanently, since vote-gaming is what reduced + Top Mud Sites to a link graveyard. + + So both tables here say what they rank on, over what window, out of how many games, and what + they exclude. A ranking whose arithmetic a reader cannot check is a ranking they have to trust, + and this site does not ask for trust. + + The component is RankingsPage and not Rankings because the view model it renders is called + Rankings, and a page class of that name shadows it inside its own code block. +*@ + +@if (Plain) +{ +
@PlainText.RenderRankings(Table, Now)
+} +else +{ + Rankings — mu*index + +

Rankings

+

@EcosystemCopy.NoVote

+ +
+

Busiest, by measured concurrent players

+

@EcosystemCopy.BusiestBasis(Table)

+ + @if (Table.Busiest.Count == 0) + { +

+ No listed game has produced enough counted samples to be ranked yet. That is a + statement about how long we have been measuring and not about how busy anybody is. +

+ } + else + { +
+ + + + + + + + + + + + + @{ var place = 0; } + @foreach (var game in Table.Busiest) + { + place++; + + + + @* The rank is on the median, so the median is the emphasised cell and + the peak sits beside it as context. A peak is one sample, and a game + that had forty players for a minute is not busier than one that has + thirty all day. *@ + + + + + } + +
+ Games ranked by the median of the player counts we measured over the last + @((int)Table.Window.TotalDays) days. +
#gamemedianpeakcounted samples
@place@game.Name@game.Median@game.Peak@game.Samples
+
+ } +
+ +
+

Longest unbroken reachable spell

+

@EcosystemCopy.SpellBasis

+ + @if (Table.LongestUnbroken.Count == 0) + { +

No listed game is in an unbroken reachable spell right now.

+ } + else + { +
+ + + + + + + + + + + + @{ var place = 0; } + @foreach (var spell in Table.LongestUnbroken) + { + place++; + + + + + + + } + +
+ Games whose every probe since the date given found them reachable. +
#gamereachable sincethat is
@place@spell.Name@spell.Since.ToString("d MMMM yyyy")@Wording.Duration(spell.LengthAt(Now))
+
+ } +
+ +

+ Archived games are out of both tables and of nothing else — their pages, URLs and series all + survive, and one successful probe puts them back. the archive · + the ecosystem · + read this page as plain text +

+} + +@code { + [SupplyParameterFromQuery(Name = "plain")] private string? PlainFlag { get; set; } + + private bool Plain => Truthy.Is(PlainFlag); + + private DateTimeOffset Now => Clock.GetUtcNow(); + + private Rankings Table = Rankings.Empty(default, TimeSpan.Zero, 0); + + protected override async Task OnParametersSetAsync() => Table = await Queries.RankingsAsync(); +} diff --git a/src/MUI.Web/Components/PlainText.cs b/src/MUI.Web/Components/PlainText.cs index aa25301..6927afd 100644 --- a/src/MUI.Web/Components/PlainText.cs +++ b/src/MUI.Web/Components/PlainText.cs @@ -423,6 +423,132 @@ public static string RenderAbout(AboutPage page) return b.ToString(); } + /// + /// The ecosystem dashboard, which is the page whose graphic is most obviously an illustration. + /// + /// + /// Every bar on the rendered page illustrates a sentence that is complete without it: "PennMUSH — + /// 122 of 310 (39.4%)" is the fact, and the bar is a way of seeing several of them at once. + /// Nothing is lost here but the seeing-at-once, which is the test §9 sets for a graphic. + /// + public static string RenderEcosystem(EcosystemDashboard dashboard, DateTimeOffset now) + { + ArgumentNullException.ThrowIfNull(dashboard); + + var b = new StringBuilder(); + + b.AppendLine("THE ECOSYSTEM"); + Wrap(b, EcosystemCopy.NoTotals); + b.AppendLine(); + Wrap(b, $"{dashboard.ListedGames} games listed · " + + $"{EcosystemCopy.Handshakes(dashboard.Handshakes)} · " + + $"{EcosystemCopy.MsspReports(dashboard.MsspReports)}."); + + if (dashboard.OldestHandshake is { } oldest) + { + Wrap(b, "The oldest handshake in this picture was last confirmed " + + $"{Relative.Format(now - oldest)} ago."); + } + + Heading(b, "CODEBASES"); + Wrap(b, $"Share of the {dashboard.Codebases.Identified} listed games that told us what they " + + "run. A game whose codebase we could not read is counted as nothing at all, and never " + + "as something else."); + b.AppendLine(); + + foreach (var family in dashboard.Codebases.Families) + { + b.AppendLine($" {family.Label,-24} {EcosystemCopy.Share(family)}"); + } + + if (dashboard.Codebases.Families.Count == 0) + { + b.AppendLine(" No listed game has told us its codebase yet."); + } + + if (dashboard.Codebases.NotIdentified > 0) + { + b.AppendLine(); + b.AppendLine($" {dashboard.Codebases.NotIdentified} listed game(s) have not told us one."); + } + + Heading(b, "PROTOCOLS"); + Wrap(b, EcosystemCopy.Floor); + b.AppendLine(); + + foreach (var protocol in dashboard.Protocols) + { + b.AppendLine($" {protocol.Protocol}"); + Wrap(b, $"measured: {EcosystemCopy.Measured(protocol)}", " "); + Wrap(b, $"declared: {EcosystemCopy.Declared(protocol)}", " "); + } + + b.AppendLine(); + Wrap(b, $"Measured is of {EcosystemCopy.Handshakes(dashboard.Handshakes)}; declared is of " + + $"{EcosystemCopy.MsspReports(dashboard.MsspReports)}. Two denominators, because they " + + "are two different sets of games."); + + Heading(b, "WHY THIS IS A SNAPSHOT AND NOT A CURVE"); + Wrap(b, EcosystemCopy.NoCurve); + b.AppendLine(); + Wrap(b, EcosystemCopy.Transitions(dashboard.CapabilityTransitions)); + + return b.ToString(); + } + + /// The rankings, with every basis in the same words the rendered page uses. + public static string RenderRankings(Rankings rankings, DateTimeOffset now) + { + ArgumentNullException.ThrowIfNull(rankings); + + var b = new StringBuilder(); + + b.AppendLine("RANKINGS"); + Wrap(b, EcosystemCopy.NoVote); + + Heading(b, $"BUSIEST — median measured players, last {(int)rankings.Window.TotalDays} days"); + Wrap(b, EcosystemCopy.BusiestBasis(rankings)); + b.AppendLine(); + + if (rankings.Busiest.Count == 0) + { + Wrap(b, "No listed game has produced enough counted samples to be ranked yet. That is a " + + "statement about how long we have been measuring and not about how busy anybody " + + "is.", " "); + } + + var place = 0; + + foreach (var game in rankings.Busiest) + { + place++; + b.AppendLine($" {place,3} {game.Name}"); + b.AppendLine($" median {game.Median} · peak {game.Peak} · " + + $"{game.Samples} counted samples · /g/{game.Slug}"); + } + + Heading(b, "LONGEST UNBROKEN REACHABLE SPELL"); + Wrap(b, EcosystemCopy.SpellBasis); + b.AppendLine(); + + if (rankings.LongestUnbroken.Count == 0) + { + Wrap(b, "No listed game is in an unbroken reachable spell right now.", " "); + } + + place = 0; + + foreach (var spell in rankings.LongestUnbroken) + { + place++; + b.AppendLine($" {place,3} {spell.Name}"); + b.AppendLine($" reachable on every probe since {spell.Since:d MMMM yyyy} · " + + $"{Wording.Duration(spell.LengthAt(now))} · /g/{spell.Slug}"); + } + + return b.ToString(); + } + private static void Heading(StringBuilder b, string title) { b.AppendLine(); diff --git a/src/MUI.Web/Fixtures/FixtureGameQueries.cs b/src/MUI.Web/Fixtures/FixtureGameQueries.cs index 5563db5..4f4b6df 100644 --- a/src/MUI.Web/Fixtures/FixtureGameQueries.cs +++ b/src/MUI.Web/Fixtures/FixtureGameQueries.cs @@ -29,6 +29,14 @@ public sealed class FixtureGameQueries : IGameQueries, IAvailabilityHistory /// Fixed, so a rendered page is the same page tomorrow and a test can assert on it. public static readonly DateTimeOffset Now = new(2026, 7, 30, 20, 0, 0, TimeSpan.Zero); + /// + /// The same week and the same sample floor NpgsqlGameQueries ranks on, so the demo page + /// and the measured one describe their table the same way. + /// + private static readonly TimeSpan RankingWindow = TimeSpan.FromDays(7); + + private const int MinimumSamples = 24; + private static readonly GameSummary Mush = new( Guid.Parse("aaaaaaaa-0000-0000-0000-000000000001"), "m-u-s-h", "M*U*S*H", "The PennMUSH development server.", LifecycleState.Active, IsClaimed: false, @@ -235,7 +243,12 @@ private static ActivityCell[] Activity(GameSummary g) 0.14, 0.18, 0.22, 0.26, 0.34, 0.45, 0.62, 0.82, 1.00, 0.94, 0.72, 0.50, ]; double[] byDay = [0.72, 1.00, 0.78, 0.84, 0.92, 0.88, 0.66]; - const int Peak = 17; + + // Scaled to the game's own count rather than to one number for every game. A fixture where + // Aardwolf reports 219 players and draws the same week as a nine-player game is internally + // inconsistent in the way that matters here: the demo ranking reads off this series, and a + // league table whose every row ties is a table that cannot show whether it ranks anything. + var peak = g.PlayersNow ?? 17; var cells = new List(168); @@ -269,7 +282,7 @@ private static ActivityCell[] Activity(GameSummary g) (4, 15, _) => new ActivityCell(day, hour, null, Probed: true), _ => new ActivityCell( - day, hour, (int)Math.Round(shape[hour] * byDay[day] * Peak), Probed: true), + day, hour, (int)Math.Round(shape[hour] * byDay[day] * peak), Probed: true), }); } } @@ -350,6 +363,126 @@ AvailabilityInterval Span(double fromDaysAgo, double? toDaysAgo, AvailabilitySta }; } + /// + /// The dashboard, derived from this fixture's own games rather than from a table of percentages. + /// + /// + /// + /// A hand-written set of shares would be the one thing this file may not contain: a page whose + /// whole subject is that a ratio has a denominator, showing ratios that never had one. So every + /// figure here is counted off the eight games above by the same rules the Postgres reader uses — + /// archived games are out, the protocol denominator is the games with a reachable interval, and a + /// protocol no fixture game offers is unmeasured rather than nought per cent. + /// + /// + /// MSSP is the one protocol whose absence is written down as an absence, here as in the + /// crawler: we ask for it by name on every probe, so a game that answered and never engaged it has + /// declined a question that was put. Every other silence stays a silence. + /// + /// + public Task EcosystemAsync(CancellationToken cancellationToken = default) + { + var listed = All.Where(g => g.State is not LifecycleState.Archived).ToList(); + var handshaked = listed + .Where(g => Intervals(g).Any(i => i.State is AvailabilityState.Reachable)) + .ToList(); + + var codebases = listed.Select(g => g.Codebase).OfType().ToList(); + var families = codebases + .Select(CodebaseFamily.Of) + .GroupBy(family => family, StringComparer.OrdinalIgnoreCase) + .Select(group => new MeasuredShare(group.First(), group.Count(), codebases.Count)) + .OrderByDescending(share => share.Count) + .ThenBy(share => share.Label, StringComparer.Ordinal) + .ToList(); + + var offeredAnywhere = handshaked + .SelectMany(g => g.MeasuredProtocols) + .ToHashSet(StringComparer.Ordinal); + + // Games whose MSSP report we hold — not every listed game, because Midnight Sun answers and + // offers no MSSP at all. That is the second denominator, and it is a different set. + var reporting = listed + .Where(g => g.MeasuredProtocols.Contains("MSSP", StringComparer.Ordinal)) + .ToList(); + + var protocols = EcosystemProtocols.Headline + .Concat(offeredAnywhere.Order(StringComparer.Ordinal)) + .Distinct(StringComparer.Ordinal) + .Select(protocol => new ProtocolAdoption( + protocol, + offeredAnywhere.Contains(protocol) + ? handshaked.Count(g => g.MeasuredProtocols.Contains(protocol, StringComparer.Ordinal)) + : null, + + // The one honest negative: asked for on every probe, so silence is an answer. + protocol is "MSSP" + ? handshaked.Count(g => !g.MeasuredProtocols.Contains("MSSP", StringComparer.Ordinal)) + : 0, + handshaked.Count, + reporting.Count(g => Capabilities(g) + .Any(c => c.Protocol == protocol && c.Declared is CapabilityState.Present)), + reporting.Count)) + .ToList(); + + return Task.FromResult(new EcosystemDashboard( + Now, + listed.Count, + handshaked.Count, + reporting.Count, + Now.AddMinutes(-4), + + // Nothing in this fixture's change feed is a capability transition, which is the state a + // young crawl is really in: the page has to say the curve is not drawable yet, and a + // fixture that faked one would be teaching the page to lie. + CapabilityTransitions: 0, + new CodebaseUsage(families, codebases.Count, listed.Count - codebases.Count), + protocols)); + } + + /// + /// The rankings, computed off the same sampled week the heatmap renders. + /// + /// + /// The activity grid is a series of measured samples, so the median and the peak here are + /// real arithmetic over it rather than numbers typed beside a name. A game whose every sample is + /// unmeasurable produces no median and drops out of the table — which is the behaviour that has to + /// be visible, because reading those as zeros is the failure §5.4 exists to prevent. + /// + public Task RankingsAsync(CancellationToken cancellationToken = default) + { + var listed = All.Where(g => g.State is not LifecycleState.Archived).ToList(); + + var busiest = listed + .Select(g => (Game: g, Counts: Activity(g) + .Where(c => c.IsCounted) + .Select(c => c.Count!.Value) + .Order() + .ToList())) + .Where(entry => entry.Counts.Count >= MinimumSamples) + .Select(entry => new BusiestGame( + entry.Game.Slug, + entry.Game.Name, + entry.Counts[entry.Counts.Count / 2], + entry.Counts[^1], + entry.Counts.Count)) + .OrderByDescending(row => row.Median) + .ThenByDescending(row => row.Peak) + .ThenBy(row => row.Name, StringComparer.Ordinal) + .ToList(); + + var spells = listed + .Select(g => (Game: g, Open: Intervals(g) + .FirstOrDefault(i => i.IsOpen && i.State is AvailabilityState.Reachable))) + .Where(entry => entry.Open is not null) + .Select(entry => new ReachableSpell(entry.Game.Slug, entry.Game.Name, entry.Open!.FromAt)) + .OrderBy(spell => spell.Since) + .ToList(); + + return Task.FromResult(new Rankings( + Now, RankingWindow, MinimumSamples, listed.Count, busiest.Count, busiest, spells)); + } + public Task FeedsAsync(CancellationToken cancellationToken = default) => Task.FromResult(new LivenessFeeds( NewlyDiscovered: diff --git a/src/MUI.Web/wwwroot/app.css b/src/MUI.Web/wwwroot/app.css index 940dd43..ee4d5b0 100644 --- a/src/MUI.Web/wwwroot/app.css +++ b/src/MUI.Web/wwwroot/app.css @@ -535,3 +535,65 @@ ul.sources { list-style: none; margin: var(--cpad) 0 0; padding: 0; } ul.sources li { border-top: 1px solid var(--line); padding: var(--cpad) 0; } ul.sources .name { font-size: 15px; font-weight: 600; text-decoration: none; } ul.sources .source-head { display: flex; gap: var(--cpad); align-items: baseline; flex-wrap: wrap; } +/* + ── ecosystem dashboard & rankings ── + + The bars are illustrations and are styled as such: the accent means "measured" site-wide, so a + measured share is drawn in it and a declared figure is left in plain text — an amber bar beside a + green one would invite the two columns to be compared as if they were one measurement over one + set, which is the exact misreading their separate denominators exist to prevent. + + No bar carries a fact of its own. Every one of them sits beside the count and the denominator it + illustrates, and is aria-hidden, because a screen reader announcing "39.4 per cent" twice is worse + than announcing it once and a bar cannot say "of 310 games". +*/ + +ul.shares { list-style: none; margin: var(--cpad) 0; padding: 0; } +ul.shares li { + display: grid; + grid-template-columns: minmax(0, 12rem) minmax(0, 1fr) auto; + gap: var(--cpad); + align-items: center; + padding: var(--row-pad) 0; + border-bottom: 1px solid var(--line); +} +ul.shares .share-label { color: var(--text); } +ul.shares .share-figure { color: var(--dim); white-space: nowrap; } + +.share-track { + display: block; + height: 10px; + background: var(--recessed); + border-radius: 2px; + overflow: hidden; +} +.share-track.narrow { height: 6px; margin-bottom: 5px; max-width: 14rem; } +.share-fill { display: block; height: 100%; background: var(--accent); } + +/* Wide content scrolls inside its own box; the page body never scrolls sideways. */ +.table-wrap { overflow-x: auto; margin: var(--cpad) 0; } + +table.protocols, table.ranking { border-collapse: collapse; width: 100%; } +table.protocols th, table.protocols td, +table.ranking th, table.ranking td { + text-align: left; + vertical-align: top; + padding: var(--row-pad) var(--cpad) var(--row-pad) 0; + border-bottom: 1px solid var(--line); +} +table.protocols thead th, table.ranking thead th { + font: 10px/1.4 var(--kick); + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--faint); + font-weight: 400; +} +table.protocols tbody th { font-weight: 400; white-space: nowrap; } +table.protocols td.declared { color: var(--amber); } +table.ranking td.place, table.ranking th.place { width: 3rem; color: var(--faint); } +table.ranking tbody th { font-weight: 400; } + +@media (max-width: 900px) { + /* The label stops competing with the bar for a width neither of them has. */ + ul.shares li { grid-template-columns: minmax(0, 1fr); gap: 6px; } +} diff --git a/tests/MUI.Catalog.Tests/CodebaseFamilyTests.cs b/tests/MUI.Catalog.Tests/CodebaseFamilyTests.cs new file mode 100644 index 0000000..656e4c7 --- /dev/null +++ b/tests/MUI.Catalog.Tests/CodebaseFamilyTests.cs @@ -0,0 +1,49 @@ +namespace MUI.Catalog.Tests; + +/// +/// The fold that turns a CODEBASE value into the family the dashboard groups on. +/// +/// +/// It has to be tight in both directions. Too loose and a name gets truncated mid-phrase, which puts +/// a codebase on a public page under a name nobody uses; too tight and one codebase's share is spread +/// across every patch level in the wild, which answers no question anybody asked. +/// +public class CodebaseFamilyTests +{ + [Test] + [Arguments("PennMUSH 1.8.8p0", "PennMUSH")] + [Arguments("PennMUSH 1.8.7", "PennMUSH")] + [Arguments("TinyMUX 2.12", "TinyMUX")] + [Arguments("CoffeeMud v5.9", "CoffeeMud")] + [Arguments("Evennia", "Evennia")] + public async Task ATrailingVersionIsFoldedAway(string reported, string family) => + await Assert.That(CodebaseFamily.Of(reported)).IsEqualTo(family); + + [Test] + [Arguments("Midnight Sun")] + [Arguments("Ancient Anguish")] + public async Task ATwoWordNameKeepsBothWords(string reported) => + await Assert.That(CodebaseFamily.Of(reported)).IsEqualTo(reported); + + [Test] + public async Task ATrailingTokenThatIsNotAVersionIsLeftAlone() + { + // The failure this guards against is a truncation rather than a mis-grouping: folding on + // "starts with a digit" alone leaves "Rhost 4.0.4 (patchlevel", which is a name no game runs + // and no reader recognises. + await Assert.That(CodebaseFamily.Of("Rhost 4.0.4 (patchlevel 1)")) + .IsEqualTo("Rhost 4.0.4 (patchlevel 1)"); + } + + [Test] + public async Task AVersionOnItsOwnIsNotFoldedToNothing() => + await Assert.That(CodebaseFamily.Of("2.12")).IsEqualTo("2.12"); + + [Test] + public async Task SurroundingWhitespaceIsNotPartOfTheName() + { + // MSSP values are hand-typed into a config file and arrive as the game spelled them, so this + // is the ordinary case rather than the pathological one. + await Assert.That(CodebaseFamily.Of(" PennMUSH 1.8.8p0 ")).IsEqualTo("PennMUSH"); + } +} diff --git a/tests/MUI.Catalog.Tests/Persistence/EcosystemQueriesPostgresTests.cs b/tests/MUI.Catalog.Tests/Persistence/EcosystemQueriesPostgresTests.cs new file mode 100644 index 0000000..26aea02 --- /dev/null +++ b/tests/MUI.Catalog.Tests/Persistence/EcosystemQueriesPostgresTests.cs @@ -0,0 +1,342 @@ +using MUI.Catalog.Persistence; +using MUI.Catalog.Tests.Persistence.Support; + +namespace MUI.Catalog.Tests.Persistence; + +/// +/// The ecosystem dashboard and the rankings, against PostgreSQL. +/// +/// +/// These are assertions about denominators more than about numerators, because that is where +/// the arithmetic on these two pages goes wrong: a share whose denominator quietly swallows the games +/// we never reached reads as a fact about the hobby and is a fact about our crawl. The three that +/// matter are pinned by name below — a game we never handshaked is out of the protocol denominator +/// rather than in it as a no, a game that never told us its codebase is out of the codebase +/// denominator rather than in it as "other", and a protocol nothing has ever offered is unmeasured +/// rather than nought per cent. +/// +public class EcosystemQueriesPostgresTests +{ + private static readonly DateTimeOffset Now = Seed.Now; + + private static NpgsqlGameQueries QueriesOn(TestDatabase db) => + new(db.DataSource) { Clock = () => Now }; + + /// A game whose handshake completed, which is what a measured capability is measured in. + private static async Task HandshakedAsync(TestDatabase db, Guid game, DateTimeOffset? at = null) => + await new NpgsqlAvailabilityStore(db.DataSource).OpenAsync(new AvailabilityInterval + { + GameId = game, + State = AvailabilityState.Reachable, + FromAt = at ?? Now.AddDays(-30), + }); + + private static async Task FieldAsync( + TestDatabase db, Guid game, string field, FieldSource source, string value) => + await new NpgsqlGameFieldStore(db.DataSource).UpsertAsync( + new GameField(game, field, source, value, Now.AddDays(-30), Now.AddMinutes(-5))); + + private static ProtocolAdoption Protocol(EcosystemDashboard dashboard, string name) => + dashboard.Protocols.Single(p => p.Protocol == name); + + [Test] + public async Task AGameWeHaveNeverHandshakedIsOutOfTheDenominatorRatherThanInItAsANo() + { + // The whole page rests on this. A game nobody has reached tells us nothing about GMCP, and + // counting it in the denominator would let the share fall every time the crawler discovers an + // address it has not dialled yet — publishing our own backlog as the hobby's adoption curve. + await using var db = await PostgresFixture.MigratedAsync(); + var reached = await Seed.GameAsync(db, "reached", "Reached"); + await Seed.GameAsync(db, "never-dialled", "Never Dialled"); + await HandshakedAsync(db, reached); + await FieldAsync(db, reached, CapabilityFields.Measured("GMCP"), FieldSource.Handshake, "true"); + + var dashboard = await QueriesOn(db).EcosystemAsync(); + var gmcp = Protocol(dashboard, "GMCP"); + + await Assert.That(dashboard.ListedGames).IsEqualTo(2); + await Assert.That(dashboard.Handshakes).IsEqualTo(1); + await Assert.That(gmcp.Offered).IsEqualTo(1); + await Assert.That(gmcp.Declined).IsEqualTo(0); + await Assert.That(gmcp.Unobserved).IsEqualTo(0); + + // And the share is a share of the games we reached, not of the catalogue. + await Assert.That(gmcp.Measured!.Denominator).IsEqualTo(1); + await Assert.That(gmcp.Measured!.Fraction).IsEqualTo(1.0); + } + + [Test] + public async Task AHandshakeThatOfferedNothingIsStillInTheDenominator() + { + // The tempting denominator is "games with capability rows", which defines the denominator out + // of the numerator: a game whose handshake completed and produced no measurable capability + // would fall out of the bottom of every fraction and silently raise every share on the page. + await using var db = await PostgresFixture.MigratedAsync(); + var quiet = await Seed.GameAsync(db, "quiet", "Quiet"); + var talkative = await Seed.GameAsync(db, "talkative", "Talkative"); + await HandshakedAsync(db, quiet); + await HandshakedAsync(db, talkative); + await FieldAsync(db, talkative, CapabilityFields.Measured("GMCP"), FieldSource.Handshake, "true"); + + var dashboard = await QueriesOn(db).EcosystemAsync(); + + await Assert.That(dashboard.Handshakes).IsEqualTo(2); + await Assert.That(Protocol(dashboard, "GMCP").Measured!.Denominator).IsEqualTo(2); + await Assert.That(Protocol(dashboard, "GMCP").Unobserved).IsEqualTo(1); + } + + [Test] + public async Task AProtocolNothingHasOfferedIsUnmeasuredRatherThanNoughtPerCent() + { + // TLS is the standing case: the probe dials plain telnet and TLS is not a telnet option, so + // "0% of games offer TLS" would be a limit of our crawler published as a fact about the + // hobby. The rule is derived from the tally rather than compiled in, so the column starts + // reporting a share on its own the day the first measurement lands. + await using var db = await PostgresFixture.MigratedAsync(); + var game = await Seed.GameAsync(db); + await HandshakedAsync(db, game); + await FieldAsync(db, game, CapabilityFields.Measured("GMCP"), FieldSource.Handshake, "true"); + await FieldAsync(db, game, CapabilityFields.Declared("TLS"), FieldSource.Mssp, "true"); + + var dashboard = await QueriesOn(db).EcosystemAsync(); + var tls = Protocol(dashboard, "TLS"); + + await Assert.That(tls.Offered).IsNull(); + await Assert.That(tls.Measured).IsNull(); + + // The declared side is unaffected, and carries its own, different denominator. + await Assert.That(tls.Declared).IsEqualTo(1); + await Assert.That(tls.DeclaredShare.Denominator).IsEqualTo(dashboard.MsspReports); + } + + [Test] + public async Task OnlyAProtocolWeAskedForCountsSilenceAsANo() + { + // MSSP is requested by name on every probe, so a server that answered and never engaged it + // declined a question that was put — the crawler writes that down and nothing else. A game + // silent on GMCP is unobserved, and the two must never land in the same bucket. + await using var db = await PostgresFixture.MigratedAsync(); + var game = await Seed.GameAsync(db); + await HandshakedAsync(db, game); + await FieldAsync(db, game, CapabilityFields.Measured("MSSP"), FieldSource.Handshake, "false"); + + var dashboard = await QueriesOn(db).EcosystemAsync(); + + await Assert.That(Protocol(dashboard, "MSSP").Declined).IsEqualTo(1); + await Assert.That(Protocol(dashboard, "MSSP").Offered).IsEqualTo(0); + await Assert.That(Protocol(dashboard, "MSSP").Unobserved).IsEqualTo(0); + + await Assert.That(Protocol(dashboard, "GMCP").Offered).IsNull(); + } + + [Test] + public async Task ACodebaseShareCountsOnlyTheGamesThatToldUsOne() + { + // A game whose codebase we could not read is not a game running something else. Rolling those + // into a residual would publish our gap as somebody's market share. + await using var db = await PostgresFixture.MigratedAsync(); + var penn = await Seed.GameAsync(db, "penn", "Penn"); + var mux = await Seed.GameAsync(db, "mux", "Mux"); + await Seed.GameAsync(db, "unknown", "Unknown"); + await FieldAsync(db, penn, "CODEBASE", FieldSource.Mssp, "PennMUSH 1.8.8p0"); + await FieldAsync(db, mux, "CODEBASE", FieldSource.Mssp, "TinyMUX 2.12"); + + var codebases = (await QueriesOn(db).EcosystemAsync()).Codebases; + + await Assert.That(codebases.Identified).IsEqualTo(2); + await Assert.That(codebases.NotIdentified).IsEqualTo(1); + await Assert.That(codebases.Families.All(f => f.Denominator == 2)).IsTrue(); + await Assert.That(codebases.Families.Select(f => f.Label).ToList()) + .IsEquivalentTo(new[] { "PennMUSH", "TinyMUX" }); + } + + [Test] + public async Task TwoPatchLevelsOfOneCodebaseAreOneShare() + { + // Market share is a question about codebases, not point releases: spreading PennMUSH across + // as many rows as there are patch levels in the wild answers no question anybody asked. + await using var db = await PostgresFixture.MigratedAsync(); + var older = await Seed.GameAsync(db, "older", "Older"); + var newer = await Seed.GameAsync(db, "newer", "Newer"); + await FieldAsync(db, older, "CODEBASE", FieldSource.Mssp, "PennMUSH 1.8.7"); + await FieldAsync(db, newer, "CODEBASE", FieldSource.Mssp, "PennMUSH 1.8.8p0"); + + var families = (await QueriesOn(db).EcosystemAsync()).Codebases.Families; + + await Assert.That(families).Count().IsEqualTo(1); + await Assert.That(families[0]).IsEqualTo(new MeasuredShare("PennMUSH", 2, 2)); + } + + [Test] + public async Task TheLadderPicksTheCodebaseAndTheSqlDoesNotInventItsOwn() + { + // §5.1's ladder is resolved in the query here rather than in memory, because the dashboard + // reads every game at once. It has to reach the same answer FieldPrecedence would: a banner + // reading is a real observation and it loses to what the game itself declares. + await using var db = await PostgresFixture.MigratedAsync(); + var game = await Seed.GameAsync(db); + await FieldAsync(db, game, "CODEBASE", FieldSource.Banner, "Rhost"); + await FieldAsync(db, game, "CODEBASE", FieldSource.Mssp, "PennMUSH 1.8.8p0"); + + var families = (await QueriesOn(db).EcosystemAsync()).Codebases.Families; + + await Assert.That(families).Count().IsEqualTo(1); + await Assert.That(families[0].Label).IsEqualTo("PennMUSH"); + } + + [Test] + public async Task AnArchivedGameIsOutOfTheDashboardAndOutOfBothRankings() + { + // Archiving is a presentation change and it changes exactly this much (§7.5). A game that + // stopped answering is a fact about when it stopped, and its last handshake is not evidence + // about what the hobby runs now. + await using var db = await PostgresFixture.MigratedAsync(); + var gone = await Seed.GameAsync(db, "gaslight-row", "Gaslight Row", LifecycleState.Archived); + await HandshakedAsync(db, gone); + await FieldAsync(db, gone, "CODEBASE", FieldSource.Mssp, "PennMUSH 1.8.5"); + await FieldAsync(db, gone, CapabilityFields.Measured("GMCP"), FieldSource.Handshake, "true"); + + var presence = new NpgsqlPresenceStore(db.DataSource); + for (var hour = 1; hour <= 30; hour++) + { + await presence.AppendAsync( + PresenceSample.Counted(gone, Now.AddHours(-hour), 40, FieldSource.Who)); + } + + var dashboard = await QueriesOn(db).EcosystemAsync(); + var rankings = await QueriesOn(db).RankingsAsync(); + + await Assert.That(dashboard.ListedGames).IsEqualTo(0); + await Assert.That(dashboard.Handshakes).IsEqualTo(0); + await Assert.That(dashboard.Codebases.Families).IsEmpty(); + await Assert.That(Protocol(dashboard, "GMCP").Offered).IsNull(); + await Assert.That(rankings.Busiest).IsEmpty(); + await Assert.That(rankings.LongestUnbroken).IsEmpty(); + } + + [Test] + public async Task TheBusiestRankingIsAMedianOverAStatedFloorOfSamples() + { + // A median over three samples is not a median, and a game found on Friday would otherwise + // take the top of the table off one lucky evening probe — which ranks our crawl schedule. + await using var db = await PostgresFixture.MigratedAsync(); + var steady = await Seed.GameAsync(db, "steady", "Steady"); + var spike = await Seed.GameAsync(db, "spike", "Spike"); + var presence = new NpgsqlPresenceStore(db.DataSource); + + for (var hour = 1; hour <= 30; hour++) + { + await presence.AppendAsync( + PresenceSample.Counted(steady, Now.AddHours(-hour), hour <= 15 ? 10 : 20, FieldSource.Who)); + } + + // One enormous reading, and nothing else. Not enough to be ranked at all. + await presence.AppendAsync(PresenceSample.Counted(spike, Now.AddHours(-1), 900, FieldSource.Who)); + + var rankings = await QueriesOn(db).RankingsAsync(); + + await Assert.That(rankings.MinimumSamples).IsEqualTo(NpgsqlGameQueries.MinimumRankingSamples); + await Assert.That(rankings.ListedGames).IsEqualTo(2); + await Assert.That(rankings.Eligible).IsEqualTo(1); + await Assert.That(rankings.Busiest.Select(g => g.Slug).ToList()).IsEquivalentTo(new[] { "steady" }); + + var ranked = rankings.Busiest.Single(); + await Assert.That(ranked.Samples).IsEqualTo(30); + await Assert.That(ranked.Peak).IsEqualTo(20); + + // percentile_disc, so the median is a number some probe actually read and never an average + // of two that nobody measured. + await Assert.That(ranked.Median).IsEqualTo(10); + } + + [Test] + public async Task AnUncountableProbeIsNotAZeroInARanking() + { + // Rule 4 in the place it is most tempting to break: a game whose DOING header we cannot parse + // would otherwise sink to the bottom of a league table while running perfectly well. + await using var db = await PostgresFixture.MigratedAsync(); + var game = await Seed.GameAsync(db, "midnight-sun", "Midnight Sun II"); + var presence = new NpgsqlPresenceStore(db.DataSource); + + for (var hour = 1; hour <= 30; hour++) + { + await presence.AppendAsync( + PresenceSample.Counted(game, Now.AddHours(-hour), 12, FieldSource.Who)); + await presence.AppendAsync(PresenceSample.Unmeasurable( + game, Now.AddHours(-hour).AddMinutes(30), UnmeasurableReason.WhoUnparseable)); + } + + var ranked = (await QueriesOn(db).RankingsAsync()).Busiest.Single(); + + await Assert.That(ranked.Samples).IsEqualTo(30); + await Assert.That(ranked.Median).IsEqualTo(12); + } + + [Test] + public async Task OnlyAnOpenReachableSpellCounts() + { + // The table is "reachable on every probe since", so a game that is unreachable right now has + // no spell at all, and one whose reachable run ended last week has an ended run. + await using var db = await PostgresFixture.MigratedAsync(); + var running = await Seed.GameAsync(db, "running", "Running"); + var down = await Seed.GameAsync(db, "down", "Down"); + var store = new NpgsqlAvailabilityStore(db.DataSource); + + await HandshakedAsync(db, running, Now.AddDays(-100)); + await store.OpenAsync(new AvailabilityInterval + { + GameId = down, + State = AvailabilityState.Reachable, + FromAt = Now.AddDays(-300), + ToAt = Now.AddDays(-7), + }); + await store.OpenAsync(new AvailabilityInterval + { + GameId = down, + State = AvailabilityState.Unreachable, + FromAt = Now.AddDays(-7), + Cause = FailureCause.Refused, + }); + + var spells = (await QueriesOn(db).RankingsAsync()).LongestUnbroken; + + await Assert.That(spells.Select(s => s.Slug).ToList()).IsEquivalentTo(new[] { "running" }); + await Assert.That(spells[0].LengthAt(Now)).IsEqualTo(TimeSpan.FromDays(100)); + } + + [Test] + public async Task AnEmptyCatalogueReportsEmptySetsRatherThanZeroPerCent() + { + await using var db = await PostgresFixture.MigratedAsync(); + + var dashboard = await QueriesOn(db).EcosystemAsync(); + var rankings = await QueriesOn(db).RankingsAsync(); + + await Assert.That(dashboard.Handshakes).IsEqualTo(0); + await Assert.That(dashboard.OldestHandshake).IsNull(); + await Assert.That(dashboard.CapabilityTransitions).IsEqualTo(0); + + // Nothing measured is not nought per cent, and the view model has to be able to say so. + await Assert.That(Protocol(dashboard, "UTF-8").DeclaredShare.Fraction).IsNull(); + await Assert.That(rankings.Eligible).IsEqualTo(0); + } + + [Test] + public async Task ACapabilityTransitionIsCountedBecauseItIsWhatACurveWouldBeDrawnFrom() + { + // The page says it cannot draw a curve yet. This is the number that says when it can. + await using var db = await PostgresFixture.MigratedAsync(); + var game = await Seed.GameAsync(db); + var fields = new NpgsqlGameFieldStore(db.DataSource); + + await fields.RecordChangeAsync(new FieldChange( + game, CapabilityFields.Measured("GMCP"), FieldSource.Handshake, "false", "true", Now.AddDays(-2))); + await fields.RecordChangeAsync(new FieldChange( + game, "GENRE", FieldSource.Mssp, "Fantasy", "Modern", Now.AddDays(-1))); + + var dashboard = await QueriesOn(db).EcosystemAsync(); + + // Only capability transitions. A game renaming its genre is a change and is not this curve. + await Assert.That(dashboard.CapabilityTransitions).IsEqualTo(1); + } +} diff --git a/tests/MUI.Catalog.Tests/Persistence/Support/PostgresFixture.cs b/tests/MUI.Catalog.Tests/Persistence/Support/PostgresFixture.cs index c61c267..cf02cd3 100644 --- a/tests/MUI.Catalog.Tests/Persistence/Support/PostgresFixture.cs +++ b/tests/MUI.Catalog.Tests/Persistence/Support/PostgresFixture.cs @@ -100,6 +100,16 @@ private static async Task AdminAsync() { _container = new PostgreSqlBuilder("postgres:17-alpine") .WithCleanUp(true) + + // A database per test, and tests in parallel, means one live connection pool per + // test against one server. Postgres allows a hundred clients by default and the + // suite reached that ceiling on the run that took it past a hundred and thirty + // tests — as "sorry, too many clients already", which fails whichever tests + // happened to be starting and so reports itself as a flake in twenty unrelated + // places. The ceiling is a property of this fixture's shape and not of anything + // under test, so it is lifted here rather than paid for by whoever adds the next + // storage test. + .WithCommand("-c", "max_connections=500") .Build(); await _container.StartAsync(); diff --git a/tests/MUI.Web.Tests/EcosystemSurfaceTests.cs b/tests/MUI.Web.Tests/EcosystemSurfaceTests.cs new file mode 100644 index 0000000..bb8ed9c --- /dev/null +++ b/tests/MUI.Web.Tests/EcosystemSurfaceTests.cs @@ -0,0 +1,215 @@ +using MUI.Catalog; +using MUI.Web.Components; +using MUI.Web.Fixtures; + +namespace MUI.Web.Tests; + +/// +/// The two aggregate surfaces, asserted in words. +/// +/// +/// A dashboard is where a site's honesty is cheapest to lose, because a percentage is quotable and a +/// qualification is not. The three assertions worth having are all about what the page must +/// refuse to say: it may not publish an absolute player figure, it may not print a share +/// without the set it is a share of, and it may not describe a snapshot as a trend. +/// +public class EcosystemSurfaceTests +{ + private static readonly DateTimeOffset Now = FixtureGameQueries.Now; + private static readonly FixtureGameQueries Queries = new(); + + private static async Task EcosystemAsync() => + PlainText.RenderEcosystem(await Queries.EcosystemAsync(), Now); + + private static async Task RankingsAsync() => + PlainText.RenderRankings(await Queries.RankingsAsync(), Now); + + [Test] + public async Task NoAbsolutePlayerFigureIsEmittedByEitherSurface() + { + // §15.7, and the "Never" list. The absolute "how many people play MU*" number is withheld + // because a ratio over the measured set survives the unclaimed and unreachable biases and a + // headcount survives neither. The pin is the arithmetic itself: sum the fixture's measured + // counts and require that the number never appears, so a future "total players on right now" + // fails here rather than in review. + var listed = await Queries.ListAsync(new GameFilter()); + var total = listed.Sum(g => g.PlayersNow ?? 0).ToString(); + var text = await EcosystemAsync() + await RankingsAsync(); + + await Assert.That(total).IsNotEqualTo("0"); + await Assert.That(text).DoesNotContain(total); + await Assert.That(text).DoesNotContain("players in total"); + await Assert.That(text).DoesNotContain("total players"); + await Assert.That(text).DoesNotContain("across all games"); + + // And it says out loud that the omission is deliberate rather than an oversight. + await Assert.That(Render.Words(text)).Contains("Shares, never totals"); + } + + [Test] + public async Task EveryPercentageOnTheDashboardArrivesWithItsDenominator() + { + // "62% of games offer UTF-8" is not a fact until "of the 431 games whose handshake we have + // completed" is attached to it. The count and the set come first on every line, and the + // percentage second, so a line carrying one and not the other is the defect. + var lines = (await EcosystemAsync()) + .Split('\n') + .Where(line => line.Contains('%', StringComparison.Ordinal)) + .ToList(); + + await Assert.That(lines).IsNotEmpty(); + + foreach (var line in lines) + { + await Assert.That(line).Contains(" of "); + } + } + + [Test] + public async Task BothDenominatorsAreNamedRatherThanImplied() + { + // Measured and declared are counted over two different sets of games, and a page that named + // one denominator for both would be comparing a share of the reachable against a share of the + // talkative and calling the difference adoption. + var text = Render.Words(await EcosystemAsync()); + + await Assert.That(text).Contains("whose handshake we have completed"); + await Assert.That(text).Contains("whose MSSP report we hold"); + await Assert.That(text).Contains("two different sets of games"); + } + + [Test] + public async Task AProtocolWithNoMeasurementSaysSoRatherThanShowingNoughtPerCent() + { + // The one place a true number would be a false statement. Nothing has been observed to offer + // this, which is a fact about our reach; "0.0%" is a claim about everybody else's servers. + var never = new ProtocolAdoption("TLS", Offered: null, Declined: 0, Handshakes: 400, Declared: 12, MsspReports: 300); + + await Assert.That(EcosystemCopy.Measured(never)).Contains("not measured"); + await Assert.That(EcosystemCopy.Measured(never)).DoesNotContain("0.0%"); + await Assert.That(EcosystemCopy.Measured(never)).DoesNotContain("0 of 400"); + + // The declared side is unaffected and still carries its own set. + await Assert.That(EcosystemCopy.Declared(never)).IsEqualTo("12 of 300 (4.0%)"); + } + + [Test] + public async Task AnEmptyDenominatorIsNothingMeasuredAndNotNoughtPerCent() + { + // 0 of 0 is not 0%, in the same way CapabilityState.Unknown is not Absent. + var nothing = new MeasuredShare("GMCP", 0, 0); + + await Assert.That(nothing.Fraction).IsNull(); + await Assert.That(EcosystemCopy.Share(nothing)).Contains("nothing measured yet"); + await Assert.That(EcosystemCopy.Share(nothing)).DoesNotContain("%"); + } + + [Test] + public async Task TheMeasuredColumnIsSaidToBeAFloor() + { + // The crawler writes a capability down when it observes one and otherwise writes nothing, + // because it requests MSSP alone and declines MCCP outright. Printing that column without + // saying so publishes our own instrumentation as a fact about somebody's game. + var text = Render.Words(await EcosystemAsync()); + + await Assert.That(text).Contains("A protocol we did not see is not a protocol a game lacks"); + await Assert.That(text).Contains("as a floor"); + } + + [Test] + public async Task TheDashboardCallsItselfASnapshotAndNotATrend() + { + // §9 asks for adoption curves and the store cannot honestly draw one yet. Saying so is better + // than a plot of first sightings, which would draw a confident rising line measuring the + // crawl reaching more games and nothing about anybody adopting anything. + var text = Render.Words(await EcosystemAsync()); + + await Assert.That(text).Contains("not a trend"); + await Assert.That(text).Contains("nothing to plot yet"); + await Assert.That(text).DoesNotContain("growth"); + } + + [Test] + public async Task AGameWithNoCodebaseIsOutsideTheDenominatorAndSaidToBe() + { + var dashboard = await Queries.EcosystemAsync(); + var text = Render.Words(await EcosystemAsync()); + + await Assert.That(dashboard.Codebases.NotIdentified).IsGreaterThan(0); + await Assert.That(dashboard.Codebases.Families.Sum(f => f.Count)) + .IsEqualTo(dashboard.Codebases.Identified); + await Assert.That(text).Contains("counted as nothing at all, and never as something else"); + await Assert.That(text).Contains("outside the denominator") + .Or.Contains("have not told us one"); + } + + [Test] + public async Task TheRankingsStateWhatTheyRankOnAndOverWhatWindow() + { + var text = Render.Words(await RankingsAsync()); + + await Assert.That(text).Contains("median of the player counts we measured over the last 7 days"); + await Assert.That(text).Contains("counted samples a median needs"); + await Assert.That(text).Contains("is not a zero and is not among them"); + } + + [Test] + public async Task TheRankingsOfferNoVoteAndClaimNoBest() + { + // §2's permanent non-goal, said on the surface a reader would look for it on. + var text = Render.Words(await RankingsAsync()); + + await Assert.That(text).Contains("There is no vote, star or rating anywhere on this site"); + await Assert.That(text.ToLowerInvariant()).DoesNotContain("rate this"); + await Assert.That(text.ToLowerInvariant()).DoesNotContain("top rated"); + } + + [Test] + public async Task AMeasuredZeroIsRankedRatherThanDroppedFromTheTable() + { + // Eldertale sits at a measured zero across the week. That is a measurement and a strong one, + // and a league table that quietly omitted it would be softening a fact into a shrug. + var rankings = await Queries.RankingsAsync(); + var lowest = rankings.Busiest[^1]; + + await Assert.That(lowest.Slug).IsEqualTo("eldertale"); + await Assert.That(lowest.Median).IsEqualTo(0); + await Assert.That(lowest.Samples).IsGreaterThan(0); + } + + [Test] + public async Task AGameThatCannotBeCountedIsNotRankedAtZero() + { + // Midnight Sun answers and offers nothing we can count. It has no median, so it is absent + // from the busiest table — and it is present in the reachability one, because those are two + // different measurements and only one of them failed. + var rankings = await Queries.RankingsAsync(); + + await Assert.That(rankings.Busiest.Any(g => g.Slug == "midnight-sun")).IsFalse(); + await Assert.That(rankings.LongestUnbroken.Any(s => s.Slug == "midnight-sun")).IsTrue(); + } + + [Test] + public async Task AnArchivedGameIsInNeitherTable() + { + var rankings = await Queries.RankingsAsync(); + var dashboard = await Queries.EcosystemAsync(); + var archived = await Queries.ListAsync(new GameFilter { Band = ActivityBand.Archived }); + var slugs = archived.Select(g => g.Slug).ToHashSet(StringComparer.Ordinal); + + await Assert.That(slugs).IsNotEmpty(); + await Assert.That(rankings.Busiest.Any(g => slugs.Contains(g.Slug))).IsFalse(); + await Assert.That(rankings.LongestUnbroken.Any(s => slugs.Contains(s.Slug))).IsFalse(); + await Assert.That(dashboard.ListedGames).IsEqualTo(8 - slugs.Count); + } + + [Test] + public async Task NothingOnEitherSurfaceExceedsEightyColumns() + { + // The plain surface is the test of the whole system, and a text browser is eighty wide. + foreach (var line in (await EcosystemAsync() + await RankingsAsync()).Split('\n')) + { + await Assert.That(line.TrimEnd().Length).IsLessThanOrEqualTo(PlainText.Columns); + } + } +}