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