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 stored — FieldObservations 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
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.
+
+ @foreach (var family in Dashboard.Codebases.Families)
+ {
+
+ @family.Label
+ @* The bar carries no fact of its own, so it is hidden from assistive technology
+ rather than announced twice beside the figure that does. *@
+
+
+
+ @EcosystemCopy.Share(family)
+
+ @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
+
+
+
+
+ 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.
+
+
+
+
protocol
+
measured — of @EcosystemCopy.Handshakes(Dashboard.Handshakes)
+
declared — of @EcosystemCopy.MsspReports(Dashboard.MsspReports)
+}
+
+@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
+ {
+
+
+
+ Games ranked by the median of the player counts we measured over the last
+ @((int)Table.Window.TotalDays) days.
+
+
+
+
#
+
game
+
median
+
peak
+
counted samples
+
+
+
+ @{ 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. *@
+
@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
+ {
+
+
+
+ Games whose every probe since the date given found them reachable.
+
+
+
+
#
+
game
+
reachable since
+
that is
+
+
+
+ @{ var place = 0; }
+ @foreach (var spell in Table.LongestUnbroken)
+ {
+ place++;
+
+ 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);
+ }
+ }
+}