diff --git a/docs/specs/2026-10-15-sharpmush-mssp-variables.md b/docs/specs/2026-10-15-sharpmush-mssp-variables.md new file mode 100644 index 00000000..7c4812fc --- /dev/null +++ b/docs/specs/2026-10-15-sharpmush-mssp-variables.md @@ -0,0 +1,30 @@ +# Six more MSSP variables, read for what they say + +SharpMUSH reports six variables beyond the generic MSSP set. Any codebase may send them; they arrive +over option 70 or the plaintext `MSSP-REQUEST` like every other variable, are stored under the `mssp` +source, and render as **declared** — the game's statement about itself, never a measurement of ours. +None of them reaches presence, reachability or a ranking. Names and reading rules live in one place, +`SelfReport` (`src/MUI.Catalog/Fields/SelfReport.cs`). + +| Variable | Holds | How it is stored and shown | +|---|---|---| +| `PLAYERS DAY` | distinct players connected in the last 24 hours | `FieldReconciler.MovingFields`: overwritten without a change row, refresh window one day (`FieldRegistry.Rolling`) | +| `PLAYERS WEEK` | distinct players connected in the last 7 days | as `PLAYERS DAY` | +| `LOGINS` | `1`/`0`: players may log in | `FieldShape.Flag`, shown yes/no; a flip is a change-feed event | +| `PLAYER CREATION` | `1`/`0`: new characters at the login screen | as `LOGINS` | +| `GUESTS` | `1`/`0`: guests may connect | as `LOGINS` | +| `PORTAL APPLICATION` | repeated: an absolute URL, a space, a display name | every value kept (`SelfReport.Lists`, one per line); a line is a link only when it is an http(s) address and a name | + +**The two counts move without an event.** They change whenever somebody new connects, so a +`field_change` row per probe would bury every real event in the feed — the reason `PLAYERS` and +`UPTIME` are not stored at all. Unlike those, nothing else holds these numbers, so they are kept, +dated by `last_confirmed_at`, and overwritten in place. The game page says them under our measured +figure and apart from it — "By the game's own count: 12 different players in the last day, 40 in the +last week" with a declared chip and its age — and never feeds them into the presence series: that +series holds counts read at a moment, and a game's own sum over a window is not one. + +**Rule 4 holds on both sides of the store.** A count that is not a whole number at or above zero is +kept verbatim, because the row holds what the game said, but it is never read as a number: it stays +under "Declared by the game" in the game's own words and the sentence leaves that side out. A flag +other than `1` or `0` prints as sent rather than as our paraphrase of it. A portal line that is not an +address and a name prints as text — refused as a link, never hidden. diff --git a/src/MUI.Catalog/Fields/Fields.cs b/src/MUI.Catalog/Fields/Fields.cs index 3de20244..7f0f636d 100644 --- a/src/MUI.Catalog/Fields/Fields.cs +++ b/src/MUI.Catalog/Fields/Fields.cs @@ -60,6 +60,12 @@ public enum FieldShape /// An email address — or, in practice, a contact page. See . Email, + + /// + /// 1 or 0, shown as yes or no. Never a link; anything else prints as written. + /// See . + /// + Flag, } /// @@ -142,7 +148,8 @@ Task> ForGameAsync( /// /// Turns observations into stored fields, doing exactly one of two things to each (spec §5.1): /// confirm — bump last_confirmed_at and write nothing else — or change — update -/// the row and append a . +/// the row and append a . A field that moves by nature +/// (FieldReconciler.MovingFields) is changed without the append. /// public interface IFieldReconciler { @@ -154,7 +161,11 @@ Task ApplyAsync( } /// What a reconciliation did, so a caller can assert on it rather than on the store. -public sealed record FieldReconciliation(int Confirmed, int Changed, int Added) +/// +/// Rows of FieldReconciler.MovingFields given a new value with no change row — counted apart +/// from , which means "reached the change feed". +/// +public sealed record FieldReconciliation(int Confirmed, int Changed, int Added, int Overwritten = 0) { public static readonly FieldReconciliation Nothing = new(0, 0, 0); } diff --git a/src/MUI.Catalog/Fields/MsspLint.cs b/src/MUI.Catalog/Fields/MsspLint.cs index 96ee36c6..3434cb8d 100644 --- a/src/MUI.Catalog/Fields/MsspLint.cs +++ b/src/MUI.Catalog/Fields/MsspLint.cs @@ -216,7 +216,7 @@ importance is MsspImportance.Required // A bare hostname like "www.slothmud.org" is not a linkable URL and we will not guess a // scheme for it — a link we invented would be our error published under their name. It // renders as text and the operator is told what edit fixes it. - if (FieldRegistry.Instance.Find(field) is { Shape: not FieldShape.Text } shaped + if (FieldRegistry.Instance.Find(field) is { Shape: FieldShape.Url or FieldShape.Email } shaped && !ExternalUrl.IsLinkable(row.Value, shaped.Shape)) { findings.Add(new MsspFinding( diff --git a/src/MUI.Catalog/Fields/SelfReport.cs b/src/MUI.Catalog/Fields/SelfReport.cs new file mode 100644 index 00000000..288200d6 --- /dev/null +++ b/src/MUI.Catalog/Fields/SelfReport.cs @@ -0,0 +1,214 @@ +using System.Globalization; + +namespace MUI.Catalog; + +/// +/// Unofficial MSSP variables this site reads for more than their text: names, and the rules for +/// reading each. +/// +/// +/// +/// SharpMUSH reports all six; any codebase may. Every one is the game's own statement about itself, +/// stored under like any other variable and rendered as declared — +/// none of them is something we observed, and none of them reaches presence, reachability or a +/// ranking. +/// +/// +/// One spelling here, read by the crawler (which variables keep every value), the reconciler (which +/// variables move without an event) and every surface, so the three cannot disagree about a name. +/// +/// +public static class SelfReport +{ + /// Distinct players connected in the trailing 24 hours, by the game's own count. + public const string PlayersDay = "PLAYERS DAY"; + + /// Distinct players connected in the trailing seven days, by the game's own count. + public const string PlayersWeek = "PLAYERS WEEK"; + + /// 1 when players may log in. + public const string Logins = "LOGINS"; + + /// 1 when a new character may be created at the login screen. + public const string PlayerCreation = "PLAYER CREATION"; + + /// 1 when guests may connect. + public const string Guests = "GUESTS"; + + /// + /// A page of the game's web portal anyone signed in may open: an absolute URL, one space, a + /// display name. Repeated, one value per page. + /// + public const string PortalApplication = "PORTAL APPLICATION"; + + /// + /// The variables MSSP sends repeated where every value is a separate fact, and which are + /// therefore stored as all of them rather than the last. + /// + /// + /// Everything else keeps MSSP's own rule, last value wins. A list is joined with + /// , which cannot occur inside one of these values as defined — a + /// URL holds no line break and the name is the rest of one line. + /// + public static IReadOnlySet Lists { get; } = + new HashSet(StringComparer.Ordinal) { PortalApplication }; + + /// How the values of one of are joined into one stored value. + public const char ListSeparator = '\n'; + + /// + /// A flag's value as yes or no, or null for anything else — which then prints as the game wrote + /// it rather than as our reading of it. + /// + /// + /// Only 1 and 0. MSSP's capability rule (anything not an explicit no is a yes) + /// is not borrowed: a LOGINS of wizards only is a sentence, and calling it "yes" + /// would be our paraphrase published as their answer. + /// + public static bool? Flag(string? value) => value?.Trim() switch + { + "1" => true, + "0" => false, + _ => null, + }; + + /// + /// A count as a number, or null when it is not a whole number at or above zero. + /// + /// + /// Invariant and unsigned for the same reason reads counts that way: a + /// current-culture parse would accept a grouped 1,024 on one deployment and refuse it on + /// another, and no count of players means anything below zero. A value that fails is not + /// dropped — the row keeps exactly what the game sent — it is only never read as a number. + /// + public static int? Count(string? value) => + int.TryParse(value?.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out var count) + ? count + : null; +} + +/// +/// The game's own count of distinct players over the last day and week, as the game page reads it. +/// +/// +/// Built only from rows whose value is a count (); a side that is not +/// a count is null here and stays on the page verbatim under "Declared by the game". Never a +/// presence sample and never a ranking input — it is a figure the game computed about itself, and +/// the presence series holds only counts read at a moment. +/// +public sealed record RecentPlayers(int? Day, int? Week, ProvenanceChip Provenance) +{ + /// + /// What the game said, or null where it said neither as a count. + /// + /// A game page's declared fields, keyed lower-invariant. + /// + /// One chip for the sentence: the two arrive in one report, so they share a source; the older of + /// the two ages is the one shown, since a sentence dated by its fresher half would overstate the + /// staler one. + /// + public static RecentPlayers? From(IReadOnlyDictionary declared) + { + ArgumentNullException.ThrowIfNull(declared); + + var day = Read(declared, SelfReport.PlayersDay); + var week = Read(declared, SelfReport.PlayersWeek); + + if (day is null && week is null) + { + return null; + } + + var chip = (day, week) switch + { + ({ } d, { } w) => d.Chip.LastConfirmedAt <= w.Chip.LastConfirmedAt ? d.Chip : w.Chip, + ({ } d, null) => d.Chip, + (null, { } w) => w.Chip, + _ => throw new InvalidOperationException("unreachable"), + }; + + return new RecentPlayers(day?.Count, week?.Count, chip); + } + + /// + /// Whether a declared field is said by this sentence and so need not be listed again. + /// + public static bool Says(string field, ProvenanceChip chip) => + (string.Equals(field, SelfReport.PlayersDay, StringComparison.OrdinalIgnoreCase) + || string.Equals(field, SelfReport.PlayersWeek, StringComparison.OrdinalIgnoreCase)) + && SelfReport.Count(chip.Value) is not null; + + private static (int Count, ProvenanceChip Chip)? Read( + IReadOnlyDictionary declared, + string field) => + declared.TryGetValue(field.ToLowerInvariant(), out var chip) + && SelfReport.Count(chip.Value) is { } count + ? (count, chip) + : null; +} + +/// One page of a game's web portal, as the game named it. +/// +/// Normalised through , never the stored text, so a surface putting it in an +/// href need not judge a stranger's scheme. Null when the line is not a URL and a name. +/// +/// The name the game gave the page, or null where is. +/// The line exactly as the game sent it, for the reader who wants it. +public sealed record PortalApplication(string? Href, string? Name, string Shown); + +/// Reads . +/// +/// +/// It refuses rather than repairs, like . A line becomes a link only +/// when its first word is an absolute http or https address and something follows it: a URL with no +/// name would make us choose the link text, and a name with no URL is not a destination. Anything +/// else comes back with no and renders as the text the game +/// sent, so nothing it published is hidden and nothing it did not publish is linked. +/// +/// +/// The first space separates and every later one belongs to the name — Character Application +/// is one name, not a URL followed by two words. +/// +/// +public static class PortalApplications +{ + public static IReadOnlyList Parse(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return []; + } + + var pages = new List(); + + foreach (var raw in value.Split(SelfReport.ListSeparator)) + { + var line = raw.Trim(); + + if (line.Length == 0) + { + continue; + } + + pages.Add(Read(line)); + } + + return pages; + } + + private static PortalApplication Read(string line) + { + var space = line.IndexOf(' ', StringComparison.Ordinal); + + if (space <= 0) + { + return new PortalApplication(null, null, line); + } + + var name = line[(space + 1)..].Trim(); + + return name.Length > 0 && ExternalUrl.Normalise(line[..space], FieldShape.Url) is { } href + ? new PortalApplication(href, name, line) + : new PortalApplication(null, null, line); + } +} diff --git a/src/MUI.Catalog/Persistence/Fields/FieldReconciler.cs b/src/MUI.Catalog/Persistence/Fields/FieldReconciler.cs index 97b09c2e..f590a2c2 100644 --- a/src/MUI.Catalog/Persistence/Fields/FieldReconciler.cs +++ b/src/MUI.Catalog/Persistence/Fields/FieldReconciler.cs @@ -5,7 +5,8 @@ namespace MUI.Catalog.Persistence; /// /// Turns observations into stored fields (spec §5.1). Every observation does exactly one of two /// things: confirm — bump last_confirmed_at and write nothing else — or change — -/// rewrite the row and append one . +/// rewrite the row and append one . The one exception is +/// , rewritten without the append. /// /// /// The distinction is the whole economics of §5.1: a game whose GENRE never moves costs one @@ -26,6 +27,26 @@ public sealed partial class FieldReconciler(IGameFieldStore store) : IFieldRecon public static IReadOnlySet VolatileFields { get; } = new HashSet(StringComparer.Ordinal) { "PLAYERS", "UPTIME" }; + /// + /// Fields that move on most probes but are still worth keeping, with their age. + /// + /// + /// + /// The middle case between and everything else. PLAYERS DAY + /// and PLAYERS WEEK are a game's own count over a trailing window: unlike PLAYERS + /// there is no presence series to hold them, so dropping them would lose the only place they are + /// said; but like PLAYERS they move whenever somebody new connects, and a + /// per probe would bury every real event in the feed. + /// + /// + /// So a new value overwrites the row and appends nothing. last_confirmed_at still dates + /// it, which is all a reader of a trailing count needs; a history of one would be presence + /// arithmetic on a number we did not measure, and nothing here wants it. + /// + /// + public static IReadOnlySet MovingFields { get; } = + new HashSet(StringComparer.Ordinal) { SelfReport.PlayersDay, SelfReport.PlayersWeek }; + public async Task ApplyAsync( Guid gameId, IReadOnlyList observed, @@ -47,6 +68,7 @@ public async Task ApplyAsync( var confirmed = 0; var changed = 0; var added = 0; + var overwritten = 0; foreach (var observation in observed) { @@ -78,6 +100,16 @@ await store.UpsertAsync( continue; } + if (MovingFields.Contains(observation.Field)) + { + // Overwrite: the value moved, and it is the kind that always does. No change row. + await store.UpsertAsync( + existing with { Value = observation.Value, LastConfirmedAt = at }, + cancellationToken); + overwritten++; + continue; + } + // Change: first_seen_at still means "when this (game, field, source) was first seen", // not "when this value was" — the change feed carries the value's history separately. await store.UpsertAsync( @@ -95,7 +127,7 @@ await store.RecordChangeAsync( changed++; } - return new FieldReconciliation(confirmed, changed, added); + return new FieldReconciliation(confirmed, changed, added, overwritten); } /// diff --git a/src/MUI.Catalog/Persistence/Fields/FieldRegistry.cs b/src/MUI.Catalog/Persistence/Fields/FieldRegistry.cs index e42ef4a3..a941eb33 100644 --- a/src/MUI.Catalog/Persistence/Fields/FieldRegistry.cs +++ b/src/MUI.Catalog/Persistence/Fields/FieldRegistry.cs @@ -267,6 +267,12 @@ public sealed class FieldRegistry : IFieldRegistry /// Re-measured on every probe; a day unconfirmed means our crawler, not the game. public static readonly TimeSpan Measured = TimeSpan.FromDays(1); + /// + /// A count the game computes over a trailing window and reports on every probe. A day unconfirmed + /// means the figure describes a different day from the one it names. + /// + public static readonly TimeSpan Rolling = TimeSpan.FromDays(1); + /// Auto-filled by the codebase, and expected to change only on a move or an upgrade. public static readonly TimeSpan Automatic = TimeSpan.FromDays(30); @@ -434,6 +440,30 @@ void Add( Add("BLUESKY", Contactable, OwnerWritable.Enrichment, FieldShape.Url); Add("X", Contactable, OwnerWritable.Enrichment, FieldShape.Url); + // What SharpMUSH reports beyond the generic set, and any codebase may (see SelfReport). + // + // The two counts are FieldReconciler.MovingFields: stored and dated like any field, but + // overwritten without a change row, because a trailing count moves on most probes and a + // FieldChange per probe is exactly the noise §5.1 exists to keep out of the feed. Rolling + // rather than Volatile: the figure already covers a day, so hours unconfirmed do not make it + // wrong — a day does. Neither is presence: the series holds counts read at a moment, and a + // game's own sum over a week is not one. + Add(SelfReport.PlayersDay, Rolling); + Add(SelfReport.PlayersWeek, Rolling); + + // Filled in by the codebase from its own configuration, so Automatic; a flag, so the page + // can say yes or no instead of printing 1. Not writable: whether logins are open is the + // game's to say, and it says it on every probe. + Add(SelfReport.Logins, Automatic, OwnerWritable.No, FieldShape.Flag); + Add(SelfReport.PlayerCreation, Automatic, OwnerWritable.No, FieldShape.Flag); + Add(SelfReport.Guests, Automatic, OwnerWritable.No, FieldShape.Flag); + + // Every value kept (SelfReport.Lists), one per line, each a URL and a name. Text rather than + // Url: the value is a list of links, not one, and ExternalUrl would refuse the whole line — + // PortalApplications reads it instead. Automatic, since the codebase generates it from the + // pages it has. + Add(SelfReport.PortalApplication, Automatic); + return fields; } } diff --git a/src/MUI.Catalog/Persistence/Fields/OwnerEnrichment.cs b/src/MUI.Catalog/Persistence/Fields/OwnerEnrichment.cs index 3d77b58a..4aa7a4f0 100644 --- a/src/MUI.Catalog/Persistence/Fields/OwnerEnrichment.cs +++ b/src/MUI.Catalog/Persistence/Fields/OwnerEnrichment.cs @@ -194,7 +194,7 @@ public async Task ApplyAsync( // A field that holds an address gets one or nothing; a clear is exempt since "" is // absence, not a malformed URL. Gated on the write, not only the render, so a stored // value is never silently un-linked with nothing saying why. - if (definition.Shape is not FieldShape.Text + if (definition.Shape is FieldShape.Url or FieldShape.Email && value.Length > 0 && !ExternalUrl.IsLinkable(value, definition.Shape)) { diff --git a/src/MUI.Crawler/Crawl/FieldObservations.cs b/src/MUI.Crawler/Crawl/FieldObservations.cs index 5dd8938f..6a4273a5 100644 --- a/src/MUI.Crawler/Crawl/FieldObservations.cs +++ b/src/MUI.Crawler/Crawl/FieldObservations.cs @@ -285,7 +285,8 @@ private static IEnumerable Measured(ProbeResult result) /// here: drops them, so that rule has one home. A /// variable with several values is reduced by MSSP's own rule (last value wins) and never by /// joining them, since a value may legitimately contain a comma and string.Join would - /// fabricate one that can't be split back apart. A capability variable produces a + /// fabricate one that can't be split back apart — except the few + /// whose values are defined to be one line each, joined on a line break that can split back. A capability variable produces a /// .declared row, plus a descriptive row only when its value carries information beyond /// yes/no: SSL 4202 is a claim and a port, GMCP 1 is only a claim. /// @@ -305,7 +306,16 @@ private static IEnumerable Declared(ProbeResult result) } var name = variable.Trim(); - var value = values[^1].Trim(); + + // A list variable is every value, not the last (SelfReport.Lists): each PORTAL + // APPLICATION is a different page, and keeping only one would publish a portal of one + // page for a game that named five. The separator cannot occur in such a value, so the + // join splits back exactly; blanks are skipped rather than joined into empty lines. + var value = SelfReport.Lists.Contains(name) + ? string.Join( + SelfReport.ListSeparator, + values.Select(v => v.Trim()).Where(v => v.Length > 0)) + : values[^1].Trim(); if (CapabilityFields.Canonical(name) is { } capability) { diff --git a/src/MUI.Web/Components/Pages/Game.razor b/src/MUI.Web/Components/Pages/Game.razor index 2683e327..b6fe650b 100644 --- a/src/MUI.Web/Components/Pages/Game.razor +++ b/src/MUI.Web/Components/Pages/Game.razor @@ -163,6 +163,19 @@ else

@L("game.count.none")

@L("game.count.none.why")

} + + @* The game's own count of different players over a day and a week (MSSP PLAYERS DAY, + PLAYERS WEEK). Under the figure and apart from it: the figure is connections we + counted at a moment, this is people the game says it saw over a window, and neither + is folded into the other — not into the figure, the series or any ranking. The chip + carries the declared glyph and the age; the sentence says whose count it is. *@ + @if (Recent is { } recent) + { +

+ @SelfReportWords.Recent(Tag, recent) + +

+ } @@ -341,8 +354,43 @@ else open set we could not translate without inventing a word for `FANDOM`. *@ @foreach (var (name, chip) in Page.Declared) { + @* Said once, in the sentence under the figure — but only a value that reads + as a count is; anything else stays here exactly as the game sent it. *@ + if (RecentPlayers.Says(name, chip)) + { + continue; + } +
@name
-
+ @if (SelfReportWords.IsPortal(name)) + { + @* The game's portal pages, one per line it sent. A line is a link only + when it is an http(s) address and a name (PortalApplications); any + other line prints as text — refused as a link, never hidden. rel as on + ReachRow: the game chose these destinations, we did not. *@ +
+
    + @foreach (var app in PortalApplications.Parse(chip.Value)) + { +
  • + @if (app.Href is { } href) + { + @app.Name + } + else + { + @app.Shown + } +
  • + } +
+ +
+ } + else + { +
+ } } @if (StaleCount > 0) @@ -484,6 +532,9 @@ else private int StaleCount => Page?.Declared.Count(d => d.Value.IsStale) ?? 0; + /// The game's own trailing counts, or null where it reported neither as a number. + private RecentPlayers? Recent => Page is null ? null : RecentPlayers.From(Page.Declared); + /// The label on the live count, or null where there is no count to label. private ProvenanceChip? Counted => Page?.Summary.PlayersNowProvenance; diff --git a/src/MUI.Web/Components/Pages/PlainText.cs b/src/MUI.Web/Components/Pages/PlainText.cs index 1a61d942..f8e19da3 100644 --- a/src/MUI.Web/Components/Pages/PlainText.cs +++ b/src/MUI.Web/Components/Pages/PlainText.cs @@ -102,6 +102,13 @@ public static string Render( ? $"{Say(tag, "game.plain.playersNow", ("count", n))} {Label(tag, s.PlayersNowProvenance, now)}" : Say(tag, "game.plain.playersNoCount")).TrimEnd()); + // The game's own count over a day and a week, on a line of its own after ours — see + // SelfReportWords for why the two are never one figure. + if (RecentPlayers.From(page.Declared) is { } recent) + { + Wrap(b, $"{SelfReportWords.Recent(tag, recent)} {Label(tag, recent.Provenance, now)}"); + } + if (page.ReachableFraction is { } r) { b.AppendLine(Say( @@ -295,7 +302,27 @@ private static void AppendDeclared(StringBuilder b, GamePage page, DateTimeOffse foreach (var (name, chip) in page.Declared) { - b.AppendLine($" {name,-10} {chip.Value} {Label(tag, chip, now)}"); + if (RecentPlayers.Says(name, chip)) + { + continue; + } + + // One portal page per line, each exactly as the game sent it: the list is joined on + // line breaks, and printing it as one value would break this surface's one-row-per-field + // shape. + if (SelfReportWords.IsPortal(name)) + { + b.AppendLine($" {name,-10} {Label(tag, chip, now)}"); + + foreach (var app in PortalApplications.Parse(chip.Value)) + { + b.AppendLine($" {app.Shown}"); + } + + continue; + } + + b.AppendLine($" {name,-10} {SelfReportWords.Value(tag, name, chip.Value)} {Label(tag, chip, now)}"); } } diff --git a/src/MUI.Web/Components/Text/SelfReportWords.cs b/src/MUI.Web/Components/Text/SelfReportWords.cs new file mode 100644 index 00000000..7f76a3c4 --- /dev/null +++ b/src/MUI.Web/Components/Text/SelfReportWords.cs @@ -0,0 +1,65 @@ +using MUI.Catalog; +using MUI.Catalog.Persistence; + +using MUI.Web.Localization; + +namespace MUI.Web.Components; + +/// +/// How the game page says what a game reported about itself beyond the generic MSSP set (see +/// ). +/// +/// +/// +/// The game's count, never ours. The sentence opens with whose count it is, because it sits +/// beside a figure we measured and the two answer different questions: how many connections a probe +/// found at a moment, and how many different people the game says it saw over a day or a week. +/// Neither feeds the other, and the sentence must not read as if it did. +/// +/// +/// Shared by the rendered page and the plain surface so the two cannot word it differently. +/// +/// +public static class SelfReportWords +{ + /// The sentence for a game's own trailing counts. + public static string Recent(string tag, RecentPlayers recent) + { + ArgumentNullException.ThrowIfNull(recent); + + var args = new Dictionary(StringComparer.Ordinal) + { + ["day"] = recent.Day, + ["week"] = recent.Week, + }; + + return Messages.For( + tag, + (recent.Day, recent.Week) switch + { + (not null, not null) => "game.recentPlayers.both", + (not null, null) => "game.recentPlayers.day", + _ => "game.recentPlayers.week", + }, + args); + } + + /// + /// A declared value as a reader should see it: a flag's 1 or 0 as yes or no, and + /// everything else exactly as the game sent it. + /// + /// + /// Only a value reads changes. A LOGINS of + /// wizards only stays those words — calling it yes would be our paraphrase of their + /// answer. + /// + public static string Value(string tag, string field, string value) => + FieldRegistry.Instance.Find(field.ToUpperInvariant()) is { Shape: FieldShape.Flag } + && SelfReport.Flag(value) is { } flag + ? Messages.For(tag, flag ? "game.flag.yes" : "game.flag.no") + : value; + + /// Whether a declared field is the portal's list of pages. + public static bool IsPortal(string field) => + string.Equals(field, SelfReport.PortalApplication, StringComparison.OrdinalIgnoreCase); +} diff --git a/src/MUI.Web/Localization/Messages.GamePage.cs b/src/MUI.Web/Localization/Messages.GamePage.cs index 27a5db77..e3fd1faa 100644 --- a/src/MUI.Web/Localization/Messages.GamePage.cs +++ b/src/MUI.Web/Localization/Messages.GamePage.cs @@ -176,6 +176,17 @@ public static partial class Messages ["game.notFound"] = "Not found", ["game.notFound.hint"] = "No game at this address. Check the spelling.", ["game.endpoint.lastAnswered"] = "last answered", + + // The game's own trailing counts (MSSP PLAYERS DAY / PLAYERS WEEK), beside our measured + // figure and never part of it: distinct people the game says it saw, not connections we + // counted. Each sentence opens with whose count it is. + ["game.recentPlayers.both"] = "By the game's own count: {day, plural, one {# different player} other {# different players}} in the last day, {week, plural, one {# in the last week} other {# in the last week}}.", + ["game.recentPlayers.day"] = "By the game's own count: {day, plural, one {# different player} other {# different players}} in the last day.", + ["game.recentPlayers.week"] = "By the game's own count: {week, plural, one {# different player} other {# different players}} in the last week.", + + // A declared 1 or 0 for LOGINS, PLAYER CREATION and GUESTS. Anything else prints verbatim. + ["game.flag.yes"] = "yes", + ["game.flag.no"] = "no", ["game.stale"] = "{count, plural, one {# is past its refresh window. Old, not wrong.} other {# are past their refresh window. Old, not wrong.}}", ["game.referrals.lists"] = "This game's own referral list names:", ["game.referrals.listedBy"] = "Named by the referral list of:", diff --git a/src/MUI.Web/Resources/Messages.de.resx b/src/MUI.Web/Resources/Messages.de.resx index aea8b79a..d59eba40 100644 --- a/src/MUI.Web/Resources/Messages.de.resx +++ b/src/MUI.Web/Resources/Messages.de.resx @@ -2131,6 +2131,21 @@ zuletzt geantwortet + + Nach eigener Zählung des Spiels: {day, plural, one {# verschiedener Spieler} other {# verschiedene Spieler}} am letzten Tag, {week, plural, one {# in der letzten Woche} other {# in der letzten Woche}}. + + + Nach eigener Zählung des Spiels: {day, plural, one {# verschiedener Spieler} other {# verschiedene Spieler}} am letzten Tag. + + + Nach eigener Zählung des Spiels: {week, plural, one {# verschiedener Spieler} other {# verschiedene Spieler}} in der letzten Woche. + + + ja + + + nein + {count, plural, one {# ist über das Aktualisierungsfenster hinaus. Alt, nicht falsch.} other {# sind über das Aktualisierungsfenster hinaus. Alt, nicht falsch.}} diff --git a/src/MUI.Web/Resources/Messages.ja.resx b/src/MUI.Web/Resources/Messages.ja.resx index c561701d..fb48ac64 100644 --- a/src/MUI.Web/Resources/Messages.ja.resx +++ b/src/MUI.Web/Resources/Messages.ja.resx @@ -2131,6 +2131,21 @@ 最終応答 + + ゲーム自身の集計:過去1日に{day, plural, other {#人}}、過去1週間に{week, plural, other {#人}}の異なるプレイヤー。 + + + ゲーム自身の集計:過去1日に{day, plural, other {#人}}の異なるプレイヤー。 + + + ゲーム自身の集計:過去1週間に{week, plural, other {#人}}の異なるプレイヤー。 + + + はい + + + いいえ + {count, plural, other {#件が更新期間を過ぎている。古いだけで、誤りではない。}} diff --git a/src/MUI.Web/Resources/Messages.nl.resx b/src/MUI.Web/Resources/Messages.nl.resx index 8c88c00d..36b05d1a 100644 --- a/src/MUI.Web/Resources/Messages.nl.resx +++ b/src/MUI.Web/Resources/Messages.nl.resx @@ -2131,6 +2131,21 @@ laatst geantwoord + + Volgens de eigen telling van het spel: {day, plural, one {# verschillende speler} other {# verschillende spelers}} in de afgelopen dag, {week, plural, one {# in de afgelopen week} other {# in de afgelopen week}}. + + + Volgens de eigen telling van het spel: {day, plural, one {# verschillende speler} other {# verschillende spelers}} in de afgelopen dag. + + + Volgens de eigen telling van het spel: {week, plural, one {# verschillende speler} other {# verschillende spelers}} in de afgelopen week. + + + ja + + + nee + {count, plural, one {# is voorbij zijn verversingstermijn. Oud, niet fout.} other {# zijn voorbij hun verversingstermijn. Oud, niet fout.}} diff --git a/src/MUI.Web/Resources/Messages.resx b/src/MUI.Web/Resources/Messages.resx index d1513cec..d10f2ace 100644 --- a/src/MUI.Web/Resources/Messages.resx +++ b/src/MUI.Web/Resources/Messages.resx @@ -2858,6 +2858,26 @@ last answered the rest of the game page + + By the game's own count: {day, plural, one {# different player} other {# different players}} in the last day, {week, plural, one {# in the last week} other {# in the last week}}. + the game's own MSSP PLAYERS DAY and PLAYERS WEEK, shown beside but apart from our measured count; distinct people over a trailing window, never connections we counted + + + By the game's own count: {day, plural, one {# different player} other {# different players}} in the last day. + as game.recentPlayers.both, when the game reported only the day + + + By the game's own count: {week, plural, one {# different player} other {# different players}} in the last week. + as game.recentPlayers.both, when the game reported only the week + + + yes + a game's own 1 for LOGINS, PLAYER CREATION or GUESTS, under "Declared by the game" + + + no + a game's own 0 for LOGINS, PLAYER CREATION or GUESTS, under "Declared by the game" + {count, plural, one {# is past its refresh window. Old, not wrong.} other {# are past their refresh window. Old, not wrong.}} the rest of the game page diff --git a/src/MUI.Web/Resources/Messages.zh-Hans.resx b/src/MUI.Web/Resources/Messages.zh-Hans.resx index 992ecafa..7734669a 100644 --- a/src/MUI.Web/Resources/Messages.zh-Hans.resx +++ b/src/MUI.Web/Resources/Messages.zh-Hans.resx @@ -2131,6 +2131,21 @@ 最近应答 + + 据游戏自己统计:过去一天有{day, plural, other {#名}}不同玩家,过去一周有{week, plural, other {#名}}。 + + + 据游戏自己统计:过去一天有{day, plural, other {#名}}不同玩家。 + + + 据游戏自己统计:过去一周有{week, plural, other {#名}}不同玩家。 + + + 是 + + + 否 + {count, plural, other {有#项已超过刷新窗口。是旧,不是错。}} diff --git a/src/MUI.Web/wwwroot/app.css b/src/MUI.Web/wwwroot/app.css index 1096bacd..d9bc6f00 100644 --- a/src/MUI.Web/wwwroot/app.css +++ b/src/MUI.Web/wwwroot/app.css @@ -1350,6 +1350,8 @@ form.locale select:focus { outline: none; } /* A count the game states about itself rather than one we read: amber, which is what declared means everywhere else here, and no glow — the glow is the accent's, and the accent means measured. */ .game-figure .figure-count.declared { color: var(--amber); text-shadow: none; } +/* The game's own day/week count: under our figure, smaller, never styled as part of it. */ +.game-figure .recent-players { margin: 0; max-width: 32ch; } .game-figure .figure-count.unknown { color: var(--faint); text-shadow: none; font-size: var(--fs-md); font-variant-numeric: normal; } @@ -1449,6 +1451,9 @@ ol.changes li { padding: 4px 0; color: var(--dim); } dl.declared { display: grid; grid-template-columns: auto 1fr; gap: 6px 14px; margin: 0; } dl.declared dt { align-self: baseline; } dl.declared dd { margin: 0; } +/* A game's portal pages: its own list, one page a line, the chip dating the whole list after it. */ +dl.declared ul.portal-apps { list-style: none; margin: 0; padding: 0; } +dl.declared ul.portal-apps li { overflow-wrap: anywhere; } /* ── capability matrix ─────────────────────────────────────────────────── */ diff --git a/tests/MUI.Catalog.Tests/Fields/SelfReportTests.cs b/tests/MUI.Catalog.Tests/Fields/SelfReportTests.cs new file mode 100644 index 00000000..93071d7d --- /dev/null +++ b/tests/MUI.Catalog.Tests/Fields/SelfReportTests.cs @@ -0,0 +1,159 @@ +using MUI.Catalog.Persistence; + +namespace MUI.Catalog.Tests; + +/// +/// The variables SharpMUSH reports beyond the generic set: what each is read as, and what is +/// refused rather than repaired. +/// +public class SelfReportTests +{ + private static readonly DateTimeOffset Now = new(2026, 10, 15, 12, 0, 0, TimeSpan.Zero); + + private static ProvenanceChip Chip(string value, double ageHours = 1) => + new(value, FieldSource.Mssp, Now.AddHours(-ageHours), IsStale: false); + + // ── PORTAL APPLICATION ─────────────────────────────────────────────────────────────────── + + [Test] + public async Task APortalApplicationIsAUrlAndTheRestOfTheLineIsItsName() + { + var pages = PortalApplications.Parse( + "https://mush.example.com/apps/chargen Character Application"); + + var page = pages.Single(); + + await Assert.That(page.Href).IsEqualTo("https://mush.example.com/apps/chargen"); + await Assert.That(page.Name).IsEqualTo("Character Application"); + } + + [Test] + public async Task EveryLineIsItsOwnPage() + { + var pages = PortalApplications.Parse( + "https://mush.example.com/apps/chargen Character Application\nhttp://mush.example.com/wiki Wiki"); + + await Assert.That(pages.Select(p => p.Name ?? string.Empty)) + .IsEquivalentTo(new[] { "Character Application", "Wiki" }); + } + + [Test] + public async Task AUrlWithNoNameIsNotALink() + { + // We would have to choose the link text, and the game did not give us any. + var page = PortalApplications.Parse("https://mush.example.com/apps/chargen").Single(); + + await Assert.That(page.Href).IsNull(); + await Assert.That(page.Shown).IsEqualTo("https://mush.example.com/apps/chargen"); + } + + [Test] + [Arguments("javascript:alert(1) Character Application")] + [Arguments("ftp://mush.example.com/apps Files")] + [Arguments("mush.example.com/apps/chargen Character Application")] + [Arguments("Character Application")] + [Arguments("https://user:pass@mush.example.com/ Login")] + public async Task AnythingButAnHttpAddressAndANameStaysText(string line) + { + // Refused as a link, never hidden: the line is what the game published. + var page = PortalApplications.Parse(line).Single(); + + await Assert.That(page.Href).IsNull(); + await Assert.That(page.Name).IsNull(); + await Assert.That(page.Shown).IsEqualTo(line); + } + + [Test] + public async Task NothingIsNoPages() + { + await Assert.That(PortalApplications.Parse(null)).IsEmpty(); + await Assert.That(PortalApplications.Parse(" \n ")).IsEmpty(); + } + + // ── PLAYERS DAY / PLAYERS WEEK ─────────────────────────────────────────────────────────── + + [Test] + [Arguments("12", 12)] + [Arguments(" 0 ", 0)] + [Arguments("-3", null)] + [Arguments("1,024", null)] + [Arguments("lots", null)] + [Arguments("", null)] + public async Task OnlyAWholeNumberAtOrAboveZeroIsACount(string value, int? expected) + { + await Assert.That(SelfReport.Count(value)).IsEqualTo(expected); + } + + [Test] + public async Task TheSentenceIsDatedByItsOlderHalf() + { + var recent = RecentPlayers.From(new Dictionary + { + ["players day"] = Chip("12", ageHours: 1), + ["players week"] = Chip("40", ageHours: 5), + }); + + await Assert.That(recent).IsNotNull(); + await Assert.That(recent!.Day).IsEqualTo(12); + await Assert.That(recent.Week).IsEqualTo(40); + await Assert.That(recent.Provenance.LastConfirmedAt).IsEqualTo(Now.AddHours(-5)); + } + + [Test] + public async Task ACountThatIsNotANumberIsLeftToTheDeclaredList() + { + // The sentence says only what parses; the rest prints as the game wrote it. + var declared = new Dictionary + { + ["players day"] = Chip("many"), + ["players week"] = Chip("40"), + }; + + var recent = RecentPlayers.From(declared); + + await Assert.That(recent!.Day).IsNull(); + await Assert.That(recent.Week).IsEqualTo(40); + await Assert.That(RecentPlayers.Says("players day", declared["players day"])).IsFalse(); + await Assert.That(RecentPlayers.Says("players week", declared["players week"])).IsTrue(); + } + + [Test] + public async Task NoCountIsNoSentence() + { + await Assert.That(RecentPlayers.From(new Dictionary())).IsNull(); + } + + // ── the flags and the registry ─────────────────────────────────────────────────────────── + + [Test] + [Arguments("1", true)] + [Arguments("0", false)] + [Arguments("wizards only", null)] + [Arguments("yes", null)] + public async Task AFlagIsOneOrZeroAndAnythingElseIsTheGamesOwnWords(string value, bool? expected) + { + await Assert.That(SelfReport.Flag(value)).IsEqualTo(expected); + } + + [Test] + public async Task TheSixAreRegisteredAndNoneIsOwnerWritable() + { + string[] six = + [ + SelfReport.PlayersDay, SelfReport.PlayersWeek, SelfReport.Logins, + SelfReport.PlayerCreation, SelfReport.Guests, SelfReport.PortalApplication, + ]; + + foreach (var name in six) + { + var definition = FieldRegistry.Instance.Find(name); + + await Assert.That(definition).IsNotNull(); + await Assert.That(definition!.OwnerWritable).IsEqualTo(OwnerWritable.No); + } + + await Assert.That(FieldRegistry.Instance.Find(SelfReport.Guests)!.Shape).IsEqualTo(FieldShape.Flag); + await Assert.That(FieldRegistry.Instance.Find(SelfReport.PlayersDay)!.ExpectedRefresh) + .IsEqualTo(FieldRegistry.Rolling); + } +} diff --git a/tests/MUI.Catalog.Tests/Persistence/Fields/FieldReconcilerTests.cs b/tests/MUI.Catalog.Tests/Persistence/Fields/FieldReconcilerTests.cs index a3b4d499..05b6e775 100644 --- a/tests/MUI.Catalog.Tests/Persistence/Fields/FieldReconcilerTests.cs +++ b/tests/MUI.Catalog.Tests/Persistence/Fields/FieldReconcilerTests.cs @@ -211,4 +211,62 @@ public async Task AProbeThatObservedNothingWritesNothing() await Assert.That(result).IsEqualTo(FieldReconciliation.Nothing); await Assert.That(await store.ForGameAsync(Game)).IsEmpty(); } + + [Test] + public async Task ATrailingCountMovesWithoutReachingTheChangeFeed() + { + // PLAYERS DAY moves whenever somebody new connects. Kept, and dated, but a change row per + // probe would bury every real event in the feed. + var store = new InMemoryGameFieldStore(); + var reconciler = new FieldReconciler(store); + + await reconciler.ApplyAsync( + Game, [new FieldObservation(SelfReport.PlayersDay, FieldSource.Mssp, "12")], Noon); + var moved = await reconciler.ApplyAsync( + Game, [new FieldObservation(SelfReport.PlayersDay, FieldSource.Mssp, "14")], Noon.AddHours(1)); + var same = await reconciler.ApplyAsync( + Game, [new FieldObservation(SelfReport.PlayersDay, FieldSource.Mssp, "14")], Noon.AddHours(2)); + + var stored = (await store.ForGameAsync(Game)).Single(); + + await Assert.That(moved).IsEqualTo( + new FieldReconciliation(Confirmed: 0, Changed: 0, Added: 0, Overwritten: 1)); + await Assert.That(same).IsEqualTo(new FieldReconciliation(Confirmed: 1, Changed: 0, Added: 0)); + await Assert.That(store.Changes).IsEmpty(); + await Assert.That(stored.Value).IsEqualTo("14"); + await Assert.That(stored.LastConfirmedAt).IsEqualTo(Noon.AddHours(2)); + await Assert.That(stored.FirstSeenAt).IsEqualTo(Noon); + } + + [Test] + public async Task ATrailingCountThatIsNotANumberIsKeptAsSent() + { + // Rule 4 reads forward: the row holds what the game said, and nothing reads it as a count. + var store = new InMemoryGameFieldStore(); + var reconciler = new FieldReconciler(store); + + await reconciler.ApplyAsync( + Game, [new FieldObservation(SelfReport.PlayersWeek, FieldSource.Mssp, "-3")], Noon); + + var stored = (await store.ForGameAsync(Game)).Single(); + + await Assert.That(stored.Value).IsEqualTo("-3"); + await Assert.That(SelfReport.Count(stored.Value)).IsNull(); + } + + [Test] + public async Task AFlagStillReachesTheChangeFeedWhenItFlips() + { + // Only the trailing counts are exempt: logins closing is an event, not noise. + var store = new InMemoryGameFieldStore(); + var reconciler = new FieldReconciler(store); + + await reconciler.ApplyAsync( + Game, [new FieldObservation(SelfReport.Logins, FieldSource.Mssp, "1")], Noon); + var flipped = await reconciler.ApplyAsync( + Game, [new FieldObservation(SelfReport.Logins, FieldSource.Mssp, "0")], Noon.AddHours(1)); + + await Assert.That(flipped).IsEqualTo(new FieldReconciliation(Confirmed: 0, Changed: 1, Added: 0)); + await Assert.That(store.Changes.Single().NewValue).IsEqualTo("0"); + } } diff --git a/tests/MUI.Crawler.Tests/Crawl/FieldObservationTests.cs b/tests/MUI.Crawler.Tests/Crawl/FieldObservationTests.cs index 5f8ed845..f0b3ef8d 100644 --- a/tests/MUI.Crawler.Tests/Crawl/FieldObservationTests.cs +++ b/tests/MUI.Crawler.Tests/Crawl/FieldObservationTests.cs @@ -381,4 +381,21 @@ public async Task TheVolatileFieldsAreDroppedByTheReconcilerRatherThanHere() await Assert.That(observed.Select(o => o.Field)) .Contains(f => FieldReconciler.VolatileFields.Contains(f)); } + + [Test] + public async Task EveryPortalApplicationIsKeptAndOtherRepeatsKeepTheirLast() + { + // PORTAL APPLICATION is one value per page, so all of them are the answer; any other + // repeated variable keeps MSSP's own rule, last value wins. + var observed = FieldObservations.From(Probes.Answered(mssp: Probes.Mssp( + (SelfReport.PortalApplication, "https://mush.example.com/apps/chargen Character Application"), + (SelfReport.PortalApplication, " "), + (SelfReport.PortalApplication, "https://mush.example.com/apps/wiki Wiki"), + ("GENRE", "Fantasy"), + ("GENRE", "Horror")))); + + await Assert.That(Value(observed, SelfReport.PortalApplication, FieldSource.Mssp)).IsEqualTo( + "https://mush.example.com/apps/chargen Character Application\nhttps://mush.example.com/apps/wiki Wiki"); + await Assert.That(Value(observed, "GENRE", FieldSource.Mssp)).IsEqualTo("Horror"); + } } diff --git a/tests/MUI.Web.Tests/SelfReportSurfaceTests.cs b/tests/MUI.Web.Tests/SelfReportSurfaceTests.cs new file mode 100644 index 00000000..36b6d5ac --- /dev/null +++ b/tests/MUI.Web.Tests/SelfReportSurfaceTests.cs @@ -0,0 +1,166 @@ +using MUI.Catalog; +using MUI.Web.Components; +using MUI.Web.Fixtures; + +using Microsoft.Extensions.DependencyInjection; + +namespace MUI.Web.Tests; + +/// +/// The six variables SharpMUSH reports beyond the generic set, as the game page and its plain +/// surface say them: the game's own day and week count beside our figure and apart from it, its +/// flags as yes or no, and its portal pages as links only where a line is an address and a name. +/// +public class SelfReportSurfaceTests +{ + private static readonly DateTimeOffset Now = FixtureGameQueries.Now; + + private const string Slug = "m-u-s-h"; + + private static ProvenanceChip Mssp(string value, double ageHours = 1) => + new(value, FieldSource.Mssp, Now.AddHours(-ageHours), IsStale: false); + + private static readonly Dictionary Reported = new(StringComparer.Ordinal) + { + ["players day"] = Mssp("12"), + ["players week"] = Mssp("40", ageHours: 3), + ["logins"] = Mssp("1"), + ["guests"] = Mssp("0"), + ["player creation"] = Mssp("wizards only"), + ["portal application"] = Mssp( + "https://mush.example.com/apps/chargen Character Application\n" + + "javascript:alert(1) Evil\n" + + "https://mush.example.com/apps/bare"), + }; + + private static async Task PageAsync(Dictionary? extra = null) + { + var page = (await new FixtureGameQueries().FindAsync(Slug))!; + var declared = new Dictionary(page.Declared, StringComparer.Ordinal); + + foreach (var (name, chip) in extra ?? Reported) + { + declared[name] = chip; + } + + return page with { Declared = declared }; + } + + private static async Task RenderAsync(GamePage page) + { + // After the site's own graph, which registers the fixture as IGameQueries itself. + await using var site = await SiteHost.StartAsync(overrides: services => + services.AddSingleton(new WithPage(new FixtureGameQueries(), page))); + + return await site.Client.GetStringAsync($"/g/{Slug}"); + } + + [Test] + public async Task TheGamesOwnCountIsSaidAsItsOwnAndApartFromTheFigure() + { + var html = await RenderAsync(await PageAsync()); + + await Assert.That(html).Contains( + "By the game's own count: 12 different players in the last day, 40 in the last week."); + + // In its own paragraph with the declared chip, never in the measured figure. + var at = html.IndexOf("recent-players", StringComparison.Ordinal); + + await Assert.That(at).IsGreaterThan(0); + await Assert.That(html.IndexOf("chip declared", at, StringComparison.Ordinal)).IsGreaterThan(at); + + // Said once: a count the sentence carries is not listed again under "Declared by the game". + await Assert.That(html).DoesNotContain(">players day<"); + await Assert.That(html).DoesNotContain(">players week<"); + } + + [Test] + public async Task ACountThatIsNotANumberStaysInTheGamesOwnWords() + { + var html = await RenderAsync(await PageAsync(new() + { + ["players day"] = Mssp("lots"), + ["players week"] = Mssp("40"), + })); + + await Assert.That(html).Contains("By the game's own count: 40 different players in the last week."); + await Assert.That(html).Contains(">players day<"); + await Assert.That(html).Contains(">lots<"); + } + + [Test] + public async Task AFlagReadsYesOrNoAndAnythingElseAsSent() + { + var html = await RenderAsync(await PageAsync()); + + await Assert.That(html).Contains("yes"); + await Assert.That(html).Contains("no"); + await Assert.That(html).Contains("wizards only"); + } + + [Test] + public async Task APortalPageIsALinkOnlyWhereItIsAnAddressAndAName() + { + var html = await RenderAsync(await PageAsync()); + + await Assert.That(html).Contains( + "Character Application"); + + // Refused as a link, never hidden. + await Assert.That(html).DoesNotContain("href=\"javascript:"); + await Assert.That(html).Contains("javascript:alert(1) Evil"); + await Assert.That(html).DoesNotContain("href=\"https://mush.example.com/apps/bare\""); + await Assert.That(html).Contains("https://mush.example.com/apps/bare"); + } + + [Test] + public async Task ThePlainSurfaceSaysTheSameThings() + { + var text = PlainText.Render(await PageAsync(), Now); + + await Assert.That(text).Contains("By the game's own count: 12 different players in the last day,"); + await Assert.That(text).Contains(" https://mush.example.com/apps/chargen Character Application"); + await Assert.That(text).DoesNotContain("players day"); + + foreach (var line in text.Split('\n').Where(l => !l.Contains("://", StringComparison.Ordinal))) + { + await Assert.That(line.TrimEnd().Length).IsLessThanOrEqualTo(PlainText.Columns); + } + } + + /// The fixture, answering one slug with a page a test built. + private sealed class WithPage(FixtureGameQueries inner, GamePage page) : IGameQueries + { + public Task SearchAsync(GameFilter filter, CancellationToken cancellationToken = default) => + inner.SearchAsync(filter, cancellationToken); + + public Task> ListAsync(GameFilter filter, CancellationToken cancellationToken = default) => + inner.ListAsync(filter, cancellationToken); + + public Task FindAsync(string slug, CancellationToken cancellationToken = default) => + slug == page.Summary.Slug ? Task.FromResult(page) : inner.FindAsync(slug, cancellationToken); + + public Task FindAsync(Guid id, CancellationToken cancellationToken = default) => + id == page.Summary.Id ? Task.FromResult(page) : inner.FindAsync(id, cancellationToken); + + public Task FindByIdAsync(Guid id, CancellationToken cancellationToken = default) => + inner.FindByIdAsync(id, cancellationToken); + + public Task FeedsAsync(CancellationToken cancellationToken = default) => + inner.FeedsAsync(cancellationToken); + + public Task EcosystemAsync(CancellationToken cancellationToken = default) => + inner.EcosystemAsync(cancellationToken); + + public Task RankingsAsync( + RankingSpan span = RankingSpan.Week, + CancellationToken cancellationToken = default) => + inner.RankingsAsync(span, cancellationToken); + + public Task> RecentFieldChangesAsync( + int limit, + int perGameLimit = 3, + CancellationToken cancellationToken = default) => + inner.RecentFieldChangesAsync(limit, perGameLimit, cancellationToken); + } +}