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); + } + } +}