From 8532472b2643942bea03d31ebffbf8f33c2e617b Mon Sep 17 00:00:00 2001 From: Harry Cordewener Date: Fri, 31 Jul 2026 09:23:43 -0500 Subject: [PATCH] Say what we measured, who we read, and how to make us stop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /about, which spec §7.6 and §11 both require and neither calls a feature: it is an obligation incurred by crawling other people's sites and dialling other people's servers. It carries the measured-vs-declared spine, the vocabulary rule that a socket from one vantage point is reachability and never uptime, the stated known limitation that archive grace is computed only from reachable time we probed (so a game running since 1995 starts at the floor on the day we find it, and MSSP CREATED buys nothing), the four directories the backfill read plus the one it has chosen not to, what is taken from them — addresses, nothing else — and why, the crawler's identity and the shape of a probe, and the licence position: MIT code, and a data licence that is still an open question rather than one settled by accident. Both surfaces render one view model, so a sentence cannot exist on the graphical page and not in ?plain=1. The attribution list is pinned against docs/import-sources.md by parsing it: a source added to the record and not to the page is the failure worth catching, and a copied list cannot catch it. Three things it deliberately does not claim. The opt-out is not advertised as automated, because neither the MSSP field nor the DNS TXT record the design describes exists in code and a switch wired to nothing is worse than none. The crawler is not said to identify itself: TelnetNegotiationCore hardcodes a client's terminal types with no setter and answers NEW-ENVIRON from the host's own environment, so ProbeOptions.TerminalTypes and InfoUrl reach no server — both now documented where they are declared, and both a PR upstream rather than a reflection hack here. And an unconfigured contact address is marked as the placeholder it is instead of published as the way to reach us. No figure on the page is one we would have had to invent; the one recorded run's yields are left to the record, since they describe a database that is not necessarily this one. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NrGKmKcRCGktyhRTFbQDMk --- src/MUI.Crawl/ProbeOptions.cs | 23 ++ src/MUI.Web/Components/AboutPage.cs | 364 ++++++++++++++++++ .../Components/Layout/MainLayout.razor | 1 + src/MUI.Web/Components/Pages/About.razor | 123 ++++++ src/MUI.Web/Components/PlainText.cs | 73 ++++ src/MUI.Web/wwwroot/app.css | 18 + tests/MUI.Web.Tests/AboutPageTests.cs | 356 +++++++++++++++++ 7 files changed, 958 insertions(+) create mode 100644 src/MUI.Web/Components/AboutPage.cs create mode 100644 src/MUI.Web/Components/Pages/About.razor create mode 100644 tests/MUI.Web.Tests/AboutPageTests.cs diff --git a/src/MUI.Crawl/ProbeOptions.cs b/src/MUI.Crawl/ProbeOptions.cs index f37656e..6efd6b3 100644 --- a/src/MUI.Crawl/ProbeOptions.cs +++ b/src/MUI.Crawl/ProbeOptions.cs @@ -123,9 +123,26 @@ public sealed record ProbeOptions /// What the crawler calls itself over TTYPE/MTTS and MNES CLIENT_NAME (spec §11). /// /// + /// /// An admin reading their logs must be able to find out who we are and how to opt out, so this /// is a politeness obligation rather than a cosmetic string. It carries a URL for the same /// reason. + /// + /// + /// It does not reach the wire yet, and neither does . + /// TelnetNegotiationCore's TerminalTypeProtocol hardcodes a client's terminal types to + /// TNC, XTERM, MTTS 3853 in a private field with no setter, and its + /// NewEnvironProtocol answers a server's NEW-ENVIRON request with the crawler host's own + /// USER and a fixed LANG — so what an admin actually sees is the library's default + /// and a local account name, not us. The library is first-party: the fix is a PR there making + /// both settable, never a reflection hack or a hand-rolled plugin here. + /// + /// + /// Until then nothing may claim otherwise. /about reads this field and says plainly that + /// the crawler is configured to call itself this and does not manage to, because + /// "the crawler identifies itself" is a claim about our own behaviour and that one would be + /// false in exactly the way ContactedMaintainer's default was. + /// /// public IReadOnlyList TerminalTypes { get; init; } = ["MUINDEX-CRAWLER", "MUINDEX", "MTTS 9"]; @@ -159,5 +176,11 @@ public sealed record ProbeOptions public const byte MsspOption = 70; /// Where an admin can read what we do and ask us to stop. + /// + /// A placeholder domain, because the domain is an open question (spec §15.1) and inventing one + /// here would settle it by accident. It is also not yet sent to anybody — see + /// — so a deployment that leaves this alone is publishing an address + /// that answers nobody. /about compares against this default and says so when it matches. + /// public string InfoUrl { get; init; } = "https://muindex.example/crawler"; } diff --git a/src/MUI.Web/Components/AboutPage.cs b/src/MUI.Web/Components/AboutPage.cs new file mode 100644 index 0000000..3ca2b85 --- /dev/null +++ b/src/MUI.Web/Components/AboutPage.cs @@ -0,0 +1,364 @@ +using MUI.Crawl; +using MUI.Web.Api; + +namespace MUI.Web.Components; + +/// +/// The about page, as one view model both surfaces render. +/// +/// +/// +/// The page is an obligation rather than a feature. This project reads other people's directories +/// and connects to other people's servers, and spec §7.6 and §11 both say the same thing about that: +/// credit what we read, publish who is knocking and how to make it stop, and state the limits of +/// what a measurement here actually proves. +/// +/// +/// The prose is data because both surfaces have to carry it. A sentence that exists only in the +/// graphical page would be a sentence the plain page could not say, and on this site that is the +/// definition of decoration — see . So the page is a list of sections, the +/// renderers differ in markup and in nothing else, and the parity test reads words rather than tags. +/// +/// +/// Nothing here is written from the design document alone. The spec describes an opt-out over +/// an MSSP field and a DNS TXT record, and a crawler that names itself in TTYPE — none of which is +/// implemented today (see ). Writing the design's intentions here as though +/// they were the deployment's behaviour is the exact shape of the ContactedMaintainer defect +/// this repository already has a record of: a claim about the world compiled in by whoever typed it. +/// +/// +public sealed record AboutPage(string Lede, IReadOnlyList Sections) +{ + /// Every directory credited anywhere on the page, in the order they are shown. + public IReadOnlyList Sources => [.. Sections.SelectMany(s => s.Sources)]; + + /// + /// Builds the page from the two things about it a deployment can change. + /// + /// + /// The crawler's own options, so the identity published here is the object the probe is + /// constructed from rather than a copy of it that can drift. + /// + /// The licence terms this deployment serves its dumps under. + public static AboutPage Build(ProbeOptions probe, DatasetLicenceOptions dataset) => new( + "Every game listed here was measured by a machine that connected to it, and every value " + + "says where it came from and when. This page is what that does and does not amount to: " + + "what we can honestly tell you, what we know we get wrong, whose directories we read to " + + "find the games in the first place, and how to make the crawler stop.", + [ + Measures(), + Limits(), + Never(), + Crawler(probe), + Attribution(), + Licence(dataset), + ]); + + private static AboutSection Measures() => new( + "measures", + "What a fact on this site is", + [ + new("Measured beats declared, and both are shown.", + "A game's MSSP report is the game describing itself. The telnet handshake is us " + + "watching what it actually does. Both appear on a game's page, each labelled with " + + "how it was obtained and how old it is — and where the two disagree, the " + + "disagreement is the interesting fact and is not hidden or averaged away."), + new("A player count comes from one of two places, and says which.", + "Either a WHO or DOING read at the connect screen before logging in, which is a " + + "number we counted, or the game's own MSSP PLAYERS field, which is a number the " + + "game published. They are different claims and are never merged into one figure."), + new("An answer we cannot read is unknown, never zero.", + "MU* servers customise their WHO headers freely and past a point our parser cannot " + + "read one. That produces uncountable, which is its own state. A measured zero — " + + "we got in and nobody was there — is a count, and is printed as the zero it is."), + new("Reachable, never uptime.", + "We open a socket from one host at intervals. That measures whether we could reach " + + "a game from here, and it does not measure whether the game was up: a game with a " + + "routing problem to our vantage point is unreachable and perfectly alive. Nothing " + + "on this site claims to know a game's uptime, because nothing here measured it."), + new("An hour is counted, uncountable, or not measured.", + "The activity grid has three states rather than two, and the third is empty and " + + "names no cause. An hour we could not reach and an hour we never probed look the " + + "same there, because they are the same absence of a measurement — and colouring " + + "either of them as downtime would record a decision of ours as a fact about " + + "somebody else's server."), + ]); + + private static AboutSection Limits() => new( + "limits", + "What we know we get wrong", + [ + new("Archive grace is measured from the day we found you.", + "A game that stops answering leaves the default listing once it has been " + + "unreachable for longer than its grace period, and that period is a quarter of " + + "the reachable time we ourselves probed, floored at 60 days and capped at 365. " + + "So a game running continuously since 1995 starts at the 60-day floor on the day " + + "we discover it and accrues from there. It is a real limitation rather than a " + + "rounding error, and it is the accepted cost of every fact here being measured " + + "here: nothing is imported that would fill in the years before we arrived."), + new("We do not credit MSSP CREATED toward that grace.", + "A game can declare it has existed since 1995 and probably has. It is also one " + + "hand-typed line in a configuration file that nothing verifies, and crediting it " + + "toward the archive threshold would make that threshold gameable by editing that " + + "line. It is shown as the declaration it is, and it buys nothing."), + new("Claiming a game earns the ceiling.", + "Someone who can prove server access has demonstrably staked a claim, and that is " + + "worth the full year of grace regardless of how long we have been watching."), + new("Everything here is one host, looking at intervals.", + "A percentage of reachable time is a fraction of the window we observed and never " + + "of a window we did not. A game we have measured once is a game we have measured " + + "once, and no graphic on this site will imply otherwise by filling in the rest."), + new("Nothing is ever deleted.", + "Archiving takes a game out of the default listing, the rankings and the " + + "active-today figure, and out of nothing else. Its page, its URL, its history and " + + "its address survive, it keeps being probed forever, and one successful probe " + + "puts it straight back."), + ]); + + private static AboutSection Never() => new( + "never", + "What this site will not do", + [ + new("There are no votes, stars, ratings or recommendations.", + "Rankings are computed from measured data and from nothing else. This is not a " + + "feature we have not got round to: a directory ranked by who can mobilise the " + + "most clicks stops describing the hobby and starts describing the campaigning, " + + "and that is what killed the incumbents."), + new("There are no forums, reviews, wikis, comments or player profiles.", + "Orientation material — what a MUSH is, how it differs from a MUD, which codebase " + + "suits collaborative roleplay — is written and signed and versioned like the rest " + + "of the site, rather than opened to editing and then moderated."), + new("Player names are never persisted.", + "A WHO reply is parsed in memory to get a count and the shape of the header, and " + + "the names in it are not written down. Anything aggregated uses a salted hash " + + "with a rotating salt, so an estimate of distinct players is possible while " + + "re-identifying one across salt epochs is not."), + new("No absolute population figure is published.", + "Per-codebase and per-protocol shares ship, because a ratio over the measured set " + + "survives the games we cannot count and the games we have not found. \"How many " + + "people play MU*\" does not ship, because that number would not survive being " + + "quoted."), + ]); + + private static AboutSection Crawler(ProbeOptions probe) => new( + "crawler", + "The crawler, and how to make it stop", + [ + new("A probe is one connection that never logs in.", + "It opens a socket, negotiates telnet options, reads whatever connect screen the " + + "server paints, asks for MSSP by negotiating option 70, sends a single " + + $"{string.Join(" or ", TelnetProbe.PermittedCommands)} at the connect screen, and " + + "disconnects. It creates no character, sends no login, and changes nothing on the " + + "far side. The whole session is bounded by a timeout so a wedged probe cannot sit " + + "on a server's connection slot."), + new("CRAWL DELAY wins.", + "A game that states a preferred minimum gap between crawls in its MSSP report gets " + + "it, and it beats our own schedule in both directions: a game asking for 720 " + + "hours is probed monthly, not weekly. A game that has gone dark is still tried " + + "forever at whichever of the two intervals is longer, which is how a game that " + + "comes back re-lists itself with nobody involved."), + new("A referred address is verified, never trusted.", + "MSSP lets a game name other games. Those names are candidates and not facts: every " + + "one is resolved before anything is dialled and refused unless every address it " + + "resolves to is globally routable, so a hostname pointed at a private or " + + "link-local address reaches nothing. A mixed answer refuses the whole target " + + "rather than picking the good address out of it. A refusal of ours is recorded as " + + "ours and never appears in a game's record as downtime."), + new("Connect screens are shown because they are sent to everybody.", + "A server paints its connect screen, unauthenticated, to every anonymous connection " + + "that arrives. We display it as evidence and label it as what it is. If you would " + + "rather we did not, say so and it comes down — no questions and no argument."), + new("Ask, and we stop.", + "There is no automated opt-out yet. The design calls for one over an MSSP field and " + + "a DNS TXT record; neither is implemented, and advertising a switch that is not " + + "wired to anything would be worse than saying so. Until they exist the route is " + + "to ask a person, and a request is honoured whether or not the machinery for it " + + "is tidy."), + ]) + { + Identity = AboutIdentity.For(probe), + }; + + private static AboutSection Attribution() => new( + "sources", + "Where the list of games came from", + [ + new("We take addresses. Nothing else.", + "A day-one directory needs games to probe, and the existing directories are the " + + "best seed there is. What a backfill takes from them is a host and a port — no " + + "player counts, no reachability history, no descriptions, no fields, and no note " + + "of which site an address was read on."), + new("This is deliberately less than those sites can give.", + "Several of them hold years of dated player counts. Importing that would fill the " + + "heatmaps of exactly the games somebody else was already watching, in a way no " + + "reader could tell from our own measurement without reading the fine print, and " + + "would leave this site's central claim resting on another party's prober."), + new("A game's origin is not one fact, and would be a misleading one.", + "Any game worth listing appears in several of these directories, so \"imported " + + "from\" would name whichever fetch happened to run first rather than anything " + + "about the game. That a game exists is public information published by its " + + "operator to be dialled; where we happened to read it adds nothing a reader can " + + "use, and it is the part of somebody else's work with the least claim to be ours."), + new("Reading somebody's site is still reading somebody's site.", + "Taking less data does not make a crawl less of a crawl. A bulk export or a " + + "documented endpoint is asked for in preference to scraping, robots.txt is read " + + "first, scrapes are rate-limited hard, and a source that needs its maintainer's " + + "say-so is not fetched until a person can state that they were asked."), + ]) + { + Sources = + [ + new("TinTin++ MSSP Mud Crawler", "https://tintin.mudhalla.net/protocols/mssp/", + ImportSourceState.Read, + "One page for one request, published by a crawler that connects to each game and " + + "prints what it read."), + new("TinTin++ MSDP Mud Crawler", "https://tintin.mudhalla.net/protocols/msdp/", + ImportSourceState.Read, + "The same crawler's MSDP listing. Very nearly a subset of its MSSP sibling, read " + + "for the handful of addresses it reaches that the other does not."), + new("The Mud Connector", "https://www.mudconnect.com/", + ImportSourceState.Read, + "Publishes its whole catalogue on one page, so reading all of it costs a single " + + "request. It is the largest contributor of addresses here and contributes no " + + "measurement, which is the split working as intended."), + new("MudStats", "https://mudstats.com/", + ImportSourceState.Read, + "One index page and one page per world, which makes it a scrape rather than an " + + "export. On 30 July 2026 we fetched 143 of their pages — fifteen seconds apart " + + "and honouring their robots.txt, but before anyone had written to them. That " + + "should not have happened. The gate that would have stopped it can no longer be " + + "satisfied by a default in a source file; it now takes a person willing to state " + + "that the maintainer was asked."), + new("MudVerse", "https://www.mudverse.com/", + ImportSourceState.Withheld, + "Implemented, tested, and never run. It is the strongest source in this list on " + + "every axis except permission, and nothing will be fetched from it until somebody " + + "has written to them."), + ], + }; + + private static AboutSection Licence(DatasetLicenceOptions dataset) => new( + "licence", + "Licence", + [ + new("The code is MIT.", + "The site, the crawler and the parsers are open source under the MIT licence."), + new("The licence for the data is an open question.", + "It is a separate decision from the code's and has not been taken. Anyone planning " + + "to build on the dataset should treat the terms below as this deployment's " + + "current answer rather than as the project's settled position — and a rival " + + "directory taking the whole catalogue is a success condition here, not a threat, " + + "so whatever is settled will not be written to stand in the way of one."), + ]) + { + Licence = new AboutLicence( + "MIT", + dataset.LicenceName, + dataset.LicenceUrl, + dataset.Attribution, + dataset.Notice), + }; +} + +/// One headed run of prose, plus whatever structured block belongs under it. +/// +/// The extras are nullable rather than a separate ordered list, so both renderers walk one sequence +/// and neither has an if-ladder deciding where a block goes. A block that appeared in one surface +/// and not the other is the failure this page is a test of. +/// +public sealed record AboutSection(string Id, string Heading, IReadOnlyList Points) +{ + /// Who the crawler says it is, when this section is the one about the crawler. + public AboutIdentity? Identity { get; init; } + + /// The directories credited under this section. + public IReadOnlyList Sources { get; init; } = []; + + /// The terms the data goes out under, when this section is the one about licensing. + public AboutLicence? Licence { get; init; } +} + +/// +/// A lead-in and the paragraph it introduces. +/// +/// +/// Split rather than one string because the graphical page sets the lead in bold and the plain page +/// cannot. Keeping them apart means the emphasis is presentational and the sentence is not — the two +/// surfaces read identically aloud. +/// +public sealed record AboutPoint(string Lead, string Body) +{ + public string Sentence => $"{Lead} {Body}"; +} + +/// +/// What a server administrator sees when we knock, and what to do about it. +/// +/// +/// +/// Read off rather than written out here, so the name published on the +/// page is a property of the object the probe is built from. +/// +/// +/// is false and that is not a formality. Neither +/// nor reaches the wire: +/// TelnetNegotiationCore's client-mode terminal type is a hardcoded private list with no setter, so +/// an administrator reading their logs sees the library's default and not us. The page says so +/// rather than printing a name nobody will ever observe, because "the crawler identifies itself" is +/// a claim about our behaviour and this one would be false. +/// +/// +public sealed record AboutIdentity(string Name, string InfoUrl, bool Announced, bool ContactConfigured) +{ + public static AboutIdentity For(ProbeOptions probe) => new( + probe.TerminalTypes.Count > 0 ? probe.TerminalTypes[0] : "MUINDEX-CRAWLER", + probe.InfoUrl, + // Nothing consumes either field yet. When something does, this becomes a property of the + // probe rather than a constant, and the sentence below changes with it. + Announced: false, + // The built-in value is a placeholder on a domain that has not been chosen. Publishing it as + // the way to reach us, unmarked, would be publishing an address that answers nobody. + ContactConfigured: probe.InfoUrl != new ProbeOptions().InfoUrl); + + /// The honest version of "who is this in my logs", in one sentence. + public string Wording => Announced + ? $"The crawler names itself {Name} when a server asks what it is." + : $"The crawler is configured to call itself {Name}, and does not yet manage to say so: " + + "the telnet library it uses gives a client no way to set the terminal type it reports, so " + + "what reaches your logs is that library's own default, and a NEW-ENVIRON request is " + + "answered from the crawler host's environment rather than with anything about us. Both " + + "are gaps in the library and both are ours to fix there. Until they are fixed, the way to " + + "recognise a probe is its shape: one connection, no login, one WHO, gone."; +} + +/// Whether a directory was actually read, which is not the same as whether we can read it. +public enum ImportSourceState +{ + /// Fetched. Addresses were taken from it. + Read, + + /// + /// Implemented and deliberately not run, because it is a scrape and nobody has asked its + /// maintainer yet. Credited anyway: the reader is owed the whole list, and a source we have + /// chosen not to fetch is a different fact from one we never considered. + /// + Withheld, +} + +/// One directory, credited by name, with what was taken from it and whether it was read. +public sealed record ImportSource(string Name, string Url, ImportSourceState State, string Note) +{ + public string StatusWording => State switch + { + ImportSourceState.Read => "read — addresses only", + _ => "not read — waiting on permission", + }; +} + +/// The two licences, which are two decisions and only one of them has been taken. +public sealed record AboutLicence( + string CodeLicence, + string DataLicenceName, + string? DataLicenceUrl, + string Attribution, + string Notice); diff --git a/src/MUI.Web/Components/Layout/MainLayout.razor b/src/MUI.Web/Components/Layout/MainLayout.razor index 948854c..79592f5 100644 --- a/src/MUI.Web/Components/Layout/MainLayout.razor +++ b/src/MUI.Web/Components/Layout/MainLayout.razor @@ -24,6 +24,7 @@ every game here was checked by a machine, and every fact says when diff --git a/src/MUI.Web/Components/Pages/About.razor b/src/MUI.Web/Components/Pages/About.razor new file mode 100644 index 0000000..c5b74cb --- /dev/null +++ b/src/MUI.Web/Components/Pages/About.razor @@ -0,0 +1,123 @@ +@page "/about" +@using Microsoft.Extensions.Options +@using MUI.Crawl +@using MUI.Web.Api +@inject IOptions Dataset +@inject IServiceProvider Services + +@* + The page this project owes the sites it read and the servers it dials. + + Spec §7.6 and §11 both require it, and neither of them calls it a feature: crediting the + directories a backfill read, publishing who is knocking and how to make them stop, and stating + the limits of what a measurement here proves are obligations already incurred by crawling. + + It is prose and a short list, and it is deliberately the plainest page on the site — the whole + argument this project makes is that a fact stated with its provenance beats a fact presented + well, and the page making that argument would be a poor place to contradict it. +*@ + +@if (Plain) +{ +
@PlainText.RenderAbout(Page)
+} +else +{ + About — mu*index + +

About mu*index

+

@Page.Lede

+ + @foreach (var section in Page.Sections) + { +
+

@section.Heading

+ + @foreach (var point in section.Points) + { +

@point.Lead @point.Body

+ } + + @if (section.Identity is { } identity) + { +

@identity.Wording

+ +
+
crawler
+
@identity.Name
+
contact
+
+ @identity.InfoUrl + @if (!identity.ContactConfigured) + { + @* Amber is the site's word for declared-but-unverified, and an address + nobody configured is exactly that: a value with nothing behind it. *@ + — placeholder; this deployment set no contact address + } +
+
+ } + + @if (section.Sources.Count > 0) + { +
    + @foreach (var source in section.Sources) + { + @* Name and state on one line, the note under both. The listing's two-column + row is wrong here: a note is a paragraph rather than a facts column, and + reserving a column for a short badge leaves the prose reading in a gutter. *@ +
  • +

    + @source.Name + @source.StatusWording +

    +

    @source.Note

    +
  • + } +
+ } + + @if (section.Licence is { } licence) + { +
+
code
+
@licence.CodeLicence
+
data, as this deployment serves it
+
+ @if (licence.DataLicenceUrl is { } url) + { + @licence.DataLicenceName + } + else + { + @licence.DataLicenceName + } +
+
credit as
+
@licence.Attribution
+
+ +

@licence.Notice

+ } +
+ } + +

read this page as plain text

+} + +@code { + [SupplyParameterFromQuery(Name = "plain")] private string? PlainFlag { get; set; } + + private bool Plain => Truthy.Is(PlainFlag); + + /* + The crawler's own options when the crawl loop is hosted in this process, and the defaults + when it is not. Asking the container rather than constructing one means the identity this + page publishes is the identity the probe was built from — a page that said MUINDEX-CRAWLER + while a deployment had configured something else would be describing the source tree instead + of the crawler, which is the failure mode of every claim on this site that nobody pinned. + */ + private AboutPage Page => AboutPage.Build( + Services.GetService(typeof(ProbeOptions)) as ProbeOptions ?? new ProbeOptions(), + Dataset.Value); +} diff --git a/src/MUI.Web/Components/PlainText.cs b/src/MUI.Web/Components/PlainText.cs index 89e054c..aa25301 100644 --- a/src/MUI.Web/Components/PlainText.cs +++ b/src/MUI.Web/Components/PlainText.cs @@ -350,6 +350,79 @@ public static string RenderArchive(IReadOnlyList entries, string? return b.ToString(); } + /// + /// The about page. Prose, so the only thing the graphical version adds is the shape of it. + /// + /// + /// The attribution list is the part that has to survive here above all: it is what this project + /// owes the directories it read, and an acknowledgement a text browser cannot render is an + /// acknowledgement made to the layout rather than to anybody. + /// + public static string RenderAbout(AboutPage page) + { + var b = new StringBuilder(); + + b.AppendLine("ABOUT MU*INDEX"); + b.AppendLine(); + Wrap(b, page.Lede); + + foreach (var section in page.Sections) + { + Heading(b, section.Heading); + + foreach (var point in section.Points) + { + b.AppendLine(); + Wrap(b, point.Sentence, " "); + } + + if (section.Identity is { } identity) + { + b.AppendLine(); + Wrap(b, identity.Wording, " "); + b.AppendLine(); + Wrap(b, $"Crawler: {identity.Name}", " "); + Wrap(b, $"Contact: {identity.InfoUrl}", " "); + + if (!identity.ContactConfigured) + { + Wrap(b, "This deployment has not set a contact address, so the one above is " + + "the built-in placeholder and answers nobody.", " "); + } + } + + foreach (var source in section.Sources) + { + b.AppendLine(); + b.AppendLine($" {source.Name} — {source.StatusWording}"); + b.AppendLine($" {source.Url}"); + Wrap(b, source.Note, " "); + } + + if (section.Licence is { } licence) + { + b.AppendLine(); + // Every one of these goes through the wrapper rather than being laid out in columns: + // a licence name and an attribution are both configuration, and a deployment that + // sets a long one must not push a line off the side of a text browser. + Wrap(b, $"Code: {licence.CodeLicence}", " "); + Wrap(b, $"Data: {licence.DataLicenceName}", " "); + + if (licence.DataLicenceUrl is { } url) + { + Wrap(b, url, " "); + } + + Wrap(b, "(what this deployment serves. The project's own answer is still open.)", " "); + Wrap(b, $"Credit as: {licence.Attribution}", " "); + b.AppendLine(); + Wrap(b, licence.Notice, " "); + } + } + + return b.ToString(); + } + private static void Heading(StringBuilder b, string title) { b.AppendLine(); diff --git a/src/MUI.Web/wwwroot/app.css b/src/MUI.Web/wwwroot/app.css index ceb4a40..940dd43 100644 --- a/src/MUI.Web/wwwroot/app.css +++ b/src/MUI.Web/wwwroot/app.css @@ -517,3 +517,21 @@ fieldset.facets .check { display: inline-flex; gap: 6px; align-items: center; fo border-bottom: 1px solid var(--amber); color: var(--text); } + +/* ── about ───────────────────────────────────────────────────────────────── + Prose measure and nothing else. The one page whose whole job is to say what the rest of the site + can and cannot prove is the wrong place to spend the accent on decoration, so it carries no + colour of its own — the only tinted thing here is a contact address nobody configured, which is + amber for the same reason every other unverified value on this site is. */ + +section.about { max-width: 68ch; } +section.about h2 { margin-top: calc(var(--cpad) * 2.5); } + +dl.identity { font-size: 13px; margin: var(--cpad) 0; } +dl.identity dt { margin-top: 8px; } +dl.identity dd { margin: 2px 0 0; color: var(--dim); } + +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; } diff --git a/tests/MUI.Web.Tests/AboutPageTests.cs b/tests/MUI.Web.Tests/AboutPageTests.cs new file mode 100644 index 0000000..1792177 --- /dev/null +++ b/tests/MUI.Web.Tests/AboutPageTests.cs @@ -0,0 +1,356 @@ +using System.Text.RegularExpressions; + +using Microsoft.AspNetCore.Components.Web; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; + +using MUI.Crawl; +using MUI.Web.Api; +using MUI.Web.Components; + +namespace MUI.Web.Tests; + +/// +/// The about page, which is an obligation this project incurred by crawling rather than a feature. +/// +/// +/// +/// The load-bearing test here is not that the page renders: it is that the attribution list names +/// every directory docs/import-sources.md says was read. That file is the record §7.6 leaves +/// behind on main when the importer goes, and a credit that drifts from it is a credit that +/// quietly drops whoever was added last. +/// +/// +/// The rest assert sentences rather than markup, for the reason the whole plain surface exists: a +/// limitation that only survives in the graphical page is a limitation stated to the layout. +/// +/// +public class AboutPageTests +{ + private static AboutPage Page => AboutPage.Build(new ProbeOptions(), new DatasetLicenceOptions()); + + private static string Plain => PlainText.RenderAbout(Page); + + [Test] + public async Task EveryDirectoryTheBackfillReadIsCreditedByNameAndByAddress() + { + // Parsed from the record rather than pasted from it. A source added to the document and not + // to the page is the failure this exists to catch, and a copied list cannot catch it. + var documented = DocumentedSources(); + + await Assert.That(documented).IsNotEmpty(); + await Assert.That(Page.Sources).IsNotEmpty(); + + foreach (var (name, url) in documented) + { + await Assert.That(Plain).Contains(name); + await Assert.That(Plain).Contains(url); + } + } + + [Test] + public async Task ASourceWeChoseNotToFetchIsCreditedAndSaidToBeUnread() + { + // MudVerse is written, tested and deliberately never run. Leaving it off would read as + // completeness; crediting it as read would be the same lie the other way round. + var mudverse = Page.Sources.Single(s => s.Name == "MudVerse"); + + await Assert.That(mudverse.State).IsEqualTo(ImportSourceState.Withheld); + await Assert.That(Plain).Contains("not read — waiting on permission"); + } + + [Test] + public async Task TheAttributionSaysAddressesOnlyAndSaysWhy() + { + await Assert.That(Render.Words(Plain)).Contains("We take addresses. Nothing else."); + await Assert.That(Plain).Contains("no player counts"); + await Assert.That(Plain).Contains("no reachability history"); + } + + [Test] + public async Task TheCrawlOfMudStatsThatWentOutUnaskedIsOnThePage() + { + // The record matters more than the tidy version, and it matters most on the page that asks + // to be trusted about everything else. + var text = Render.Words(Plain); + + await Assert.That(text).Contains("143 of their pages"); + await Assert.That(text).Contains("before anyone had written to them"); + } + + [Test] + public async Task TheArchiveGraceLimitationIsStatedInTheSpecsOwnTerms() + { + var text = Render.Words(Plain); + + await Assert.That(text).Contains("60-day floor on the day we discover it"); + await Assert.That(text).Contains("reachable time we ourselves probed"); + } + + [Test] + public async Task TheReasonMsspCreatedEarnsNoGraceIsGiven() + { + var text = Render.Words(Plain); + + await Assert.That(text).Contains("do not credit MSSP CREATED"); + await Assert.That(text).Contains("gameable"); + } + + [Test] + public async Task TheCrawlerIdentityIsReadOffTheProbeRatherThanWrittenOut() + { + // A name typed into the page is a description of the source tree. This one has to be the + // object the probe is constructed from, or a deployment that configures it gets a page that + // confidently names somebody else. + var probe = new ProbeOptions { TerminalTypes = ["EXAMPLE-CRAWLER"], InfoUrl = "https://example.test/bot" }; + var text = PlainText.RenderAbout(AboutPage.Build(probe, new DatasetLicenceOptions())); + + await Assert.That(text).Contains("EXAMPLE-CRAWLER"); + await Assert.That(text).Contains("https://example.test/bot"); + await Assert.That(text).DoesNotContain("MUINDEX-CRAWLER"); + } + + [Test] + public async Task TheCrawlerIsNotClaimedToAnnounceItselfWhileItCannot() + { + // TelnetNegotiationCore gives a client no way to set its terminal type, so nothing we + // configure reaches an admin's logs. Saying it does would be the ContactedMaintainer defect + // in a new place: a claim about our own behaviour, compiled in by whoever typed it. + var identity = Page.Sections.Single(s => s.Id == "crawler").Identity; + + await Assert.That(identity).IsNotNull(); + await Assert.That(identity!.Announced).IsFalse(); + await Assert.That(Render.Words(Plain)).Contains("configured to call itself"); + } + + [Test] + public async Task AnUnconfiguredContactAddressIsMarkedAsThePlaceholderItIs() + { + // The built-in URL is on a domain nobody has chosen. Printed unmarked it would read as the + // way to reach us, which is the one thing this section exists to provide. + await Assert.That(Plain).Contains("built-in placeholder"); + + var configured = PlainText.RenderAbout(AboutPage.Build( + new ProbeOptions { InfoUrl = "https://example.test/crawler" }, new DatasetLicenceOptions())); + + await Assert.That(configured).DoesNotContain("built-in placeholder"); + } + + [Test] + public async Task TheOptOutIsNotAdvertisedAsAutomatedWhileItIsNot() + { + // Neither the MSSP field nor the DNS TXT record the design describes exists in code. A page + // offering a switch that is wired to nothing is worse than a page admitting there is none. + var text = Render.Words(Plain); + + await Assert.That(text).Contains("no automated opt-out yet"); + await Assert.That(text).Contains("Ask, and we stop."); + } + + [Test] + public async Task ThePoliteAndSecurityFactsAboutAProbeSurvive() + { + var text = Render.Words(Plain); + + await Assert.That(text).Contains("CRAWL DELAY wins."); + await Assert.That(text).Contains("resolved before anything is dialled"); + await Assert.That(text).Contains("globally routable"); + } + + [Test] + public async Task ThePermittedCommandIsReadOffTheProbeAndNotDescribedFromMemory() + { + // "It sends one WHO" is a claim about the probe. The probe publishes the list, so the page + // cannot understate it when a second command is ever added. + foreach (var command in TelnetProbe.PermittedCommands) + { + await Assert.That(Plain).Contains(command); + } + } + + [Test] + public async Task TheMeasuredSpineSurvivesInWords() + { + var text = Render.Words(Plain); + + await Assert.That(text).Contains("Measured beats declared, and both are shown."); + await Assert.That(text).Contains("MSSP PLAYERS field"); + await Assert.That(text).Contains("WHO or DOING read at the connect screen"); + await Assert.That(text).Contains("unknown, never zero"); + } + + [Test] + public async Task ReachableIsExplainedAndNeverCalledUptimeExceptToRefuseTheWord() + { + var text = Render.Words(Plain); + + await Assert.That(text).Contains("Reachable, never uptime."); + + // The word appears twice and both are refusals. Anything else would be the site's own + // vocabulary rule broken on the page that states it. + var uses = Regex.Matches(text, "uptime", RegexOptions.IgnoreCase).Count; + await Assert.That(uses).IsEqualTo(2); + await Assert.That(text).Contains("does not measure whether the game was up"); + await Assert.That(text).Contains("nothing here measured it"); + } + + [Test] + public async Task TheThingsThisSiteWillNotDoAreStatedRatherThanImplied() + { + // The one page where these words are allowed to appear, because it is the page that says + // they are absent. PlainParityTests asserts the opposite about every other surface. + var text = Render.Words(Plain); + + await Assert.That(text).Contains("no votes, stars, ratings or recommendations"); + await Assert.That(text).Contains("no forums, reviews, wikis, comments or player profiles"); + await Assert.That(text).Contains("Player names are never persisted."); + await Assert.That(text).Contains("No absolute population figure is published."); + } + + [Test] + public async Task TheDataLicenceIsPresentedAsUndecidedRatherThanAsSettled() + { + var text = Render.Words(Plain); + + await Assert.That(text).Contains("The code is MIT."); + await Assert.That(text).Contains("licence for the data is an open question"); + await Assert.That(text).Contains("has not been taken"); + + // What the deployment serves is still shown — a consumer needs the terms — but framed as + // this deployment's answer rather than as the project's. + await Assert.That(text).Contains("this deployment serves"); + await Assert.That(text).Contains(new DatasetLicenceOptions().LicenceName); + } + + [Test] + public async Task NoPlainLineIsWiderThanEightyColumns() + { + // The over-long lines rather than the first one, because a width failure is usually a whole + // block that was built without the wrapper and the count is the diagnosis. + var wide = Plain.Split('\n') + .Select(l => l.TrimEnd()) + .Where(l => l.Length > PlainText.Columns) + .ToArray(); + + await Assert.That(wide).IsEmpty(); + } + + [Test] + public async Task TheGraphicalPageCarriesEverySentenceThePlainOneDoes() + { + // The parity that matters: both surfaces render one view model, so nothing can exist on one + // and not the other. Read off the rendered frame, because that is what a browser receives. + var markup = await RenderAboutAsync(); + var text = Text(markup); + + foreach (var section in Page.Sections) + { + await Assert.That(text).Contains(section.Heading); + + foreach (var point in section.Points) + { + await Assert.That(text).Contains(Render.Words(point.Sentence)); + } + + foreach (var source in section.Sources) + { + await Assert.That(text).Contains(source.Name); + await Assert.That(text).Contains(Render.Words(source.Note)); + + // The address is the link here and a printed line there. Asserted against the + // markup rather than the reading text, because a graphical page that spelled its + // URLs out as well would be the plain page with worse typography. + await Assert.That(markup).Contains(source.Url); + } + } + } + + [Test] + public async Task TheGraphicalPageIsReachableWithoutScriptingAndSaysHowToReadItPlainly() + { + var html = await RenderAboutAsync(); + + await Assert.That(html).DoesNotContain(" + /// A rendered page as its reader hears it: tags gone, entities decoded, whitespace collapsed. + /// + /// + /// A sentence whose lead-in is bold is one sentence with a tag inside it, so an assertion that + /// read the markup would be asserting on the emphasis rather than on the claim. + /// + private static string Text(string html) => Render.Words(Regex.Replace(html, "<[^>]+>", " ")); + + /// + /// Renders the page with the services it injects, which cannot supply. + /// + private static async Task RenderAboutAsync() + { + var services = new ServiceCollection(); + services.AddSingleton(NullLoggerFactory.Instance); + services.AddSingleton>( + Options.Create(new DatasetLicenceOptions())); + + await using var provider = services.BuildServiceProvider(); + await using var renderer = new HtmlRenderer(provider, NullLoggerFactory.Instance); + + return await renderer.Dispatcher.InvokeAsync(async () => + { + var output = await renderer.RenderComponentAsync(); + return output.ToHtmlString(); + }); + } + + /// + /// The sources docs/import-sources.md records under ## Read, as name and address. + /// + /// + /// Rows are | [Name](url) | … |; the second table in that stretch is a yield summary with + /// no links in its first cell, so requiring the link is what separates them. + /// + private static IReadOnlyList<(string Name, string Url)> DocumentedSources() + { + var lines = File.ReadAllLines(Path.Combine(RepositoryRoot(), "docs", "import-sources.md")); + var found = new List<(string, string)>(); + var reading = false; + + foreach (var line in lines) + { + if (line.StartsWith("## ", StringComparison.Ordinal)) + { + reading = line.StartsWith("## Read", StringComparison.Ordinal); + continue; + } + + if (!reading) + { + continue; + } + + var match = Regex.Match(line, @"^\|\s*\[([^\]]+)\]\(([^)]+)\)"); + if (match.Success) + { + found.Add((match.Groups[1].Value, match.Groups[2].Value)); + } + } + + return found; + } + + private static string RepositoryRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + + while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "MUIndex.slnx"))) + { + directory = directory.Parent; + } + + return directory?.FullName + ?? throw new InvalidOperationException("No MUIndex.slnx above " + AppContext.BaseDirectory); + } +}