diff --git a/Directory.Build.props b/Directory.Build.props index 2385b72..5326804 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -8,7 +8,28 @@ enable true false - true + + false true Harry Cordewener MUIndex diff --git a/content/reference/de/client-atlantis.md b/content/reference/de/client-atlantis.md new file mode 100644 index 0000000..00348a8 --- /dev/null +++ b/content/reference/de/client-atlantis.md @@ -0,0 +1,48 @@ +--- +kind: client +slug: atlantis +title: Atlantis +summary: Ein Client nur für macOS, langlebig und lange in der Beta. Sein Skripting ist als nicht mehr funktionierend dokumentiert, und das ist das eine ehrliche „Nein“ in diesem Abschnitt. +home: https://www.riverdark.net/atlantis/ +platform: macOS +capability: screen reader | unknown | +capability: TLS | yes | https://www.riverdark.net/atlantis/history.php +capability: UTF-8 | yes | https://www.riverdark.net/atlantis/history.php +capability: MCCP | yes | https://www.riverdark.net/atlantis/history.php +capability: GMCP | unknown | +capability: MSDP | unknown | +capability: ATCP | unknown | +capability: MXP | unknown | +capability: MSP | unknown | +capability: scripting | no | https://www.riverdark.net/atlantis/ +see-also: clients/mudlet +see-also: protocols/charset +--- + +Atlantis ist ein nativer macOS-Client, den es seit Mac OS X 10.3 gibt und der in der Catalina-Zeit +auf 64 Bit aktualisiert wurde. Er beherrscht die Zeichensatz-Aushandlung nach RFC 2066 und Unicode, +was besser ist, als sein Alter vermuten ließe, und er kann MCCP und SSL. + +## Das eine „Nein“ in diesem Abschnitt + +Sein Skripting war Perl über die CamelBones-Brücke, und die Startseite des Projekts selbst sagt, +dass es nicht mehr funktioniert — Apples Umgang mit Perl hat sich geändert, und der Autor der +Bibliothek ist vor einigen Jahren gestorben. Das ist ein *belegtes Fehlen*, und das ist etwas +anderes als ein Unbekannt; es ist die einzige Zelle im ganzen Client-Abschnitt, die so etwas trägt. +Überall sonst lautete die ehrliche Antwort, dass wir es nicht feststellen konnten. + +## Alles, was wir nicht feststellen konnten + +Die Versionsgeschichte ist vollständig und öffentlich und nennt **MCCP**, **SSL** und +**Zeichensatz-Aushandlung** — und nennt nie GMCP, MSDP, ATCP oder MSP. MXP taucht einmal auf, als +etwas, das für eine Version nach 1.0.0 vorgesehen war, die nicht gekommen ist. + +In der Skripting-API gibt es einen Perl-Aufruf `Atlantis::Speak()`, und es wäre leicht, das als +Screenreader-Unterstützung zu lesen. Das ist es nicht: Es ist ein per Skript ausgelöster Aufruf zur +Sprachausgabe in einem Skriptsystem, von dem das Projekt sagt, dass es nicht funktioniert. +VoiceOver, „accessible“ und „screen reader“ kommen weder auf der Startseite noch auf der +Downloadseite noch in der vollständigen Versionsgeschichte noch im archivierten Benutzerhandbuch +vor. + +Der aktuelle Download ist 0.9.9.8, nominell immer noch eine Beta, ohne dass irgendwo auf der Website +ein Veröffentlichungsdatum stünde. diff --git a/content/reference/de/client-beipmu.md b/content/reference/de/client-beipmu.md new file mode 100644 index 0000000..d9e12ba --- /dev/null +++ b/content/reference/de/client-beipmu.md @@ -0,0 +1,47 @@ +--- +kind: client +slug: beipmu +title: BeipMU +summary: Ein Windows-Client für die MUSH-Seite des Hobbys, mit Screenreader-Unterstützung im Ausgabefenster und Pueblo statt MXP. +home: https://beipdev.github.io/BeipMU/ +platform: Windows +capability: screen reader | yes | https://github.com/BeipDev/BeipMU/blob/master/Assets/Changes.txt +capability: TLS | yes | https://beipdev.github.io/BeipMU/ +capability: UTF-8 | yes | https://beipdev.github.io/BeipMU/ +capability: MCCP | unknown | +capability: GMCP | yes | https://github.com/BeipDev/BeipMU/blob/master/Documentation/GMCP.md +capability: MSDP | unknown | +capability: ATCP | unknown | +capability: MXP | unknown | +capability: MSP | unknown | +capability: scripting | yes | https://beipdev.github.io/BeipMU/ +see-also: clients/mushclient +see-also: clients/potato +see-also: collaborative-roleplay +--- + +BeipMU ist ein Windows-Client unter MIT-Lizenz, aktiv veröffentlicht, und einer der wenigen, die mit +Blick auf MUSH-artiges Spiel gebaut sind statt auf Kampf-MUDs — mehrere Eingabefenster, +Spawn-Fenster und eine Textmaschine, die lange Absätze erwartet. Skripting ist standardmäßig +JavaScript, weitere ActiveScript-Engines sind verfügbar. + +## Barrierefreiheit + +Das Ausgabefenster implementiert die Windows-Schnittstelle `IAccessible`, bewusst als Schritt hin zu +einer Benutzbarkeit für sehbehinderte Spieler eingebaut, und es gibt eine Trigger-Aktion **Speak** +für Sprachausgabe. Nirgends wird ein bestimmter Screenreader genannt, und ein Kapitel zur +Barrierefreiheit gibt es in der Dokumentation nicht. + +Eine Warnung, falls Sie nachsehen: Eine Seite in der projekteigenen Dokumentation sagt immer noch, +BeipMU könne keine Sprachsynthese nutzen. Diese Seite ist veraltet — das Änderungsprotokoll und die +Issue-Kommentare des Betreuers selbst sind beide jünger. + +## Zwei leichte Irrtümer über diesen Client + +**BeipMU implementiert MCMP, nicht MSP.** Das sind verschiedene Protokolle mit ähnlichen Namen und +ähnlichen Zwecken, und das eine als das andere zu lesen hieße, eine Behauptung in diese Tabelle zu +setzen, die niemand aufgestellt hat. Die MSP-Zeile sagt deshalb unbekannt. + +**Es unterstützt Pueblo, nicht MXP.** Pueblo ist das ältere Verfahren für HTML in einem MUD und MXP +das spätere; BeipMU dokumentiert einfache Pueblo-Stile und anklickbare Links. Zu MXP ließ sich weder +das eine noch das andere feststellen. diff --git a/content/reference/de/client-blightmud.md b/content/reference/de/client-blightmud.md new file mode 100644 index 0000000..d3a07ea --- /dev/null +++ b/content/reference/de/client-blightmud.md @@ -0,0 +1,51 @@ +--- +kind: client +slug: blightmud +title: Blightmud +summary: Ein moderner Terminal-Client in Rust, mit Lua-Skripting, eingebauter Sprachausgabe und einem Screenreader-Modus, der sich beim Server selbst ankündigt. +home: https://github.com/Blightmud/Blightmud +platform: Linux +platform: macOS +platform: Windows (WSL only) +capability: screen reader | yes | https://github.com/Blightmud/Blightmud +capability: TLS | yes | https://github.com/Blightmud/Blightmud +capability: UTF-8 | yes | https://github.com/Blightmud/Blightmud +capability: MCCP | yes | https://github.com/Blightmud/Blightmud +capability: GMCP | yes | https://github.com/Blightmud/Blightmud +capability: MSDP | yes | https://github.com/Blightmud/Blightmud +capability: ATCP | unknown | +capability: MXP | unknown | +capability: MSP | unknown | +capability: scripting | yes | https://github.com/Blightmud/Blightmud +see-also: clients/tintin +see-also: clients/mudlet +see-also: protocols/ttype +--- + +Blightmud ist ein Terminal-Client in Rust, GPL 3, und einer der am aktivsten veröffentlichten +Clients in diesem Abschnitt. Skripting ist Lua. Er läuft nur im Terminal: Es gibt keinen nativen +Windows-Build, und Windows-Nutzer betreiben ihn unter WSL. + +## Barrierefreiheit + +Blightmud hat hier drei verschiedene Stücke, was mehr ist, als eine einzelne Zeile tragen kann: + +- Einen **screenreader-freundlichen Modus** (`--reader-mode` oder die Einstellung `reader_mode`), + der die Terminal-Oberfläche in etwas verwandelt, dem ein Screenreader folgen kann. Den + Statusbereich unterstützt er nicht. +- **Eingebaute Sprachausgabe**, als optionale Kompilierung, mit einer Lua-API, die ein Skript + benutzen kann — darunter ein `tts.gag()`, um eine passende Zeile vom Vorlesen auszunehmen. Die + Dokumentation ist offen darin, dass die eigene Sprachausgabe zusammen mit einem Screenreader nicht + immer eine glückliche Verbindung ist. +- **Automatische MTTS-Ankündigung**: Im Reader-Modus oder bei aktivierter Sprachausgabe fügt er + `MTTS_SCREEN_READER` zu dem hinzu, was er dem Server über sich selbst mitteilt, damit ein Spiel, + dem das wichtig ist, sich anpassen kann. + +Wie bei TinTin++ wird kein bestimmter Screenreader genannt, das ist also ein dokumentierter Modus +und keine geprüfte Verträglichkeit mit einem Produkt. + +## Wo die Tabelle unbekannt sagt + +**MXP**, **MSP** und **ATCP** kommen weder in der README des Projekts noch in seiner mitgelieferten +Hilfe vor. **MCCP** ist als v2 dokumentiert; ob auch v1 behandelt wird, haben wir nicht +festgestellt. diff --git a/content/reference/de/client-mudlet.md b/content/reference/de/client-mudlet.md new file mode 100644 index 0000000..542bc5a --- /dev/null +++ b/content/reference/de/client-mudlet.md @@ -0,0 +1,57 @@ +--- +kind: client +slug: mudlet +title: Mudlet +summary: Plattformübergreifend, mit Lua skriptbar und der Client mit der am gründlichsten dokumentierten Screenreader-Unterstützung in diesem Abschnitt. +home: https://www.mudlet.org/ +platform: Windows +platform: macOS +platform: Linux +capability: screen reader | yes | https://wiki.mudlet.org/w/Manual:Screen_Readers +capability: TLS | yes | https://wiki.mudlet.org/w/Manual:Supported_Protocols +capability: UTF-8 | yes | https://wiki.mudlet.org/w/Manual:Supported_Protocols +capability: MCCP | unknown | +capability: GMCP | yes | https://wiki.mudlet.org/w/Manual:Supported_Protocols +capability: MSDP | yes | https://wiki.mudlet.org/w/Manual:Supported_Protocols +capability: ATCP | yes | https://wiki.mudlet.org/w/Manual:Supported_Protocols +capability: MXP | yes | https://wiki.mudlet.org/w/Manual:Supported_Protocols +capability: MSP | yes | https://wiki.mudlet.org/w/Manual:Supported_Protocols +capability: scripting | yes | https://github.com/Mudlet/Mudlet +see-also: clients/blightmud +see-also: clients/tintin +see-also: protocols/gmcp +see-also: connecting +--- + +Mudlet ist ein grafischer Client mit Kartenwerkzeug, einem Paketsystem und einer Lua-API, gegen die +der größte Teil seines eigenen Funktionsumfangs geschrieben ist. Er steht unter der GPL, wird aktiv +veröffentlicht und ist die übliche Empfehlung für alle, die mit einem modernen Kampf-MUD anfangen. + +## Barrierefreiheit + +Das ist der Client mit dem stärksten dokumentierten Fall in diesem Abschnitt, und es lohnt sich +auszubuchstabieren, was „dokumentiert“ hier heißt, denn es ist ungewöhnlich. + +Mudlet hat ein **Handbuchkapitel zu Screenreadern**, Seiten je Betriebssystem, die Narrator, NVDA +und JAWS unter Windows, Orca unter Linux und VoiceOver unter macOS nennen, einen Befehl +`mudlet access on` im Client und eine Option, eingehenden Spieltext über den Screenreader ansagen zu +lassen. Es gibt außerdem eine Einstellung, die dem Server über MTTS die Nutzung eines Screenreaders +mitteilt, damit ein Spiel sich anpassen kann, wenn es will. + +Es ist auch offen darin, wo es nicht gut funktioniert: Die eigene Windows-Seite sagt, dass JAWS das +Ausgabefenster nicht so vorliest wie andere Screenreader, und empfiehlt stattdessen Narrator oder +NVDA. Ein Projekt, das den Fall veröffentlicht, in dem seine Unterstützung für Barrierefreiheit +untauglich ist, gibt Ihnen bessere Auskunft als eines, das ein Häkchen veröffentlicht. + +## Wo die Tabelle unbekannt sagt + +**MCCP.** Mudlets Quelltext implementiert MCCP v1 und v2, aber die Seite des Handbuchs zu den +unterstützten Protokollen führt es nicht auf, und die Regel dieses Abschnitts lautet, dass eine +Fähigkeitsaussage die Dokumentation des Projekts selbst zitiert. Eine Konstante aus einer +Header-Datei zu lesen ist nicht derselbe Vorgang, also sagt die Zelle unbekannt. + +## Hinweis zur Kodierung + +Mudlets voreingestellte Kodierung für Serverdaten ist ASCII und nicht UTF-8, und die +CHARSET-Aushandlung kam in 4.10 hinzu. Wenn der Text eines Spiels in einem frischen Profil falsch +herauskommt, ist diese Einstellung die erste Stelle, an der man nachsieht. diff --git a/content/reference/de/client-mushclient.md b/content/reference/de/client-mushclient.md new file mode 100644 index 0000000..242f8a1 --- /dev/null +++ b/content/reference/de/client-mushclient.md @@ -0,0 +1,53 @@ +--- +kind: client +slug: mushclient +title: MUSHclient +summary: Der seit Langem etablierte Windows-Client. Fünf Skriptsprachen, eine Plugin-Architektur, in der der Großteil seiner Protokollunterstützung lebt, und eine Veröffentlichungsgeschichte, die sich verlangsamt hat. +home: https://www.mushclient.com/ +platform: Windows +platform: Linux (Wine) +capability: screen reader | unknown | +capability: TLS | unknown | +capability: UTF-8 | unknown | +capability: MCCP | yes | https://www.mushclient.com/mushclient/mccp.htm +capability: GMCP | unknown | +capability: MSDP | unknown | +capability: ATCP | yes | https://www.mushclient.com/gmcp +capability: MXP | yes | https://www.mushclient.com/mushclient/doc/general/features.html +capability: MSP | yes | https://github.com/nickgammon/mushclient/blob/master/plugins/msp.xml +capability: scripting | yes | https://www.mushclient.com/mushclient/doc/general/features.html +see-also: clients/mudlet +see-also: clients/potato +see-also: protocols/mccp +--- + +MUSHclient ist Nick Gammons Windows-Client, MIT-lizenziert, und über eine lange Strecke die +Standardantwort für alle unter Windows. Er skriptet in Lua, VBScript, JScript, PerlScript und +Python, und vieles von dem, was er tut, tragen Plugins statt des Kerns — was eine echte +architektonische Entscheidung ist und zugleich der Grund, warum sich mehrere Zeilen oben schwerer +beantworten lassen, als sie aussehen. + +Die letzte getaggte Veröffentlichung ist **5.06 vom März 2019**. Ins Repository wird weiterhin +committet, und es gibt Release Notes für ein 5.07, das nicht ausgeliefert wurde. + +## Warum so viele Zeilen unbekannt sagen + +Bei jeder einzelnen davon lautet die ehrliche Antwort „wir konnten es nicht feststellen“, und die +Gründe sind verschieden: + +- **GMCP** — die projekteigene Seite dazu zeigt ein *Beispiel*-Plugin, das man schreiben könnte, + keine Funktion, die der Client hat. Das ist etwas anderes als ausgelieferte Unterstützung, also + ist die Zelle unbekannt statt ja. +- **TLS** — der dokumentierte Weg ist ein externer `stunnel`-Prozess. Ein Commit, der TLS über + OpenSSL hinzufügt, ist 2026 im master-Branch gelandet und in keiner Veröffentlichung enthalten, es + gibt also nichts, was heute jemand installieren könnte und worauf wir zeigen könnten. +- **UTF-8** — die CHARSET-Aushandlung taucht in den Notizen zum unveröffentlichten 5.07 auf und + nirgends, wo wir sie in der Dokumentation einer ausgelieferten Version hätten finden können. +- **MSDP** — nichts in die eine oder andere Richtung. +- **Screenreader** — ein Plugin für Sprachausgabe über Windows SAPI wird mit dem Client + ausgeliefert, und das ist nicht dasselbe wie Screenreader-Unterstützung. Im Handbuch gibt es + keinen Abschnitt zur Barrierefreiheit, und der Autor hat in seinem eigenen Forum beschrieben, + warum das Ausgabefenster für einen Screenreader schwer zu handhaben ist: Es kennt keine aktuelle + Zeile. Wir konnten keine Antwort feststellen, also gibt die Tabelle keine. + +Keines davon ist ein *Nein*. Mehrere sind vermutlich ein Ja, und wir konnten es nicht zeigen. diff --git a/content/reference/de/client-potato.md b/content/reference/de/client-potato.md new file mode 100644 index 0000000..da043d5 --- /dev/null +++ b/content/reference/de/client-potato.md @@ -0,0 +1,48 @@ +--- +kind: client +slug: potato +title: Potato MUSHclient +summary: Ein plattformübergreifender Tcl/Tk-Client, geschrieben für MUSH-Spieler. Gute Unterstützung für Kodierungen und eine Dokumentation, die zu den meisten Protokollen überhaupt nichts sagt. +home: https://www.potatomushclient.com/ +platform: Windows +platform: Linux +platform: macOS +capability: screen reader | unknown | +capability: TLS | yes | https://github.com/potatomushclient/potato/wiki/ConfigureWorldsBasics +capability: UTF-8 | yes | https://github.com/potatomushclient/potato/wiki/Features +capability: MCCP | unknown | +capability: GMCP | unknown | +capability: MSDP | unknown | +capability: ATCP | unknown | +capability: MXP | unknown | +capability: MSP | unknown | +capability: scripting | yes | https://github.com/potatomushclient/potato/wiki/FAQs +see-also: clients/beipmu +see-also: clients/mushclient +see-also: collaborative-roleplay +--- + +Potato ist ein Tcl/Tk-Client für das MUSH-Spiel — mehrere Welten, Spawn-Fenster und ein Satz von +Voreinstellungen, die davon ausgehen, dass Sie Posen tippen und keine Kampfbefehle. Er läuft aus +derselben Quelle unter Windows, Linux und macOS, wobei die macOS-Builds meist ein bis zwei Versionen +zurückliegen. + +Er handelt die Zeichenkodierung aus und spricht volles Unicode, was für die MUSH-Seite des Hobbys +die Fähigkeit ist, auf die es in der Praxis am meisten ankommt. + +Beachten Sie eine dokumentierte Einschränkung: Er unterstützt die Verbindung zu einem Port, der von +Beginn an SSL spricht, und seine eigene Konfigurationsseite sagt, dass ausgehandeltes SSL nach Art +von STARTTLS **nicht** unterstützt wird. + +## Warum sechs Zeilen unbekannt sagen + +Wir haben die Startseite des Projekts, seine Downloadseite, alle 103 Hilfedateien seines Wikis und +seinen gesamten Quellbaum nach GMCP, MSDP, MCCP, MXP, MSP und ATCP durchsucht. Zu keinem davon gibt +es eine dokumentierte Aussage. Es gibt *Code*, der einige davon berührt, und dieser Abschnitt macht +aus Code keine Fähigkeitsaussage — eine Tabelle, die auf Grundlage einer Konstante in einer +Header-Datei „ja“ sagt, gibt ein Versprechen ab, das das Projekt nie gegeben hat. + +Die Screenreader-Zeile ist dieselbe Antwort, auf demselben Weg erreicht: Eine Suche ohne Beachtung +der Groß- und Kleinschreibung nach „screen reader“, „text-to-speech“, NVDA, JAWS, VoiceOver, +„accessibility“, „visually impaired“ und „blind“ über alles, was das Projekt veröffentlicht, ergab +überhaupt nichts. Das ist kein Befund über die Software. diff --git a/content/reference/de/client-tintin.md b/content/reference/de/client-tintin.md new file mode 100644 index 0000000..f1f886a --- /dev/null +++ b/content/reference/de/client-tintin.md @@ -0,0 +1,53 @@ +--- +kind: client +slug: tintin +title: TinTin++ +summary: Ein Terminal-Client mit eigener Skriptsprache, auf jeder Plattform einschließlich Telefonen, und mit einem dokumentierten Screenreader-Modus. +home: https://tintin.mudhalla.net/ +platform: Linux +platform: macOS +platform: Windows +platform: Android +platform: iOS +capability: screen reader | yes | https://tintin.mudhalla.net/manual/screen_reader.php +capability: TLS | yes | https://github.com/scandum/tintin +capability: UTF-8 | yes | https://github.com/scandum/tintin +capability: MCCP | yes | https://tintin.mudhalla.net/ +capability: GMCP | yes | https://tintin.mudhalla.net/manual/event.php +capability: MSDP | yes | https://tintin.mudhalla.net/manual/msdp.php +capability: ATCP | unknown | +capability: MXP | unknown | +capability: MSP | unknown | +capability: scripting | yes | https://github.com/scandum/tintin +see-also: clients/blightmud +see-also: clients/mudlet +see-also: protocols/msdp +see-also: protocols/ttype +--- + +TinTin++ ist ein Kommandozeilen-Client, GPL 3, aktiv veröffentlicht, und er läuft an mehr Orten als +alles andere hier — einschließlich Android und iOS. Seine Skriptsprache ist seine eigene, knapp und +zu sehr viel fähig; ein erheblicher Teil dessen, was andere Clients in der Oberfläche machen, ist +hier eine `#config`-Zeile. + +Derselbe Autor pflegt die Protokollspezifikationen für **MSSP** und **MSDP**, weshalb so viele der +Protokollseiten in diesem Abschnitt dieselbe Website zitieren. + +## Barrierefreiheit + +TinTin++ hat eine eigene Handbuchseite zum **Screenreader-Modus** (`#config screen reader on` oder +`-s` beim Start). Ihn einzuschalten bewirkt zweierlei: Es entfernt oder verändert visuelle Elemente, +die vorgelesen keinen Sinn ergeben, und es meldet dem Server die Nutzung eines Screenreaders über +[MTTS](/reference/protocols/ttype), damit ein Spiel seine eigene Ausgabe anpassen kann. + +Das ist ein dokumentierter Modus und keine Aussage über einen Test mit einem bestimmten Screenreader +— auf der Seite wird kein Produkt genannt. Das ist deutlich schwächer als ein Client, der die +Screenreader benennt, mit denen er funktioniert, und deutlich stärker als nichts. + +## Wo die Tabelle unbekannt sagt + +Zu **MXP** und **MSP** gibt es beide Male Community-Skripte auf der Website des Projekts, und ein +Skript ist nicht dasselbe wie ein Client, der ein Protokoll unterstützt — das MXP-Skript sagt +rundheraus, dass es womöglich nicht auf jedem MUD funktioniert. Native Unterstützung für eines von +beiden ließ sich nicht feststellen. Zu **ATCP** haben wir weder das eine noch das andere gefunden; +zu beachten ist, dass ATCP weitgehend von GMCP abgelöst ist, das TinTin++ sehr wohl unterstützt. diff --git a/content/reference/de/client-tinyfugue.md b/content/reference/de/client-tinyfugue.md new file mode 100644 index 0000000..49c98d3 --- /dev/null +++ b/content/reference/de/client-tinyfugue.md @@ -0,0 +1,46 @@ +--- +kind: client +slug: tinyfugue +title: TinyFugue +summary: Der klassische UNIX-Terminal-Client. Upstream hat seit 2007 nichts mehr veröffentlicht; ein gepflegter Fork führt ihn weiter. +home: https://tinyfugue.sourceforge.net/ +platform: Linux +platform: macOS +platform: BSD +capability: screen reader | unknown | +capability: TLS | yes | https://tinyfugue.sourceforge.net/ +capability: UTF-8 | unknown | +capability: MCCP | yes | https://tinyfugue.sourceforge.net/ +capability: GMCP | unknown | +capability: MSDP | unknown | +capability: ATCP | unknown | +capability: MXP | unknown | +capability: MSP | unknown | +capability: scripting | yes | https://tinyfugue.sourceforge.net/ +see-also: clients/tintin +see-also: clients/blightmud +--- + +TinyFugue — „tf“ — ist der Terminal-Client, den ein großer Teil der MUSH-Welt zwei Jahrzehnte lang +benutzt hat, mit getrennten Bereichen für Eingabe und Ausgabe, einer eigenen Makrosprache und einem +Satz von Gewohnheiten, die mehrere seiner Konkurrenten überlebt haben. + +**Upstream ruht**: Die letzte Veröffentlichung ist 5.0 Beta 8 vom Januar 2007. Es baut noch immer, +und es funktioniert noch immer. + +Ein gepflegter Fork, *TinyFugue Rebirth*, wird aktiv veröffentlicht und ergänzt GMCP, ATCP, +Unterstützung für Breitzeichen über ICU sowie Python- und Lua-Skripting neben der eigenen +Makrosprache. Die Tabelle oben beschreibt **Upstream**, denn dorthin führt „TinyFugue“; wenn Sie +heute etwas installieren, lohnt sich zuerst ein Blick auf den Fork. + +## Die Falle in der Dokumentation dieses Clients + +Upstream hat ein Dokumentationsthema namens **„non-visual mode“**. Darin geht es nicht um +Hilfstechnik — es geht darum, die Eingabe auf die unterste Zeile zu beschränken —, und es erwähnt +nirgends einen Screenreader, keine Sprachausgabe und keine blinden Nutzer. Eine Fähigkeitstabelle, +die per Stichwortsuche zusammengestellt wird, würde aus diesem Dateinamen ein Ja machen. Diese hier +sagt unbekannt, denn das ist es, was die Dokumentation hergibt. + +UTF-8 ist eine Antwort derselben Form: Die dokumentierte Unterstützung für Kodierungen betrifft +8-Bit-Zeichensätze nach ISO 8859, und wir haben von Upstream keine Aussage zu UTF-8 gefunden, weder +in die eine noch in die andere Richtung. diff --git a/content/reference/de/client-vipmud.md b/content/reference/de/client-vipmud.md new file mode 100644 index 0000000..29db37a --- /dev/null +++ b/content/reference/de/client-vipmud.md @@ -0,0 +1,44 @@ +--- +kind: client +slug: vipmud +title: VIP Mud +summary: Ein kommerzieller Windows-Client, von Grund auf für blinde Spieler gebaut. Er nennt sieben Screenreader — und veröffentlicht fast nichts über seine Protokollunterstützung. +home: https://www.gmagames.com/vipmud.shtml +platform: Windows +capability: screen reader | yes | https://www.gmagames.com/vipmud.shtml +capability: TLS | unknown | +capability: UTF-8 | unknown | +capability: MCCP | unknown | +capability: GMCP | unknown | +capability: MSDP | unknown | +capability: ATCP | unknown | +capability: MXP | unknown | +capability: MSP | yes | https://www.gmagames.com/vipmud.shtml +capability: scripting | yes | https://www.gmagames.com/vipmud.shtml +see-also: clients/mudlet +see-also: clients/blightmud +--- + +VIP Mud ist der eine Client in diesem Abschnitt, dessen *gesamte* Entwurfsprämisse Barrierefreiheit +ist. Er ist kommerziell — dreißig Dollar, mit einer dreißigtägigen Vollversion zum Testen, nach der +er mit eingeschränktem Funktionsumfang weiterläuft — und er ist ein Windows-Programm. + +Er ist mit Abstand die stärkste Aussage zur Barrierefreiheit hier, und ungewöhnlicherweise ist sie +konkret. Die Produktseite nennt **JAWS, Window-Eyes, System Access, NVDA, Cobra, SuperNova/Hal und +Microsoft SAPI** als von Haus aus funktionierend und beschreibt Funktionen, die nur Sinn ergeben, +wenn man scharf über das Problem nachgedacht hat: verschiedene Stimmen je Fenster und je Art der +Ausgabe, das Ausblenden von Spam aus der Sprachausgabe bei weiterhin sichtbarem Text und mehrere +Methoden, ASCII-Kunst zu unterdrücken — das Feindseligste, was ein MUD einem Screenreader schicken +kann. + +## Warum der Rest der Tabelle leer ist + +Weil der Anbieter eine Werbeseite veröffentlicht und kein Handbuch. Nichts darauf erwähnt GMCP, +MSDP, MCCP, MXP, ATCP, TLS oder Zeichenkodierung; es beschreibt das Produkt als „a Telnet-based +client“ und belässt es dabei. **Neun Unbekannt in Folge sind kein Urteil über die Software.** So +sieht eine Matrix aus, wenn die einzige verfügbare Quelle eine einzige Seite ist, und sie als neun +Nein zu veröffentlichen wäre eine Lüge über ein Produkt, das durchaus alles davon können mag. + +Zwei weitere Dinge, die wir nicht feststellen konnten: irgendein Veröffentlichungsdatum der +aktuellen Version, und ob sie noch aktiv entwickelt wird — der Anbieter wurde im Februar 2025 +übernommen, und die Produktseite trägt ein Copyright von 2016. diff --git a/content/reference/de/codebase-aresmush.md b/content/reference/de/codebase-aresmush.md new file mode 100644 index 0000000..91a72db --- /dev/null +++ b/content/reference/de/codebase-aresmush.md @@ -0,0 +1,34 @@ +--- +kind: codebase +slug: aresmush +title: AresMUSH +summary: Ein moderner Rollenspiel-Server in Ruby, mit Web-Oberfläche und eingebauten Szenen-Werkzeugen statt Softcode. +codebase: AresMUSH +home: https://aresmush.com/ +see-also: collaborative-roleplay +see-also: codebases/pennmush +see-also: codebases/evennia +--- + +AresMUSH ist der neueste weit verbreitete Server, der ausdrücklich auf **gemeinsames Rollenspiel** +zielt, und er bezieht eine andere Position als die TinyMUSH-Linie, deren Nachfolge er antritt. Wo +ein PennMUSH-Spiel sein Szenensystem, seine Charakterbögen und seine Job-Warteschlange aus Softcode +baut, den geschrieben hat, wer gerade da war, liefert Ares all das als fertige Funktionen mit und +erwartet von der Spielleitung, sie zu konfigurieren statt sie zu programmieren. + +Es bringt ein **Web-Portal** mit — Charakter-Wikis, Szenen-Logs, Foren und das Spiel selbst, alles +aus einem Browser erreichbar —, was für ein Genre, in dem die Logs hinterher gelesen werden, ein +Unterschied der Art und nicht bloß des Grades ist. + +Konfiguriert wird in YAML; Erweiterungen sind Ruby-Plugins. Für Spieler gibt es keine +Programmiersprache im Spiel, und das ist der Handel: weniger Strick, weniger Unfälle mit dem Strick +und weniger von jener improvisierenden Baukultur, nach der die MUSH-Linie benannt ist. + +## Wie es von außen aussieht + +Kein MSSP. Es beantwortet ein `WHO` vor der Anmeldung, und die Antwort ist eine **Liste pro +Spieler** statt einer nackten Zahl; unser Parser zählt sie anhand ihrer Struktur. Auf dem Spiel, das +wir gemessen haben, wurden keine Telnet-Optionen ausgehandelt. + +Wenn Sie für ein neues Rollenspiel zwischen diesem und PennMUSH wählen, lautet die Frage ungefähr, +ob Sie ein System wollen, das Sie konfigurieren, oder eines, das Sie schreiben. diff --git a/content/reference/de/codebase-circlemud.md b/content/reference/de/codebase-circlemud.md new file mode 100644 index 0000000..9b6ae85 --- /dev/null +++ b/content/reference/de/codebase-circlemud.md @@ -0,0 +1,27 @@ +--- +kind: codebase +slug: circlemud +title: CircleMUD +summary: Die DikuMUD-Ableitung, die gut genug dokumentiert war, um damit zu unterrichten. Ungeheuer oft geforkt; läuft immer noch. +codebase: CircleMUD +home: https://www.circlemud.org/ +see-also: codebases/dikumud +see-also: codebases/tbamud +see-also: codebases/rom +--- + +CircleMUD ist eine DikuMUD-Ableitung, deren hervorstechendes Merkmal nie eine Spielmechanik war, +sondern die **Dokumentation**. Jeremy Elsons Veröffentlichung war sauber, kommentiert und von einem +Programmierleitfaden begleitet, und so wurde sie die Codebase, an der Leute C lernten, aus der sie +einen MUD betrieben und die sie forkten, ohne vorher irgendetwas rückentwickeln zu müssen. + +Die Folge ist, dass sehr viele laufende Spiele um mehrere Generationen entfernte Circle-Ableitungen +sind, oft ohne dass das Wort irgendwo auftaucht, wo ein Spieler es zu sehen bekäme. + +Die Entwicklung von Circle selbst ist längst abgeschlossen; **tbaMUD** ist die Fortsetzung, und ein +Circle-Spiel, das heute gepflegt wird, wird meist als tbaMUD gepflegt. + +## Wie es von außen aussieht + +MSSP, auf Anfrage beantwortet. Kein `WHO` am Anmeldebildschirm — die Diku-Familie bietet in der +Regel keines an, weshalb ein Verzeichnis, das nur Anmeldebildschirme liest, hier nichts sieht. diff --git a/content/reference/de/codebase-cobramush.md b/content/reference/de/codebase-cobramush.md new file mode 100644 index 0000000..1f937f1 --- /dev/null +++ b/content/reference/de/codebase-cobramush.md @@ -0,0 +1,26 @@ +--- +kind: codebase +slug: cobramush +title: CobraMUSH +summary: Ein PennMUSH-Fork mit eigenem Divisions- und Befugnismodell. Geringe Verbreitung, antwortet weiterhin. +codebase: CobraMUSH +home: https://cobramush.org/ +see-also: codebases/pennmush +see-also: codebases/rhostmush +--- + +CobraMUSH ist ein Fork von PennMUSH und hat ein *Division*-Modell hinzugefügt — eine Hierarchie +administrativer Autorität mit delegierbaren Befugnissen anstelle der flachen Unterscheidung zwischen +wizard und royalty, die das Elternprojekt verwendet. Seine Klientel sind Spiele, die Teile der +Leitungsbefugnis abgeben wollen, ohne alles abzugeben. + +Für PennMUSH geschriebener Softcode läuft größtenteils, und die Unterschiede ballen sich genau in +dem Bereich, um den es beim Fork ging. + +## Wie es von außen aussieht + +Kein MSSP, ein funktionierendes `WHO` vor der Anmeldung und überhaupt keine ausgehandelten +Telnet-Optionen auf dem Spiel, das wir gemessen haben. Der letzte Punkt ist kein Vorwurf: Ein +Server, der nichts aushandelt, ist ein Server, der beim Aushandeln nichts falsch machen kann, und +einfacher Text über einen einfachen Socket ist genau das, womit jeder Client in diesem Hobby +umgeht. diff --git a/content/reference/de/codebase-coffeemud.md b/content/reference/de/codebase-coffeemud.md new file mode 100644 index 0000000..d6cb247 --- /dev/null +++ b/content/reference/de/codebase-coffeemud.md @@ -0,0 +1,29 @@ +--- +kind: codebase +slug: coffeemud +title: CoffeeMUD +summary: Ein MUD-Server in Java, mit dem größten MSSP-Bericht von allem, was wir abgefragt haben, und einer ungewöhnlich breiten Protokollfläche. +codebase: CoffeeMUD +home: https://www.coffeemud.net/ +see-also: codebases/dikumud +see-also: protocols/mssp +--- + +CoffeeMUD ist ein MUD-Server in Java mit ungewöhnlich breitem Funktionsumfang — er bringt einen +eigenen Webserver, Mail, Foren sowie ein großes Klassen- und Fertigkeitensystem mit und ist einer +der wenigen Server im Hobby, die nicht in C geschrieben sind. + +Er wird aktiv gepflegt, was nach den Maßstäben dieses Teils des Katalogs der ausdrücklichen +Erwähnung wert ist. + +## Wie es von außen aussieht + +MSSP und **MCCP2**, und CoffeeMUD ist einer von nur drei Servern unter zwanzig, die wir probiert +haben, der auch die *Klartext*-Form `MSSP-REQUEST` beantwortet hat — eine Variante, die älter ist +als die Telnet-Option und immer noch gelegentlich vorkommt. + +Sein MSSP-Bericht ist der größte, den wir gemessen haben: **47 Felder**, darunter `PORT`, neunmal +getrennt gemeldet für neun getrennte Ports. Das ist keine Fehlbildung. MSSP-Variablen sind Listen, +und ein Crawler, der ein mehrwertiges `PORT` zu einer einzigen Zeichenkette plattdrückt, macht aus +`"80" "23" "4201"` die ganze Zahl `80234201` — ein Fehler, den dieses Projekt ausgeliefert und +behoben hat, und der Grund, warum der Parser hier Werte durchgehend als Listen behält. diff --git a/content/reference/de/codebase-dikumud.md b/content/reference/de/codebase-dikumud.md new file mode 100644 index 0000000..50eb632 --- /dev/null +++ b/content/reference/de/codebase-dikumud.md @@ -0,0 +1,38 @@ +--- +kind: codebase +slug: dikumud +title: DikuMUD +summary: Die Wurzel der Kampf-MUD-Familie. Level, Klassen, Ausrüstung und Area-Dateien — und eine Lizenz, die eine ganze Generation von Ableitungen geprägt hat. +codebase: DikuMUD +home: https://dikumud.com/ +see-also: codebases/circlemud +see-also: codebases/rom +see-also: codebases/smaug +see-also: mush-mud-muck-moo +--- + +DikuMUD, geschrieben am Datalogisk Institut der Universität Kopenhagen und 1991 veröffentlicht, ist +der Vorfahr des meisten von dem, was gemeint ist, wenn jemand ohne nähere Bestimmung „MUD“ sagt. +Level, Charakterklassen, Trefferpunkte, Mobs, Ausrüstungsplätze, ein Area-Dateiformat, das ein +Builder offline schreibt — das ganze Vokabular kommt von hier, und Spiele, die nie Diku-Quelltext +gesehen haben, erben trotzdem seine Form. + +Seine Lizenz gehört zur Geschichte. Diku war frei nutzbar, verbot aber, Geld für den Zugang zu +verlangen, und verlangte, die ursprünglichen Credits anzuzeigen; diese Klausel ist der Grund, warum +„die Diku-Credits“ auf dem Anmeldebildschirm von Spielen stehen, die mehrere Forks von ihm entfernt +sind. + +Die direkten Nachfahren — **Merc**, dann **ROM**, **CircleMUD**, **SMAUG**, **tbaMUD** und Dutzende +weitere — machen einen großen Teil jedes MUD-Verzeichnisses aus, das je existiert hat. + +## Wie es von außen aussieht + +Die Diku-Familie ist die **MSSP**-Familie. Während die MUSH-Seite eine Zählung über ein `WHO` am +Anmeldebildschirm veröffentlicht und überhaupt kein MSSP anbietet, beantworten Server der Diku-Linie +ganz überwiegend die Telnet-Option 70 mit einem strukturierten Bericht, und daher stammen ihre +Zahlen hier. + +**MCCP2** — Stromkompression — ist in dieser Familie ebenfalls verbreitet, und es lohnt sich zu +wissen, dass ein Client, der sie aushandelt, den Strom aber nicht entpacken kann, den gesamten +Verbindungsbildschirm als binäres Rauschen erhält. Das war ein echter Defekt in der eigenen +Telnet-Bibliothek dieses Projekts und ist behoben; siehe [MCCP](/reference/protocols/mccp). diff --git a/content/reference/de/codebase-evennia.md b/content/reference/de/codebase-evennia.md new file mode 100644 index 0000000..8fdc559 --- /dev/null +++ b/content/reference/de/codebase-evennia.md @@ -0,0 +1,34 @@ +--- +kind: codebase +slug: evennia +title: Evennia +summary: Eher ein Python-Framework als ein fertiges Spiel. Zwei Evennia-Spiele können außer dem Unterbau nichts gemeinsam haben. +codebase: Evennia +home: https://www.evennia.com/ +see-also: codebases/aresmush +see-also: collaborative-roleplay +see-also: protocols/gmcp +--- + +Evennia ist ein **MU\*-Framework**, kein Spiel — das ist das Erste, was man darüber wissen muss, und +das ist es, was den Vergleich von Evennia-Spielen untereinander wenig hilfreich macht. Es ist eine +Python-Bibliothek auf Basis von Django und Twisted, die Ihnen Accounts, Objekte, Räume, Befehle, +eine Persistenzschicht und den Netzwerk-Stack gibt und dann erwartet, dass Sie das Spiel schreiben. + +Die Folge ist, dass „läuft auf Evennia“ weit weniger über ein Spiel aussagt als „läuft auf +PennMUSH“. Es gibt Kampf-MUDs auf Evennia und es gibt Rollenspiele auf Evennia, und sie teilen kein +Vokabular. Zwei Evennia-Spiele haben womöglich keinen einzigen Befehl gemeinsam. + +Wer Python bereits kennt, hat hier den kürzesten Weg von nichts zu einer laufenden Welt, und dort +hat ein guter Teil der neuen Spiele seit Mitte der 2010er begonnen. + +## Wie es von außen aussieht + +Evennia bietet **MSSP** an und veröffentlicht darüber eine Spielerzahl. Auf dem Spiel, das wir +gemessen haben, hat es außerdem **MCCP2** ausgehandelt — Kompression —, was für einen Stack +charakteristisch ist, der sein Telnet ernst genommen hat. + +Weil Evennia ein Framework ist, ist das, was ein bestimmtes Spiel aushandelt, zum Teil die +Entscheidung des Spiels. Die Verbreitungszahlen auf den Protokollseiten zählen, was Server uns +tatsächlich angeboten haben, nicht, was das Framework kann, und bei Evennia liegen diese beiden +weiter auseinander als bei den meisten. diff --git a/content/reference/de/codebase-fluffos.md b/content/reference/de/codebase-fluffos.md new file mode 100644 index 0000000..b3ab6ce --- /dev/null +++ b/content/reference/de/codebase-fluffos.md @@ -0,0 +1,34 @@ +--- +kind: codebase +slug: fluffos +title: FluffOS +summary: Der gepflegte MudOS-Nachfolger und der Driver, auf dem die meisten überlebenden LPMud-Spiele laufen. Das Spiel ist in LPC geschrieben, nicht in C. +codebase: FluffOS +home: https://www.fluffos.info/ +see-also: codebases/dikumud +see-also: mush-mud-muck-moo +--- + +Die LPMud-Tradition teilt die Welt anders auf als Diku. Es gibt einen **Driver** — ein C-Programm, +das einen objektorientierten Interpreter ausführt — und eine **Mudlib**, die das gesamte Spiel ist, +in **LPC** geschrieben und vom Driver geladen. Räume, Kampf, Befehle und der Anmeldeablauf sind +allesamt Mudlib-Objekte; der Driver weiß von keinem davon. + +Das rückt ein LPMud dem Geist nach näher an ein MUSH heran, als seine Kampfsysteme vermuten lassen: +Das Spiel ist in einer Sprache geschrieben, die im Spiel selbst lebt, und zwei LPMuds mit demselben +Driver teilen möglicherweise sonst nichts. + +**MudOS** war jahrelang der vorherrschende Driver; **FluffOS** ist seine gepflegte Fortsetzung und +das, worauf ein laufendes LP-Spiel heute am ehesten läuft. Bekannte Mudlibs — Nightmare, Lima, +Discworlds eigene — sind wiederum eigene Projekte. + +## Wie es von außen aussieht + +MSSP und **MCCP2** auf dem FluffOS-Spiel, das wir gemessen haben. MudOS war eine von nur zwei +Codebases in unserer Erhebung, die *sowohl* MSSP als auch ein `WHO` am Anmeldebildschirm beantwortet +haben, wobei das `WHO` allerdings eine Auflistung pro Spieler statt einer Zählung lieferte. + +Weil die Mudlib das Spiel ist, ist das, was ein bestimmtes LP-Spiel aushandelt, ebenso sehr eine +Entscheidung der Mudlib wie eine des Drivers — die Verbreitungszahlen auf den Protokollseiten +zählen, was Server uns tatsächlich angeboten haben, was für diese Familie ein schwächeres Signal +über die Codebase ist als anderswo. diff --git a/content/reference/de/codebase-moo.md b/content/reference/de/codebase-moo.md new file mode 100644 index 0000000..d06f387 --- /dev/null +++ b/content/reference/de/codebase-moo.md @@ -0,0 +1,30 @@ +--- +kind: codebase +slug: moo +title: MOO +summary: Objektorientiert, vollständig von innen heraus bearbeitet und ebenso sehr Forschungs- und Lehrplattform wie Spiel-Engine. +codebase: MOO +home: https://www.ipomoea.org/moo/ +see-also: mush-mud-muck-moo +see-also: codebases/muck +--- + +MOO — *MUD, Object-Oriented* — treibt die Idee, dass die Welt sich selbst bearbeitet, weiter als +alles andere in diesem Hobby. LambdaMOO, der ursprüngliche Server, liefert einen kleinen C-Kern und +eine Datenbank; im Grunde alles, was man dort erlebt, ist **in der MOO-Sprache, innerhalb der +laufenden Datenbank, von den Leuten geschrieben, die sie benutzen**. Für einen Raum gibt es keine +Quelldatei. + +Diese Eigenschaft gab MOOs ein Leben außerhalb von Spielen. In den Neunzigern wurden sie für Lehre, +Konferenzen und Forschung eingesetzt — Diversity University, BioMOO, Jay's House —, und die +technische Literatur über MOO ist für eine Codebase in diesem Feld überproportional akademisch. + +Die Verbreitung ist heute klein, aber wirklich nicht null, und die verbliebenen Server laufen oft +seit Jahrzehnten ununterbrochen. + +## Wie es von außen aussieht + +Kein MSSP, und auf dem Spiel, das wir gemessen haben, kein `WHO`, das wir auswerten konnten. Was es +hatte, war ein Satz in seinem Verbindungsbildschirm: *„one of three players are active“* — daher +stammt der Leser für ausgeschriebene Zahlen in diesem Crawler. Ein Parser, der nur Ziffern kennt, +sieht dort überhaupt keine Zählung und hätte dieses Spiel für immer als unbekannt gemeldet. diff --git a/content/reference/de/codebase-muck.md b/content/reference/de/codebase-muck.md new file mode 100644 index 0000000..37c9560 --- /dev/null +++ b/content/reference/de/codebase-muck.md @@ -0,0 +1,31 @@ +--- +kind: codebase +slug: muck +title: MUCK +summary: Ein TinyMUD-Nachfahre mit einer eigenen Forth-artigen Sprache im Spiel und einer sozialen Kultur, die sich von der MUSH-Seite unterscheidet. +codebase: MUCK +home: https://www.fuzzball.org/ +see-also: mush-mud-muck-moo +see-also: codebases/tinymush +see-also: codebases/moo +--- + +MUCK — in der Praxis fast immer **Fuzzball MUCK** — ist eher ein Geschwister der MUSH-Linie als ihr +Nachfahre: Beide stammen von TinyMUD ab, und beide setzen eine Programmiersprache ins Spiel hinein. + +Die Sprache ist der sichtbare Unterschied. MUF (*Multi-User Forth*) ist stapelbasiert und liest sich +überhaupt nicht wie MUSH-Softcode; wer in der einen geübt ist, ist in der anderen Anfänger. Darüber +sitzt MPI, eine kleinere, eingebettete Ausdruckssprache für das, was auf einem MUSH der Softcode +täte. + +Kulturell ist MUCK die Heimat eines großen Teils der geselligen und der Fandom-Welten dieses Hobbys. +Diese Spiele sind eher um Anwesenheit und Gespräch herum gebaut als um Szenen mit einem Anfang und +einem Ende, was ein echter Unterschied zur Rollenspiel-MUSH-Tradition ist und keine Frage des +Themas. + +## Wie es von außen aussieht + +Kein MSSP. Ein `WHO` vor der Anmeldung, das mit einer Zählung antwortet. Auf dem Spiel, das wir +gemessen haben, wurden keine Telnet-Optionen ausgehandelt — und eine Einzelheit aus der Erhebung ist +festzuhalten: Seine `WHO`-Antwort endete auf ein Leerzeichen ohne Zeilenumbruch, und genau so etwas +bringt einen naiven Parser dazu, gar nichts zu melden. diff --git a/content/reference/de/codebase-pennmush.md b/content/reference/de/codebase-pennmush.md new file mode 100644 index 0000000..ea4db81 --- /dev/null +++ b/content/reference/de/codebase-pennmush.md @@ -0,0 +1,52 @@ +--- +kind: codebase +slug: pennmush +title: PennMUSH +summary: Der am weitesten verbreitete MUSH-Server. Softcode, eine lange Veröffentlichungsgeschichte und eine von nur zwei Codebases in unserer Erhebung, die sowohl MSSP als auch ein WHO vor der Anmeldung beantworten. +codebase: PennMUSH +home: https://www.pennmush.org/ +see-also: codebases/tinymux +see-also: codebases/rhostmush +see-also: codebases/cobramush +see-also: mush-mud-muck-moo +see-also: protocols/mssp +--- + +PennMUSH stammt über einen Fork von 1991 von TinyMUSH ab, und es ist der Server, auf dem die meisten +langlebigen Rollenspiel-MUSHes laufen. Sein bestimmendes Merkmal ist **Softcode**: eine funktionale +Ausdruckssprache, von innerhalb des Spiels bearbeitet von jedem, der das passende Bit gesetzt hat, +und in ihr ist ein großer Teil des Verhaltens jedes einzelnen MUSH geschrieben. Ein PennMUSH-Spiel +wird weniger konfiguriert als von seinen Spielern programmiert. + +Versionen lesen sich als `1.8.8p0` — eine Hauptversion, eine Nebenversion und ein Patchlevel —, und +der Patchlevel bewegt sich oft. Spiele laufen häufig mit einer Version, die mehrere Patchlevel +zurückliegt, was nicht weiter bemerkenswert ist. + +## Wie es von außen aussieht + +PennMUSH ist eine von nur zwei Codebases in unserer eigenen Erhebung über 38 Server, die *beide* +Wege beantwortet hat, die wir abfragen. Es bietet MSSP an, wenn man fragt, und es beantwortet ein +`WHO`, das am Anmeldebildschirm getippt wird, und auf dem Spiel, das wir gemessen haben, stimmten +die beiden überein — was seltener ist, als es klingt, und PennMUSH zu der Kontrolle machte, an der +wir andere Server geprüft haben. + +Das `WHO` vor der Anmeldung ist mehr als eine Bequemlichkeit: Es ist überhaupt die Art, wie die +MUSH-Familie eine Spielerzahl veröffentlicht, denn der größte Teil der übrigen Familie bietet gar +kein MSSP an. Unter [MSSP](/reference/protocols/mssp) steht, warum diese Spaltung der Grund dafür +ist, dass diese Website vier Schichten abfragt statt einer. + +CHARSET-Aushandlung ist auf modernem PennMUSH normal, weshalb Namen mit Akzenten die Reise +überstehen. + +## Verwandte Server + +PennMUSH, **TinyMUX**, **RhostMUSH** und **CobraMUSH** sind vier Server mit gemeinsamem Vorfahren +und gemeinsamem Vokabular — wer einen kennt, kann den Softcode eines anderen mit Mühe lesen. +Kompatibel sind sie nicht: Eine Datenbank wandert nicht ohne Konvertierung von einem zum anderen, +und die Funktionsbibliotheken unterscheiden sich auf Weisen, die zählen. + +## SharpMUSH + +Eine .NET-Neuimplementierung mit dem Ziel der PennMUSH-Kompatibilität ist in Entwicklung, von +demselben Autor wie diese Website. Nichts auf dieser Seite ist an ihr gemessen, und sie hat keine +Spiele im Katalog. diff --git a/content/reference/de/codebase-rhostmush.md b/content/reference/de/codebase-rhostmush.md new file mode 100644 index 0000000..220c344 --- /dev/null +++ b/content/reference/de/codebase-rhostmush.md @@ -0,0 +1,28 @@ +--- +kind: codebase +slug: rhostmush +title: RhostMUSH +summary: Ein MUSH-Server, bekannt für ein tiefes Rechtemodell und einen großen Satz eingebauter Funktionen. Kein MSSP; beantwortet ein WHO vor der Anmeldung. +codebase: RhostMUSH +home: https://github.com/RhostMUSH/trunk +see-also: codebases/pennmush +see-also: codebases/tinymux +see-also: codebases/cobramush +--- + +RhostMUSH ist der vierte der weit verbreiteten, von TinyMUSH abstammenden Server und der mit dem +ausgefeiltesten Verwaltungsmodell: Sein Rechte- und Flag-System ist erheblich feinkörniger als das +seiner Verwandten, und das ist der übliche Grund, aus dem ein Spiel sich für ihn entscheidet. + +Seine Bibliothek eingebauter Funktionen ist groß, und für Rhost geschriebener Softcode lässt sich +oft nicht sauber nach PennMUSH oder TinyMUX übertragen, ohne die Stellen umzuschreiben, die +Funktionen benutzt haben, die es dort nicht gibt. + +## Wie es von außen aussieht + +Kein MSSP. Ein `WHO` vor der Anmeldung, das mit einer Zählung antwortet. CHARSET wird ausgehandelt. + +Diese Kombination — kein MSSP, ein funktionierendes `WHO` — ist die Signatur der MUSH-Familie, und +sie ist der Grund, warum diese Website den Anmeldebildschirm überhaupt abfragt. Nach dem Befund +unserer eigenen Erhebung sind die MSSP- und die `WHO`-Familie nahezu disjunkt: 28 Codebases +veröffentlichen eine Zählung über MSSP, sieben über `WHO` und nur zwei über beides. diff --git a/content/reference/de/codebase-rom.md b/content/reference/de/codebase-rom.md new file mode 100644 index 0000000..f82966f --- /dev/null +++ b/content/reference/de/codebase-rom.md @@ -0,0 +1,32 @@ +--- +kind: codebase +slug: rom +title: ROM +summary: Der bekannteste Nachfahre von Merc und die Kampf-Engine, auf der ein großer Teil der Neunziger-MUDs aufgebaut wurde. +codebase: ROM +see-also: codebases/dikumud +see-also: codebases/smaug +see-also: protocols/mccp +--- + +ROM — *Rivers of MUD* — ist eine Ableitung von **Merc**, das selbst eine DikuMUD-Ableitung ist, und +es ist diejenige, die sich durchgesetzt hat. Sein Kampfmodell, sein Fertigkeits- und Zaubersystem +und sein Area-Format waren der Ausgangspunkt für eine enorme Zahl von Spielen in den Neunzigern und +danach, und besonders ROM 2.4 ist einer der meistgeforkten Quelltexte im Hobby. + +Wie der Rest der Diku-Linie trägt es die ursprüngliche Credits-Auflage mit sich, und so nennt ein +Spiel, dessen Abstammung sich sonst nicht ermitteln lässt, auf seinem Anmeldebildschirm oft Diku, +Merc und ROM. + +## Wie es von außen aussieht + +MSSP, CHARSET und **MCCP2**, auf dem Spiel, das wir gemessen haben. + +ROM ist der Server, an dem dieses Projekt seinen eigenen Kompressionsfehler nachgewiesen hat. Unsere +Abfrage handelte MCCP2 aus, der Server begann korrekt zu komprimieren, und die Telnet-Bibliothek, +auf die wir angewiesen sind, hat den Strom nie entpackt — der Verbindungsbildschirm kam also als +Wand aus Ersatzzeichen an, und wir haben das kurzzeitig als Schuld des Spiels verbucht. Die Nutzlast +ließ sich mit einem gewöhnlichen zlib-Aufruf sauber dekomprimieren, und das machte die Sache +eindeutig. Es wurde upstream behoben; die Geschichte steht auf der Seite +[MCCP](/reference/protocols/mccp), weil sie ein gutes Beispiel für einen Defekt ist, der von außen +genau wie ein kaputtes Spiel aussieht. diff --git a/content/reference/de/codebase-smaug.md b/content/reference/de/codebase-smaug.md new file mode 100644 index 0000000..11022c0 --- /dev/null +++ b/content/reference/de/codebase-smaug.md @@ -0,0 +1,28 @@ +--- +kind: codebase +slug: smaug +title: SMAUG +summary: Eine Merc-Ableitung für große thematische Welten, mit Gottheiten, Ebenen und einem Area-Bestand um ein Mehrfaches größer als der ihres Elternprojekts. +codebase: SMAUG +home: https://www.smaug.org/ +see-also: codebases/dikumud +see-also: codebases/rom +--- + +SMAUG — *Simulated Medieval Adventure multi-User Game* — kam aus dem Realms of Despair und ist eine +Merc-Ableitung, die gewachsen ist statt vereinfacht. Wo ROM Mercs Kampf gestrafft hat, hat SMAUG +hinzugefügt: Gottheiten, Ebenen, ausgefeiltere Area- und Mob-Programme, eine viel größere +Standardwelt und einen entsprechend größeren Quellbaum. + +Die Spiele, die es einsetzen, sind eher die großen thematischen, und mehrere laufen seit +fünfundzwanzig Jahren ununterbrochen. + +## Wie es von außen aussieht + +MSSP, auf Anfrage beantwortet, und sonst nichts ausgehandelt auf dem Spiel, das wir gemessen haben. + +Hinweis für alle, die Zählungen vergleichen: SMAUG-Server in unserer Erhebung haben über MSSP +berichtet und kein `WHO` am Anmeldebildschirm beantwortet, die Zahl hier ist also das eigene MSSP +`PLAYERS` des Spiels — eine Behauptung des Servers, die nur deshalb für bare Münze genommen wird, +weil wir sie nicht unabhängig messen konnten. Die Spielseite kennzeichnet, aus welcher der beiden +Quellen eine gegebene Zahl stammt. diff --git a/content/reference/de/codebase-tbamud.md b/content/reference/de/codebase-tbamud.md new file mode 100644 index 0000000..3f09904 --- /dev/null +++ b/content/reference/de/codebase-tbamud.md @@ -0,0 +1,26 @@ +--- +kind: codebase +slug: tbamud +title: tbaMUD +summary: Die gepflegte Fortsetzung von CircleMUD und die übliche Antwort auf „Ich will heute einen MUD im Diku-Stil aufmachen“. +codebase: tbaMUD +home: https://tbamud.com/ +see-also: codebases/circlemud +see-also: codebases/dikumud +--- + +tbaMUD — *The Builder Academy MUD* — hat CircleMUD dort aufgenommen, wo dessen eigene Entwicklung +aufhörte, und baut, patcht und dokumentiert es seither weiter. Wenn jemand einen MUD mit Leveln und +Kampf von einer bekanntermaßen guten Grundlage aus beginnt statt vom eigenen Fork, dann ist das +diese Grundlage. + +Es trägt die Tugenden der Circle-Codebase weiter: lesbares C, ein dokumentiertes Area-Dateiformat +und ein Builder-Handbuch, das voraussetzt, dass Sie das noch nie gemacht haben. Die Builder Academy +selbst läuft als Lehrspiel, in dem Leute das Bauen darauf lernen. + +## Wie es von außen aussieht + +MSSP, auf Anfrage beantwortet. Eine gemessene Null auf dem Spiel, das wir abgefragt haben — was auf +dieser Website eine gefüllte Zelle ist und keine leere: Wir sind hineingekommen, wir haben die Zahl +abgelesen, und die Zahl war null. Das ist eine andere Tatsache als ein Spiel, das wir nicht zählen +konnten, und die beiden werden hier nie zusammengeführt. diff --git a/content/reference/de/codebase-tinymush.md b/content/reference/de/codebase-tinymush.md new file mode 100644 index 0000000..01e296b --- /dev/null +++ b/content/reference/de/codebase-tinymush.md @@ -0,0 +1,41 @@ +--- +kind: codebase +slug: tinymush +title: TinyMUSH +summary: Der Vorfahr der MUSH-Linie, auf dem noch Spiele laufen. Er hat diesem Crawler beigebracht, dass dessen eigene Aushandlungsbytes den nächsten Befehl kaputtmachen können, den er sendet. +codebase: TinyMUSH +home: https://github.com/TinyMUSH/TinyMUSH +see-also: codebases/pennmush +see-also: codebases/tinymux +see-also: mush-mud-muck-moo +--- + +TinyMUSH ist das, wovon die Linie PennMUSH, TinyMUX, RhostMUSH und CobraMUSH allesamt abstammt, und +es ist immer noch im Einsatz. Die Entwicklung ist eher still als abwesend. + +## Wie es von außen aussieht + +Kein MSSP. Ein `WHO` vor der Anmeldung, das mit einem Satz der Form `0 Players logged in, 22 +record, no maximum.` antwortet. + +## Der Fehler, den es bei uns gefunden hat + +TinyMUSH ist hier einen Absatz wert, weil es das Spiel ist, das einen Defekt im eigenen Crawler +dieser Website aufgedeckt hat, und die Korrektur ist eine gute Veranschaulichung dessen, was +„gemessen“ heißen soll. + +Unsere Abfrage las TinyMUSH wochenlang als *Zählung unbekannt*. Die Vermutung zu den Akten war, dass +seine Antwort keinen abschließenden Zeilenumbruch habe. Sie hat einen. Vom Draht mitgeschnitten, war +die wirkliche Ursache unsere: **TinyMUSH wertet an seinem Anmeldebildschirm kein Telnet aus**, also +landen die drei Bytes `IAC DO MSSP`, die wir beim Verbinden senden, in seinem Eingabepuffer, als +hätte jemand sie getippt. Die nächste Zeile, die es liest, ist nicht `WHO`, sondern drei +Steuerbytes gefolgt von `WHO`, und das ist kein Befehl, den es hat — also zeigt es seinen +Verbindungsbildschirm erneut an und sagt nichts über Spieler. + +Die Abfrage sendet nach dem Aushandeln nun einen leeren Zeilenumbruch und verwirft, was immer daraus +folgt, denn diese Ausgabe ist eine Reaktion auf Bytes, die *wir* zu senden gewählt haben, und ist +daher weder der Verbindungsbildschirm des Spiels noch seine Antwort. TinyMUSH wird jetzt korrekt +gelesen, und die Abfrage war in einem Drittel der Zeit fertig. + +Ein Verzeichnis, das nicht nachgeprüft hätte, hätte „dieses Spiel meldet seine Spieler nicht“ +veröffentlicht, solange es besteht, und der Satz wäre über uns gewesen. diff --git a/content/reference/de/codebase-tinymux.md b/content/reference/de/codebase-tinymux.md new file mode 100644 index 0000000..e392ebc --- /dev/null +++ b/content/reference/de/codebase-tinymux.md @@ -0,0 +1,38 @@ +--- +kind: codebase +slug: tinymux +title: TinyMUX +summary: Der andere große MUSH-Server. Softcode nah genug an dem von PennMUSH, um darüber zu streiten, gar kein MSSP und ein funktionierendes WHO vor der Anmeldung. +codebase: TinyMUX +home: https://www.tinymux.org/ +see-also: codebases/pennmush +see-also: codebases/tinymush +see-also: codebases/rhostmush +see-also: mush-mud-muck-moo +--- + +TinyMUX ist der zweite der beiden Server, auf denen die meisten etablierten Rollenspiel-MUSHes +laufen, und für viele Spieler ist die Wahl zwischen ihm und PennMUSH eine Frage dessen, welchen die +Spielleitung ihres Spiels zuerst gelernt hat. Versionen lesen sich als `2.12` und ähnlich. + +Wie PennMUSH stammt es von TinyMUSH ab, und sein Softcode ist nah genug, dass jemand, der zwischen +beiden wechselt, übersetzt statt neu zu lernen. Die Unterschiede sind real — Funktionsbibliotheken, +einige Ecken des Parsens, der Satz der `@`-Befehle — und genau die Art von Sache, die das +Verschieben einer Datenbank zwischen beiden zu einem Projekt macht statt zu einem Export. + +## Wie es von außen aussieht + +**Kein MSSP.** TinyMUX bietet die Option überhaupt nicht an, was es zusammen mit AresMUSH, MUCK, +RhostMUSH, CobraMUSH und TinyMUSH auf jene Seite des Hobbys stellt, die ein Verzeichnis auf reiner +MSSP-Basis schlicht nicht sehen kann. Seine Spielerzahl kommt von einem `WHO` am Anmeldebildschirm, +das es mit einer schlichten Zählung beantwortet. + +CHARSET handelt es hingegen aus, und damit kommt es bei nicht-ASCII-Text besser weg als die meisten +seiner Verwandten. + +## Woher die Zählungen stammen + +Wenn Sie die Zahl dieser Website für ein TinyMUX-Spiel mit der eines anderen Verzeichnisses +vergleichen, beachten Sie: Wir lesen das `WHO` am Anmeldebildschirm, und die meisten Crawler tun das +nicht. Ein Verzeichnis, das allein auf MSSP baut, meldet diese Spiele als solche ganz ohne Zählung — +oder führt sie nicht auf. diff --git a/content/reference/de/orientation-collaborative-roleplay.md b/content/reference/de/orientation-collaborative-roleplay.md new file mode 100644 index 0000000..a6bce4c --- /dev/null +++ b/content/reference/de/orientation-collaborative-roleplay.md @@ -0,0 +1,67 @@ +--- +kind: orientation +slug: collaborative-roleplay +title: Sie wollen gemeinsames Rollenspiel +summary: Wenn es Ihnen darum geht, mit anderen Menschen Szenen zu schreiben, ist das hier der kurze Weg — nach welchen Codebases Sie suchen, ein Client, der Ihnen nicht im Weg steht, und was Sie am ersten Abend erwartet. +see-also: mush-mud-muck-moo +see-also: connecting +see-also: codebases/pennmush +see-also: codebases/aresmush +see-also: codebases/evennia +see-also: clients/mudlet +--- + +Mit *gemeinsamem Rollenspiel* meinen wir: Sie und andere Menschen schreiben zusammen eine Szene, in +der Rolle, in Prosa, meist in Echtzeit. Niemand würfelt Initiative, außer das Spiel verlangt es. Das +ist der Teil des Hobbys, den das Wort „MUD“ verdeckt, und er lebt. + +## Halten Sie nach diesen Codebases Ausschau + +Diese Kultur lebt in der TinyMUD-Linie, denn ihre Server sind für Leute gebaut, die Dinge erschaffen, +und nicht für Leute, die Dinge töten. + +- **[PennMUSH](/reference/codebases/pennmush)** und **[TinyMUX](/reference/codebases/tinymux)** — + die zwei großen MUSH-Server. Die meisten langlebigen Rollenspiele, von denen Sie gehört haben, + laufen auf einem von beiden. +- **[AresMUSH](/reference/codebases/aresmush)** — modern, eigens für Rollenspiel gebaut, mit einem + Web-Frontend und Szenenprotokollen als Funktionen erster Klasse statt als Softcode, den jemand + geschrieben hat. +- **[Evennia](/reference/codebases/evennia)** — ein Python-Framework und kein fertiges Spiel. Die + darauf gebauten Spiele unterscheiden sich enorm; mehrere der neueren Rollenspiele sind Evennia. +- **[MUCK](/reference/codebases/muck)** — eine eigene Kultur, stark sozial geprägt, und einen Blick + wert, wenn Sie die Fandom-Welten suchen. + +Nach jedem davon können Sie den Katalog filtern: Jede Codebase-Seite verlinkt die Spiele, bei denen +wir sie gemessen haben. + +## Ein Client, der Ihnen nicht im Weg steht + +Sie brauchen keinen — jedes Spiel hier spricht einfaches Telnet, und Ihr Betriebssystem hat +wahrscheinlich schon einen Client. Aber fürs Szenenschreiben zählen drei Dinge mehr als sonst: + +1. **Eine Befehlszeile, die einen langen Absatz übersteht.** Sie werden Posen über mehrere Sätze + tippen. +2. **Protokollieren.** Sie werden die Szene hinterher haben wollen. +3. **UTF-8.** In Namen stecken Akzente. + +Siehe den Abschnitt [Clients](/reference). Wenn Sie einen Screenreader benutzen: Die +Fähigkeitstabellen dort führen eine Zeile dafür — nicht für jeden Client konnten wir eine Antwort +feststellen, und wo wir es nicht konnten, steht in der Tabelle *unbekannt* statt einer Vermutung. + +## Wie der erste Abend wirklich abläuft + +Die meisten Rollenspiele haben ein **Bewerbungsverfahren**: Sie verbinden sich als Gast, lesen ein +paar Hilfedateien und schreiben einen Charakter auf, den die Spielleitung liest. Das kann einen Tag +dauern oder zwei Wochen. Es ist keine Abschottung um ihrer selbst willen — so hält ein Spiel eine +gemeinsame Welt stimmig —, aber es heißt eben, dass „einloggen und losspielen“ selten die Form der +Sache ist. + +Spiele, auf denen gerade niemand ist, wenn Sie nachsehen, sind hier normal, und das Aktivitätsraster +auf der Seite jedes Spiels ist nützlicher als seine Momentanzahl: Ein Spiel mit fünfzehn Leuten an +jedem Abend und niemandem um 4 Uhr morgens ist ein gesundes Spiel, zur falschen Stunde betrachtet. + +## Was diese Website Ihnen nicht sagen kann + +Ob die Leute etwas taugen. Ob die Spielleitung fair ist. Ob die Welt eine ist, die Ihnen gefallen +würde. Wir messen Server. Es gibt hier keine Bewertung und wird keine geben — das ist eine bewusste +Entscheidung und keine Lücke. diff --git a/content/reference/de/orientation-connecting.md b/content/reference/de/orientation-connecting.md new file mode 100644 index 0000000..24c0ad0 --- /dev/null +++ b/content/reference/de/orientation-connecting.md @@ -0,0 +1,49 @@ +--- +kind: orientation +slug: connecting +title: Wie man sich verbindet +summary: Ein Host, ein Port und Telnet. Was die Adresse auf einer Spielseite bedeutet und was man mit ihr macht. +see-also: mush-mud-muck-moo +see-also: protocols/tls +see-also: protocols/charset +--- + +Jedes hier verzeichnete Spiel antwortet auf einem **Host und einem Port**, und das Protokoll darunter +ist Telnet — was in der Praxis eine rohe TCP-Verbindung mit ein wenig optionaler Aushandlung +obendrauf bedeutet. + + telnet mush.pennmush.org 4201 + +Das funktioniert, und auf vielen Systemen ist es schon installiert. Es ist auch eine schlechte Art zu +spielen: Das `telnet` des Systems hat keine nennenswerte Steuerung des lokalen Echos, kein +Protokoll, keine Historie, und es verstümmelt alles oberhalb von ASCII. Es ist das richtige Werkzeug, +um zu prüfen, ob ein Spiel antwortet, und das falsche, um einen Abend darin zu verbringen. + +## Was Ihnen die Adresse auf einer Spielseite sagt + +Jede Spielseite führt die Endpunkte auf, die wir gemessen haben, und kennzeichnet jeden, bei dem +**TLS** beobachtet wurde. Ein Spiel mit einem TLS-Port ist ein Spiel, zu dem Sie verschlüsselt +verbinden können; die Portnummer ist meist eine andere als die des einfachen Ports. + +Wo ein Spiel mehrere Ports hat, sind das häufig verschiedene Wege zu derselben Welt und nicht +verschiedene Spiele. Wir führen auf, was wir gemessen haben, und raten nicht, welcher der maßgebliche +ist. + +## Einen Client wählen + +Der Abschnitt [Clients](/reference) hat für jeden eine Seite, mit einer Fähigkeitstabelle. Die drei +Dinge, die zu prüfen sich lohnt, bevor Sie irgendetwas installieren: + +- **Kann er UTF-8?** Wenn das Spiel nicht nur englisch ist, kommt das schon am ersten Abend auf. +- **Kann er TLS?** Zählt nur, wenn das Spiel es anbietet, aber mehrere tun das inzwischen. +- **Wenn Sie einen Screenreader benutzen: Dokumentiert das Projekt Unterstützung dafür?** Das ist die + Zeile, die in Client-Vergleichen am häufigsten fehlt, deshalb ist sie bei uns die erste — und wo + niemand eine Antwort festgestellt hat, steht dort *unbekannt*. + +## Wenn nichts antwortet + +Ein Spiel, das nicht antwortet, ist nicht zwangsläufig verschwunden. Spiele ziehen auf andere Hosts +um, DNS-Einträge laufen aus, und Firewalls haben ihre Meinungen. Diese Website behält jedes Spiel, +das sie je gemessen hat — auch die, die vor Jahren aufgehört haben zu antworten —, und klopft weiter +wöchentlich an; das [Archiv](/archive) ist also der Ort, an dem man nachsieht, bevor man irgendetwas +schlussfolgert. diff --git a/content/reference/de/orientation-families.md b/content/reference/de/orientation-families.md new file mode 100644 index 0000000..87833ae --- /dev/null +++ b/content/reference/de/orientation-families.md @@ -0,0 +1,85 @@ +--- +kind: orientation +slug: mush-mud-muck-moo +title: MUSH, MUD, MUCK, MOO — was die Wörter bedeuten +summary: Vier Wörter für vier Traditionen, von denen keine ein Genre ist. Was sie Ihnen tatsächlich sagen. +see-also: collaborative-roleplay +see-also: connecting +see-also: codebases/pennmush +see-also: codebases/aresmush +see-also: codebases/muck +see-also: codebases/moo +see-also: codebases/evennia +--- + +Jedes dieser Wörter benennt eine **Familie von Serversoftware**, keine Art von Spiel. Das ist das mit +Abstand Nützlichste, was man über sie wissen kann, und deshalb wird „Ist das ein MUSH oder ein MUD?“ +so oft schlecht beantwortet: Die ehrliche Antwort lautet meist *beides, und die Frage, die Sie +meinten, ging um die Kultur*. + +## MUD + +Der älteste Begriff und heute der weiteste. Er begann als *Multi-User Dungeon* — Bartles und +Trubshaws Spiel von 1978 — und war Mitte der Neunziger das Sammelwort für jede textbasierte +Mehrspielerwelt. + +Eng gebraucht meint er die **DikuMUD- und LPMud-Linien**: Server, die um Stufen, Kampf, Ausrüstung +und eine Gebietsdatei herum gebaut sind, in der ein Erbauer die Räume im Voraus beschrieben hat. Wenn +jemand sagt „Ich spiele ein MUD“ und etwas Bestimmtes meint, ist es meist das. + +Das Verzeichnis kann Ihnen jede Linie für sich zeigen: [die DikuMUD-Spiele](/games?lineage=DikuMUD) +und [die LPMud-Spiele](/games?lineage=LPMud). + +## MUSH + +*Multi-User Shared Hallucination*, aus der TinyMUD-Linie. Die bestimmende Eigenschaft ist nicht das +Thema, sondern der **Softcode**: MUSH-Server bringen eine Programmiersprache mit, die Spieler von +innerhalb des Spiels benutzen; ein Spieler mit Baurechten legt damit Räume, Objekte und Verhalten an, +ohne eine Quelldatei anzufassen oder irgendetwas neu zu starten. + +Diese eine Entwurfsentscheidung hat die Kultur hervorgebracht. MUSHes haben tendenziell wenig +automatisierte Systeme und viele menschliche — von der Spielleitung geführte Handlungsbögen, +geschriebene Szenen, Bewerbungsverfahren —, weil die Leute, die spielen, auch die Leute sind, die +bauen. + +PennMUSH, TinyMUSH, TinyMUX, RhostMUSH, CobraMUSH und AresMUSH stehen alle in dieser Linie, und +keines von ihnen sagt das: MSSP kennt keinen Wert `MUSH`, den man veröffentlichen könnte, und außer +PennMUSH veröffentlicht keines überhaupt MSSP. Sie zu gruppieren ist daher etwas, das wir tun, und +nicht etwas, das wir lesen — weshalb [die MUSH-Spiele](/games?lineage=MUSH) überall, wo sie +auftauchen, als *abgeleitet* gekennzeichnet sind. + +## MUCK + +Ein TinyMUD-Abkömmling wie MUSH, mit eigenem Softcode (MUF, einer Forth-artigen Sprache) und einer +starken Tradition sozialer Welten und der Furry-Fandom-Welten. Technisch nah an MUSH; kulturell so +eigen, dass Leute, die beides spielen, sie nicht als dasselbe beschreiben würden — +[die MUCK-Spiele](/games?lineage=MUCK). + +## MOO + +*MUD, Object-Oriented*. Der reinste Ausdruck der Idee „das Spiel bearbeitet sich selbst“: Nahezu +alles in einem MOO ist in der Programmiersprache MOO geschrieben, von den Leuten, die es benutzen, +von innen heraus. LambdaMOO ist der Vorfahr, und MOOs waren historisch in Bildung und Forschung +ebenso beliebt wie im Spiel. [Die MOO-Spiele](/games?lineage=MOO). + +## Was sollten Sie also tatsächlich fragen? + +Drei Fragen leisten mehr als das Wort aus vier Buchstaben: + +1. **Gibt es Kampf, und ist er automatisiert?** Das trennt die Diku/LP-Linie verlässlicher von der + TinyMUD-Linie als jeder Name. +2. **Wer baut?** Nur die Spielleitung, oder jeder mit einem Bau-Bit? +3. **Ist das Spiel verabredet oder beiläufig?** Szenen nach Termin und gepostes Rollenspiel, oder + einloggen und los? + +Das Verzeichnis auf dieser Website kann Ihnen einen Teil der ersten Frage beantworten: Die +**Codebase**, die wir für ein Spiel gemessen haben, sagt Ihnen, aus welcher Tradition sein Server +kommt, und die Facette **Abstammung** ist dieselbe Antwort, filterbar gemacht. Die Kultur kann sie +Ihnen nicht sagen, und diese Seite wird nicht so tun als ob. + +Eine Warnung zu dieser Facette, denn auf dieser Seite wird jemand ihr zum ersten Mal begegnen. Die +Codebase ist gemessen und die Abstammung nicht: Sie ist *unsere* Gruppierung dessen, was ein Spiel +uns mitgeteilt hat, geführt unter einer eigenen Kennzeichnung — **abgeleitet** — neben *gemessen* und +*angegeben*. Wo eine Codebase keinen unumstrittenen Vorfahren hat, wird sie aus jeder Abstammung +herausgelassen statt unter der nächstbesten einsortiert, und mehrere dieser Spiele stimmen uns mit +eigenen Worten zu und veröffentlichen `FAMILY Custom`. diff --git a/content/reference/de/protocol-atcp.md b/content/reference/de/protocol-atcp.md new file mode 100644 index 0000000..da52189 --- /dev/null +++ b/content/reference/de/protocol-atcp.md @@ -0,0 +1,31 @@ +--- +kind: protocol +slug: atcp +title: ATCP +summary: Der Vorgänger von GMCP. Out-of-Band-Daten mit lockererer Nutzlast, weitgehend abgelöst und immer noch von Servern ausgehandelt, die es nie entfernt haben. +protocol: ATCP +see-also: protocols/gmcp +see-also: protocols/msdp +see-also: clients/mudlet +--- + +ATCP — das Achaea Telnet Client Protocol — ist Telnet-Option 200, und hier wurde die Idee, neben dem +MUD-Text strukturierte Daten zu senden, zum ersten Mal breit eingesetzt. Ein Server sendet einen +Modulnamen und eine Nutzlast; der Client leitet sie weiter. + +Sein Format für die Nutzlast ist lockerer als das JSON von [GMCP](/reference/protocols/gmcp), und im +Wesentlichen ist das der Grund, warum GMCP es abgelöst hat. Clients, die ATCP unterstützen, führen es +heute in der Regel als veraltet und verweisen stattdessen auf GMCP. + +## Warum es immer noch da ist + +Weil nichts kaputtgeht, wenn man es angeschaltet lässt. Ein Server, der ATCP 2008 implementiert und +2014 GMCP ergänzt hat, handelt meist immer noch beides aus, und ein Client, der beides unterstützt, +nimmt das, was ihm angeboten wird. + +Für eine neue Implementierung gibt es keinen Grund, es zu wählen. + +## Was wir messen + +Server, die Telnet-Option 200 in einem von uns beobachteten Handshake angeboten haben. Ein niedriger +Wert ist hier zu erwarten und sagt etwas über das Alter aus und sonst über nichts. diff --git a/content/reference/de/protocol-charset.md b/content/reference/de/protocol-charset.md new file mode 100644 index 0000000..b065e42 --- /dev/null +++ b/content/reference/de/protocol-charset.md @@ -0,0 +1,43 @@ +--- +kind: protocol +slug: charset +title: CHARSET +summary: Die Telnet-Option aus RFC 2066, mit der eine Kodierung vereinbart wird. Der Grund, warum die Namen eines Spiels ihre Akzente über die Strecke retten, und die Ursache einiger subtiler Fehler, wenn sie fehlt. +protocol: CHARSET +home: https://www.rfc-editor.org/rfc/rfc2066 +see-also: protocols/ttype +see-also: connecting +see-also: codebases/tinymux +--- + +CHARSET ist Telnet-Option 42, festgelegt in RFC 2066. Die eine Seite bietet eine Liste von +Zeichensätzen an, die andere wählt einen aus, und beide sind sich danach einig, wie Bytes auf Zeichen +abgebildet werden. + +In der Praxis einigt sich die Aushandlung auf **UTF-8** oder findet gar nicht erst statt. Die +MUSH-Familie handelt sie merklich häufiger aus als die MUD-Familie — TinyMUX, RhostMUSH und PennMUSH +tun es alle —, was eine Bevölkerung widerspiegelt, die Prosa schreibt, in der Namen vorkommen. + +## Was ohne sie passiert + +Ein Client muss raten, und geraten wird üblicherweise entweder ASCII oder Latin-1. Rät er ASCII, wird +jedes Byte oberhalb von 0x7F zu einem Fragezeichen; rät er Latin-1 bei einem UTF-8-Server, wird aus +jedem Zeichen mit Akzent ein Paar Satzzeichen. Beide Fehler sehen aus wie ein Fehler des Spiels und +sind keiner. + +Für einen Crawler wird das an einer bestimmten Stelle unangenehm. Unsere eigene Telnet-Bibliothek +setzt ihre aktuelle Kodierung standardmäßig auf ASCII, und dieser Standard ist nicht folgenlos — mit +ihm wird jedes Byte dekodiert, bei jedem Server, der CHARSET nie aushandelt, und das sind die +meisten. Aus diesem Grund geben wir ihn bewusst selbst vor. + +## Die eine Stelle, die CHARSET nicht erreicht + +MSSP-Feldnamen und -Werte werden als ASCII dekodiert, gleich worauf sich CHARSET geeinigt hat, denn +eine Subnegotiation ist ein Befehl und kein Text, und die Spezifikation begrenzt CHARSET auf Text. +Das ist vertretbar konform und es ist verlustbehaftet: Ein Spiel, dessen MSSP-`NAME` `Café Noir` +lautet, meldet `Caf? Noir`, und die ursprünglichen Bytes sind weg, bevor irgendetwas unter unserer +Kontrolle sie zu sehen bekommt. + +Wenn Sie auf dieser Website in einem angegebenen Feld ein verstümmeltes Zeichen sehen und in der +Ausgabe des Spiels selbst nicht, dann ist das der Grund, und von unserer Seite lässt es sich nicht +wiederherstellen. diff --git a/content/reference/de/protocol-gmcp.md b/content/reference/de/protocol-gmcp.md new file mode 100644 index 0000000..14cf678 --- /dev/null +++ b/content/reference/de/protocol-gmcp.md @@ -0,0 +1,46 @@ +--- +kind: protocol +slug: gmcp +title: GMCP +summary: Das Generic Mud Communication Protocol — strukturierte JSON-Nachrichten neben dem Text und der Out-of-Band-Kanal, gegen den die meisten modernen Clients entwickeln. +protocol: GMCP +home: https://www.mudhalla.net/tintin/protocols/gmcp/ +see-also: protocols/msdp +see-also: protocols/atcp +see-also: clients/mudlet +--- + +GMCP ist Telnet-Option 201. Ist sie einmal ausgehandelt, kann der Server **strukturierte Daten out of +band** senden: einen Paketnamen und eine JSON-Nutzlast, die im selben Strom ankommen wie der Text, +aber nicht Teil von ihm sind. + +`Char.Vitals { "hp": 412, "maxhp": 500 }` ist das kanonische Beispiel. Ein Client kann daraus eine +Lebensanzeige speisen, ohne die Prosa nach Zahlen abzugrasen, und genau darum geht es — eine +Statusanzeige, die auf Mustererkennung im Text beruht, geht an dem Tag kaputt, an dem ein Spiel +seinen Prompt ändert, und eine auf GMCP gebaute nicht. + +Der Namensraum der Pakete beruht auf Konvention und nicht auf einem Standard. `Char`, `Room`, `Comm` +und `Client` sind weit verbreitet; darüber hinaus erfinden Spiele, was sie brauchen, und einem Client +muss in der Regel gesagt werden, was ein bestimmtes Spiel sendet. + +## Warum es ATCP verdrängt hat + +GMCP ist der Nachfolger von [ATCP](/reference/protocols/atcp), das dieselbe Aufgabe mit einem +lockereren Nutzlastformat erledigte. JSON war die Verbesserung, und Mitte der 2010er-Jahre war der +Umstieg weitgehend vollzogen. Ein Spiel, das beides unterstützt, ist nichts Ungewöhnliches; ein neues +Spiel, das nur ATCP unterstützt, wäre es. + +## Was wir messen + +Ein Spiel zählt hier, wenn **sein Server GMCP in einem von uns beobachteten Handshake angeboten +hat**. Das ist eine andere Behauptung als ein MSSP eines Spiels, das `GMCP 1` sagt — worauf die +meisten Protokolltabellen in diesem Hobby aufgebaut sind —, und die beiden widersprechen sich +regelmäßig. + +Eine Anmerkung zur Messung aus unserer eigenen Geschichte: Eine Zeit lang konnten wir GMCP auf +Servern nicht sehen, die auch [MCCP](/reference/protocols/mccp) aushandelten, denn unsere +Telnet-Bibliothek handelte die Kompression aus, ohne den Strom zu dekomprimieren, und alles nach der +Kompressionsmarke war für uns Rauschen. Von mindestens einem Server in unserer Erhebung stellte sich +heraus, dass er die ganze Zeit GMCP sprach. Wenn ein Wert auf dieser Seite für eine Familie niedrig +aussieht, die Sie gut kennen, ist diese Art von Defekt das Erste, was zu vermuten ist — bei uns, +nicht bei ihnen. diff --git a/content/reference/de/protocol-mccp.md b/content/reference/de/protocol-mccp.md new file mode 100644 index 0000000..21044e5 --- /dev/null +++ b/content/reference/de/protocol-mccp.md @@ -0,0 +1,44 @@ +--- +kind: protocol +slug: mccp +title: MCCP +summary: Kompression des Datenstroms. Billig, weit verbreitet und das Protokoll, das den lehrreichsten Fehler in der Geschichte dieses Projekts hervorgebracht hat. +protocol: MCCP +home: https://www.mudhalla.net/tintin/protocols/mccp/ +see-also: codebases/rom +see-also: codebases/dikumud +see-also: protocols/gmcp +--- + +MCCP komprimiert den Strom vom Server zum Client mit zlib. Version 1 ist Telnet-Option 85 und +praktisch historisch; **Version 2** ist Option 86 und das, was moderne Server aushandeln. Nachdem der +Server `IAC SB MCCP2 IAC SE` gesendet hat, gehört jedes folgende Byte zu einem einzigen durchgehenden +zlib-Strom. + +Bei einem Textprotokoll ist das eine echte Ersparnis — MUD-Ausgabe lässt sich außerordentlich gut +komprimieren —, und in der Diku- und der LP-Familie ist es verbreitet: Rund ein Drittel der von uns +erhobenen Codebases handelt es aus. + +## Der Fehlerfall, und warum er hier zählt + +Ein Client, der MCCP2 aushandelt und den Strom dann nicht dekomprimiert, empfängt **ab der +Kompressionsmarke binären Müll**. Kein Fehler, kein Verbindungsabbruch: Der Verbindungsbildschirm +kommt als Wand aus Ersatzzeichen an, und alles danach — die `WHO`-Antwort, jedes spätere MSSP, die +ganze Sitzung — ist verloren. + +Das ist nicht hypothetisch. Unsere eigene Telnet-Bibliothek hat genau das getan. Sie handelte die +Option aus, löste ihren Callback „Kompression aktiviert“ aus und dekomprimierte kein einziges Byte. +Die Nutzlast ließ sich mit einem gewöhnlichen zlib-Aufruf sauber entpacken, und das machte es +eindeutig, dass die Server im Recht waren und wir nicht. Dreizehn der achtunddreißig Codebases in +unserer Erhebung waren betroffen, und solange das andauerte, konnten wir nicht beobachten, was diese +Server *nach* dem Beginn der Kompression aushandelten — unsere Aufzeichnung ihrer Fähigkeiten blieb +also hinter dem zurück, was sie konnten. + +Es wurde upstream behoben. Ein Folgedefekt — der Inflater wird pro Lesevorgang neu erzeugt, statt für +die Verbindung erhalten zu bleiben, was mitten in einem großen Verbindungsbildschirm scheitert — ist +gemeldet und offen und betrifft das Ende der größten Bildschirme. + +Zwei Dinge sollte ein Leser daraus mitnehmen. **Ein Protokollwert auf dieser Seite ist ebenso sehr +eine Messung unseres Crawlers wie eine des Hobbys**, und wo wir wissen, dass er falsch war, sagen wir +es. Und wenn Sie einen Client schreiben: MCCP auszuhandeln ist leicht, die Arbeit steckt darin, es +richtig zu dekomprimieren. diff --git a/content/reference/de/protocol-msdp.md b/content/reference/de/protocol-msdp.md new file mode 100644 index 0000000..68a3065 --- /dev/null +++ b/content/reference/de/protocol-msdp.md @@ -0,0 +1,31 @@ +--- +kind: protocol +slug: msdp +title: MSDP +summary: Das Mud Server Data Protocol — dieselbe Aufgabe wie GMCP, erledigt mit einer kompakten binären Kodierung und einem Mechanismus zur Erkundung, den GMCP nicht hat. +protocol: MSDP +home: https://www.mudhalla.net/tintin/protocols/msdp/ +see-also: protocols/gmcp +see-also: clients/tintin +see-also: clients/blightmud +--- + +MSDP ist Telnet-Option 69 und löst dasselbe Problem wie [GMCP](/reference/protocols/gmcp): +strukturierte Daten neben dem Text zu senden, damit ein Client die Prosa nicht nach Zahlen abgrasen +muss. + +Die Unterschiede sind zwei. Die Kodierung von MSDP ist **binär und kompakt** — Variablen und Werte +werden mit einzelnen Steuerbytes markiert statt in JSON verpackt —, und MSDP definiert ein Gespräch +zur **Erkundung**: Ein Client kann mit `LIST` nach `COMMANDS`, `REPORTABLE_VARIABLES` und so weiter +fragen und bekommt gesagt, was ein bestimmtes Spiel unterstützt. GMCP hat dafür kein Gegenstück, +weshalb ein GMCP-Client in der Regel je Spiel konfiguriert werden muss. + +In der Praxis hat GMCP sich in der Verbreitung durchgesetzt, und MSDP hält sich in den Servern und +Clients, die es implementiert haben, oft neben GMCP. + +## Was wir messen + +Ein Spiel zählt hier, wenn sein Server MSDP in einem von uns beobachteten Handshake angeboten hat. +Wie bei jedem Wert in diesem Abschnitt ist das eine positive Beobachtung, und der Rest ist nicht ihr +Gegenteil — ein Spiel, das nicht gezählt ist, implementiert MSDP womöglich nicht, oder wir haben +seinen Handshake einfach noch nicht gelesen. diff --git a/content/reference/de/protocol-msp.md b/content/reference/de/protocol-msp.md new file mode 100644 index 0000000..9537fbb --- /dev/null +++ b/content/reference/de/protocol-msp.md @@ -0,0 +1,41 @@ +--- +kind: protocol +slug: msp +title: MSP +summary: Das MUD Sound Protocol — der Server nennt eine Klangdatei, und der Client spielt sie ab. Alt, einfach und leicht mit zwei anderen Dingen zu verwechseln. +protocol: MSP +home: https://www.zuggsoft.com/zmud/msp.htm +see-also: protocols/mxp +see-also: clients/vipmud +--- + +Mit MSP kann ein Server einen Client bitten, einen Klang abzuspielen: eine in Klammern gesetzte +Anweisung, die eine Datei, eine Lautstärke, eine Wiederholungszahl und eine URL nennt, von der sie zu +holen ist, falls der Client sie nicht hat. Es wird über Telnet-Option 90 ausgehandelt und kann von +Servern, die überhaupt nichts aushandeln, auch in band im Textstrom gesendet werden. + +Es ist wirklich alt und wird wirklich noch benutzt — Umgebungsklang in einem Textspiel wirkt stärker, +als es klingt, und für Spieler, die die Audiohinweise eines Clients statt seiner Anzeige nutzen, ist +er mehr als Zierde. + +## Drei Dinge, die es nicht ist + +Die Client-Tabellen in diesem Abschnitt mussten hier vorsichtig sein, und es lohnt sich +aufzuschreiben, warum: + +- **MCMP** — das Mud Client Media Protocol — ist ein anderes Protokoll mit einer ähnlichen Aufgabe. + Mindestens ein Client implementiert MCMP und nicht MSP, und das eine als das andere zu lesen würde + eine Behauptung in eine Tabelle setzen, die niemand aufgestellt hat. +- **Der eigene Skriptaufruf eines Clients zum Abspielen eines Klangs** ist nicht MSP. Er spielt eine + lokale Datei ab, wenn ein Skript es sagt; MSP ist ein Server, der einem Client sagt, was er + abspielen soll. +- **Unterstützung durch ein mitgeliefertes Plugin ist als solche zu benennen.** Bei einem Client + kommt die MSP-Unterstützung als Plugin, das ausdrücklich keine Telnet-Aushandlung betreibt; das + funktioniert bei Servern, die MSP in band senden, und nicht bei Servern, die erwarten, es + auszuhandeln. + +## Was wir messen + +Server, die Telnet-Option 90 anbieten. Weil MSP häufig ohne Aushandlung in band gesendet wird, liegt +dieser Wert um einen Betrag unter der tatsächlichen Verbreitung, den wir nicht schätzen können — das +ist eine Grenze dessen, was ein Handshake sehen kann, und kein Befund über das Protokoll. diff --git a/content/reference/de/protocol-mssp.md b/content/reference/de/protocol-mssp.md new file mode 100644 index 0000000..c666ea1 --- /dev/null +++ b/content/reference/de/protocol-mssp.md @@ -0,0 +1,61 @@ +--- +kind: protocol +slug: mssp +title: MSSP +summary: Das Mud Server Status Protocol — wie ein Spiel einem Crawler von sich erzählt. Alles, was es meldet, ist angegeben und nicht gemessen, und diese Website hält beides auseinander. +protocol: MSSP +home: https://www.mudhalla.net/tintin/protocols/mssp/ +see-also: protocols/gmcp +see-also: codebases/dikumud +see-also: codebases/pennmush +--- + +MSSP ist Telnet-Option 70. Ein Crawler sendet `IAC DO MSSP`; ein Server, der es unterstützt, +antwortet mit einer Tabelle aus Name/Wert-Paaren, die ihn beschreibt — Name, Spielerzahl, Codebase, +Uptime, Hostname, Port, Genre und was er sonst noch veröffentlichen möchte. + +Es ist das, was diesem Hobby am nächsten an einen maschinenlesbaren Verzeichniseintrag herankommt, +und es ist der Grund, warum es mehrere Verzeichnisse überhaupt gibt. + +## Alles in einem MSSP-Bericht ist eine Behauptung + +Das ist der Punkt, in dem sich diese Website von jedem etablierten Verzeichnis unterscheidet. Ein +MSSP-Bericht ist das Spiel, das Ihnen *von sich erzählt*. `GMCP 1` in einer MSSP-Tabelle heißt, dass +jemand eine `1` in eine Konfigurationsdatei getippt hat, womöglich im Jahr 2011. Es ist kein Beleg +dafür, dass der Server GMCP anbietet, und die beiden widersprechen sich oft genug, um interessant zu +sein. + +Deshalb werden Tatsachen aus MSSP hier als **angegeben** ausgewiesen, und wo wir dieselbe Tatsache +messen können — eine Fähigkeit, indem wir sehen, ob die Option tatsächlich ausgehandelt wird —, wird +beides nebeneinander gezeigt, jedes mit seinem Alter. Ein Spiel, dessen MSSP seit sechs Jahren GMCP +angibt und es kein einziges Mal in einem Handshake angeboten hat, ist eine Tatsache, die man kennen +sollte, und nirgendwo sonst ist sie zu finden. + +Das eine Feld, das wir bewusst überhaupt nicht anrechnen, ist `CREATED`. Es ist eine einzelne von +Hand getippte Zeile, und sie irgendwo anzurechnen würde das Betreffende trivial manipulierbar machen. + +## Wer darauf antwortet + +MSSP ist die Antwort der **Diku- und LP-Welt**. In unserer eigenen Erhebung über 38 Codebases +veröffentlichten 28 eine Spielerzahl über MSSP und sieben über ein `WHO` am Anmeldebildschirm, und +nur zwei taten beides — die zwei Familien sind nahezu disjunkt. AresMUSH, TinyMUX, MUCK, RhostMUSH, +CobraMUSH und TinyMUSH bieten überhaupt kein MSSP. + +Das ist der empirische Grund dafür, vier Schichten abzufragen statt einer: **Ein Crawler, der allein +auf MSSP baut, kann den größten Teil der MUSH-Familie nicht sehen**, und die ist ein großer Teil des +Hobbys und der größte Teil des Publikums, für das diese Website gedacht ist. + +## Fragen, nicht warten + +Sehr viele Server, die MSSP vollständig unterstützen, bieten es von sich aus nie an — sie antworten +auf `IAC DO MSSP` und sagen sonst nichts. Ein Crawler, der mit `IAC WILL NAWS` eröffnet und wartet, +meldet diese Spiele daher als solche, die nichts veröffentlichen, und das ist eine Behauptung über +den Server, gemacht aus dem eigenen Schweigen des Crawlers. Wir senden `IAC DO MSSP` beim Verbinden. + +## Die Klartextform + +Es gibt eine ältere Variante, bei der ein Client am Anmeldebildschirm wörtlich die Zeile +`MSSP-REQUEST` sendet. Wir haben sie gemessen: Von zwanzig versuchten Spielen antworteten drei — und +alle drei antworteten auch auf Telnet-Option 70, sie erreichte also nichts, was die Option nicht +schon erreichte. Acht Server lasen die Anfrage als **Charakternamen** und sagten das auch, wobei +einer der Anmeldeversuche verbraucht wurde, die einem Fremden zustehen. Wir senden sie nicht. diff --git a/content/reference/de/protocol-mxp.md b/content/reference/de/protocol-mxp.md new file mode 100644 index 0000000..322215d --- /dev/null +++ b/content/reference/de/protocol-mxp.md @@ -0,0 +1,42 @@ +--- +kind: protocol +slug: mxp +title: MXP +summary: Das MUD eXtension Protocol — HTML-ähnliche Auszeichnung im Textstrom, die anklickbare Links, Bilder und Formulare möglich macht. Umfassend spezifiziert, ungleichmäßig implementiert. +protocol: MXP +home: https://www.zuggsoft.com/zmud/mxp.htm +see-also: protocols/pueblo +see-also: clients/mushclient +see-also: clients/mudlet +--- + +MXP bettet eine kleine, HTML-ähnliche Auszeichnungssprache in den Text ein, den ein Server sendet: +`` für einen anklickbaren Befehl, `` für einen Link, Elemente für Farbe und Schrift und +einen Mechanismus, mit dem ein Server eigene Tags definieren kann. Es wird über Telnet-Option 91 +ausgehandelt. + +Sein Entwurfsproblem steckt in ihm selbst und ist interessant: Die Auszeichnung reist im selben Strom +wie der Text, ein Server muss also auf Text achtgeben, der wie Auszeichnung *aussieht*, und ein +Client muss achtgeben, was er darstellt. Genau deshalb definiert MXP Sicherheitsstufen — ein Tag, das +in einer Chatzeile von einem anderen Spieler ankommt, ist nicht dasselbe wie ein Tag, das der Server +selbst ausgegeben hat. + +## Anklickbarkeit ist der Grund, warum man es haben will + +Das meiste, wofür MXP tatsächlich benutzt wird, ist, `north` und Gegenstandsnamen in etwas zu +verwandeln, das man anklicken kann. Für einen neuen Spieler ist das ein erheblicher Unterschied, und +deshalb wird das Protokoll trotz seiner Komplexität immer wieder implementiert. + +## Pueblo ist das andere + +[Pueblo](/reference/protocols/pueblo) ist älter als MXP und erledigt eine ähnliche Aufgabe mit einem +anderen, buchstäblicher an HTML angelehnten Ansatz. Ein Client, der das eine unterstützt, unterstützt +häufig das andere nicht, und beim Lesen einer Funktionsliste sind die beiden leicht zu verwechseln — +ein Fehler, vor dem wir uns in den Client-Tabellen dieses Abschnitts hüten mussten. + +## Was wir messen + +Server, die Telnet-Option 91 in einem von uns beobachteten Handshake angeboten haben. MXP wird +seltener ausgehandelt als die Out-of-Band-Protokolle, zum Teil deshalb, weil ein großer Teil seines +Nutzens von Servern eingelöst wird, die die Auszeichnung einfach senden und hoffen, ohne überhaupt +auszuhandeln — und das können wir nicht sehen. diff --git a/content/reference/de/protocol-pueblo.md b/content/reference/de/protocol-pueblo.md new file mode 100644 index 0000000..6052857 --- /dev/null +++ b/content/reference/de/protocol-pueblo.md @@ -0,0 +1,33 @@ +--- +kind: protocol +slug: pueblo +title: Pueblo +summary: Das ältere Verfahren, HTML in ein MUD zu bringen, aus dem gleichnamigen Client. Von Clients der MUSH-Seite weiterhin unterstützt und regelmäßig mit MXP verwechselt. +protocol: PUEBLO +home: https://pueblo.sourceforge.net/ +see-also: protocols/mxp +see-also: clients/beipmu +--- + +Pueblo ging Mitte der Neunziger aus dem gleichnamigen Client hervor und verfolgte einen direkten +Ansatz, MUD-Text aufzuwerten: Der Server soll **HTML** senden und der Client es darstellen. Ein +Server kündigt seine Pueblo-Unterstützung beim Verbinden in einer Zeile an; der Client antwortet, und +von da an darf der Strom Auszeichnung tragen. + +Es erreichte die MUSH-Seite des Hobbys stärker als die MUD-Seite, und MUSH-Server, die es +unterstützen, tun das in der Regel weiterhin. + +## Nicht MXP + +[MXP](/reference/protocols/mxp) ist das spätere Verfahren und das breiter implementierte. Sie +erledigen eine ähnliche Aufgabe und sind nicht kompatibel, und die Pueblo-Unterstützung eines Clients +als MXP-Unterstützung zu lesen — oder umgekehrt — ist der mit Abstand am leichtesten zu machende +Fehler, wenn man einen Client-Vergleich zusammenstellt. Deshalb halten die Client-Seiten in diesem +Abschnitt beides getrennt, und wo ein Projekt das eine dokumentiert und das andere nicht, steht beim +anderen *unbekannt*. + +## Was wir messen + +Der Handshake von Pueblo ist keine Telnet-Option im üblichen Sinn; was wir beobachten, ist also enger +als bei den ausgehandelten Protokollen, und ein niedriger Wert ist hier als Aussage über unsere Sicht +zu lesen und nicht über die Verbreitung. diff --git a/content/reference/de/protocol-tls.md b/content/reference/de/protocol-tls.md new file mode 100644 index 0000000..97d91a7 --- /dev/null +++ b/content/reference/de/protocol-tls.md @@ -0,0 +1,42 @@ +--- +kind: protocol +slug: tls +title: TLS +summary: Verschlüsselte Verbindungen. Meist ein eigener Port statt einer ausgehandelten Umstellung, und die eine Fähigkeit auf dieser Website, die wir durch Verbinden prüfen und nicht durch Fragen. +protocol: TLS +see-also: connecting +see-also: protocols/charset +see-also: clients/potato +--- + +Telnet ist Klartext. Alles, was Sie an ein MU\* senden — auch Ihr Passwort —, quert das Netz lesbar +für alles auf dem Weg, sofern das Spiel nicht TLS anbietet. + +In diesem Hobby heißt TLS fast immer **ein zweiter Port, der vom ersten Byte an TLS spricht**, und +nicht eine Umstellung im laufenden Strom. Ein Spiel mit einem einfachen Port auf 4201 und einem +TLS-Port auf 4202 ist die übliche Form. Es gibt eine ausgehandelte Variante, und sie ist selten +genug, dass die Dokumentation mindestens eines Clients ausdrücklich sagt, sie werde nicht +unterstützt. + +## Warum die Spielseiten das besonders kennzeichnen + +TLS ist die eine Fähigkeit auf dieser Website, die dadurch feststeht, dass wir es *getan* haben: Ein +Endpunkt ist als TLS gekennzeichnet, weil wir gegen ihn einen TLS-Handshake abgeschlossen haben. Da +wird nichts gefragt, und es gibt kein Feld, in dem sich etwas angeben ließe — das macht es zur +saubersten Messung im Katalog. + +Das ist auch der Grund, warum der TLS-Port eines Spiels und sein einfacher Port als getrennte +Endpunkte geführt und nicht zusammengelegt werden. Es sind verschiedene Messungen verschiedener +Dinge. + +## Praktischer Rat + +Wenn ein Spiel, das Sie spielen, einen TLS-Port anbietet, benutzen Sie ihn. Wenn nicht und es Ihnen +wichtig ist, fragen Sie nach — für einen Administrator ist es wenig Arbeit, und dass es ihn nicht +überall gibt, liegt meist daran, dass niemand gefragt hat, und nicht daran, dass jemand etwas dagegen +hätte. + +Prüfen Sie, ob Ihr Client es unterstützt, bevor Sie sich darauf verlassen. Mehrere im Abschnitt +[Clients](/reference) tun es; mindestens einer dokumentiert stattdessen einen Behelf mit einem +externen `stunnel`-Prozess, der funktioniert und mehr Einrichtung ist, als die meisten Leute auf sich +nehmen. diff --git a/content/reference/de/protocol-ttype.md b/content/reference/de/protocol-ttype.md new file mode 100644 index 0000000..3aba86f --- /dev/null +++ b/content/reference/de/protocol-ttype.md @@ -0,0 +1,46 @@ +--- +kind: protocol +slug: ttype +title: TTYPE und MTTS +summary: Wie ein Client einem Server sagt, was er ist und was er kann — auch, sofern der Client sich dafür entscheidet, dass ein Screenreader im Einsatz ist. +protocol: TTYPE +home: https://www.mudhalla.net/tintin/protocols/mtts/ +see-also: protocols/charset +see-also: clients/tintin +see-also: clients/blightmud +--- + +TTYPE ist Telnet-Option 24, aus RFC 1091: Der Server fragt den Client, was für ein Terminal er ist, +und der Client antwortet. Historisch lautete die Antwort `VT100` oder `ANSI`. + +**MTTS** — der Mud Terminal Type Standard — legt darüber eine Konvention. Ein Client antwortet +dreimal: mit seinem Namen, mit seinem Terminaltyp und dann mit `MTTS `, wobei die Bits +Fähigkeiten angeben. 256 Farben, True Color, UTF-8, MNES, MSP über den Out-of-Band-Kanal — und, +bemerkenswert, **`MTTS_SCREEN_READER`**. + +## Das Screenreader-Bit + +Bei dem letzten lohnt es sich innezuhalten, denn es ist die einzige Stelle im Protokollstapel dieses +Hobbys, an der Barrierefreiheit ein Konzept erster Klasse ist. + +Ein Client, der es setzt, teilt dem Server mit, dass ein Screenreader im Einsatz ist, und ein Server, +der das bemerkt, kann sich anpassen: ASCII-Grafik unterdrücken, den schmückenden Rahmen aus +Linienzeichen um eine Raumbeschreibung weglassen, eine Tabelle anders anordnen. Sowohl +[TinTin++](/reference/clients/tintin) als auch [Blightmud](/reference/clients/blightmud) geben es an, +und [Mudlet](/reference/clients/mudlet) hat eine Einstellung dafür. + +Ob ein bestimmtes Spiel darauf reagiert, ist eine andere Frage, und keine, die diese Website messen +kann — wir können einen Server nicht fragen, was er anders machen würde. + +## Was ein Crawler hier schuldet + +Ein Crawler weist sich über TTYPE aus, und das soll er auch. Unserer tut es, mit einer URL zur +Information, damit ein Administrator beim Lesen seiner Logs herausfinden kann, wer sich mit seinem +Spiel verbunden hat und wie er uns bitten kann aufzuhören. Ein Crawler, der `ANSI` antwortet und +sonst nichts, ist von Haus aus anonym, und dafür gibt es keinen guten Grund. + +## Was wir messen + +Server, die TTYPE mit uns ausgehandelt haben. Beachten Sie, dass dies eine der wenigen Optionen ist, +bei denen *wir* die gefragte Seite sind; ein Wert hier ist also eine Zählung der Server, die +überhaupt gefragt haben. diff --git a/content/reference/ja/client-atlantis.md b/content/reference/ja/client-atlantis.md new file mode 100644 index 0000000..730bf87 --- /dev/null +++ b/content/reference/ja/client-atlantis.md @@ -0,0 +1,46 @@ +--- +kind: client +slug: atlantis +title: Atlantis +summary: macOS専用のクライアント。長命で、長らくベータのままです。スクリプト機能はもう動かないと文書化されており、それがこの節で唯一の正直な「いいえ」です。 +home: https://www.riverdark.net/atlantis/ +platform: macOS +capability: screen reader | unknown | +capability: TLS | yes | https://www.riverdark.net/atlantis/history.php +capability: UTF-8 | yes | https://www.riverdark.net/atlantis/history.php +capability: MCCP | yes | https://www.riverdark.net/atlantis/history.php +capability: GMCP | unknown | +capability: MSDP | unknown | +capability: ATCP | unknown | +capability: MXP | unknown | +capability: MSP | unknown | +capability: scripting | no | https://www.riverdark.net/atlantis/ +see-also: clients/mudlet +see-also: protocols/charset +--- + +AtlantisはmacOSのネイティブなクライアントで、Mac OS X 10.3の頃から存在し、Catalinaの時代に64ビットへ +更新されました。RFC 2066の文字セットのネゴシエーションとUnicodeを扱い、これはその年齢から想像される +よりも優れています。MCCPとSSLにも対応しています。 + +## この節で唯一の「いいえ」 + +スクリプト機能はCamelBonesのブリッジを介したPerlでしたが、プロジェクト自身のホームページが、それはもう +動かないと述べています — AppleによるPerlの扱いが変わり、ライブラリの作者は数年前に亡くなりました。 +これは*出典のある不在*であって、不明とは別のものです。そしてクライアントの節全体で、それを持つセルは +ここだけです。ほかのどこでも、正直な答えは「確かめられなかった」でした。 + +## 確かめられなかったすべて + +バージョンの履歴は完全な形で公開されており、**MCCP**、**SSL**、**文字セットのネゴシエーション**に +触れています — そしてGMCP、MSDP、ATCP、MSPには一度も触れていません。MXPは一度だけ、1.0.0より後の +バージョンに向けたものとして現れますが、そのバージョンは来ていません。 + +スクリプトのAPIにはPerlの `Atlantis::Speak()` という呼び出しがあり、これをスクリーンリーダー対応と +読むのは簡単でしょう。そうではありません。プロジェクト自身が動かないと述べているスクリプトの仕組みの +中の、スクリプトから呼ぶ音声読み上げです。VoiceOver、「accessible」、「screen reader」のいずれも、 +ホームページにも、ダウンロードのページにも、完全なバージョン履歴にも、アーカイブされたユーザーガイドにも +現れません。 + +現在のダウンロードは0.9.9.8で、名目上はまだベータであり、リリース日はサイトのどこにも公開されて +いません。 diff --git a/content/reference/ja/client-beipmu.md b/content/reference/ja/client-beipmu.md new file mode 100644 index 0000000..12f3193 --- /dev/null +++ b/content/reference/ja/client-beipmu.md @@ -0,0 +1,47 @@ +--- +kind: client +slug: beipmu +title: BeipMU +summary: この趣味のMUSH側に向けたWindowsのクライアント。出力ウィンドウにスクリーンリーダー対応があり、MXPではなくPuebloに対応します。 +home: https://beipdev.github.io/BeipMU/ +platform: Windows +capability: screen reader | yes | https://github.com/BeipDev/BeipMU/blob/master/Assets/Changes.txt +capability: TLS | yes | https://beipdev.github.io/BeipMU/ +capability: UTF-8 | yes | https://beipdev.github.io/BeipMU/ +capability: MCCP | unknown | +capability: GMCP | yes | https://github.com/BeipDev/BeipMU/blob/master/Documentation/GMCP.md +capability: MSDP | unknown | +capability: ATCP | unknown | +capability: MXP | unknown | +capability: MSP | unknown | +capability: scripting | yes | https://beipdev.github.io/BeipMU/ +see-also: clients/mushclient +see-also: clients/potato +see-also: collaborative-roleplay +--- + +BeipMUはMITライセンスのWindowsのクライアントで、活発にリリースされており、戦闘MUDではなくMUSH風の +遊び方を念頭に作られた数少ないものの一つです — 複数の入力ウィンドウ、スポーンウィンドウ、そして長い +段落を前提としたテキストエンジン。スクリプトは既定でJavaScriptで、ほかのActiveScriptのエンジンも +使えます。 + +## アクセシビリティ + +出力ウィンドウはWindowsの `IAccessible` インターフェースを実装しています。これは視覚に障害のある +プレイヤーにとっての使いやすさへ向けた一歩として、意図的に加えられたものです。また音声読み上げのための +**Speak**というトリガーの動作があります。特定のスクリーンリーダーはどこにも名指しされておらず、 +ドキュメントにアクセシビリティの章はありません。 + +調べに行くなら注意が一つあります。プロジェクト自身のドキュメントのあるページは、BeipMUは音声合成を +使えないと今も述べています。そのページは古くなっています — 変更履歴も、メンテナー自身のissueへの +コメントも、どちらもそれより後のものです。 + +## このクライアントについて起こしやすい2つの誤り + +**BeipMUが実装しているのはMCMPであって、MSPではありません。** 両者は名前も目的も似た、別のプロトコル +です。一方を他方として読めば、誰もしていない主張をこの表に載せることになります。だからMSPの行は不明と +しています。 + +**対応しているのはPuebloであって、MXPではありません。** PuebloはMUDの中でHTMLを使う古いほうの方式で、 +MXPは後のほうです。BeipMUは基本的なPuebloのスタイルとクリックできるリンクを文書化しています。MXPに +ついては、どちらとも確かめられませんでした。 diff --git a/content/reference/ja/client-blightmud.md b/content/reference/ja/client-blightmud.md new file mode 100644 index 0000000..7cd7484 --- /dev/null +++ b/content/reference/ja/client-blightmud.md @@ -0,0 +1,47 @@ +--- +kind: client +slug: blightmud +title: Blightmud +summary: Rustで書かれた現代的なターミナルのクライアント。Luaのスクリプト、内蔵の音声読み上げ、そしてサーバーに自らを知らせるスクリーンリーダーモードを備えています。 +home: https://github.com/Blightmud/Blightmud +platform: Linux +platform: macOS +platform: Windows (WSL only) +capability: screen reader | yes | https://github.com/Blightmud/Blightmud +capability: TLS | yes | https://github.com/Blightmud/Blightmud +capability: UTF-8 | yes | https://github.com/Blightmud/Blightmud +capability: MCCP | yes | https://github.com/Blightmud/Blightmud +capability: GMCP | yes | https://github.com/Blightmud/Blightmud +capability: MSDP | yes | https://github.com/Blightmud/Blightmud +capability: ATCP | unknown | +capability: MXP | unknown | +capability: MSP | unknown | +capability: scripting | yes | https://github.com/Blightmud/Blightmud +see-also: clients/tintin +see-also: clients/mudlet +see-also: protocols/ttype +--- + +BlightmudはRustで書かれたターミナルのクライアントで、GPL 3、そしてこの節で最も活発にリリースされて +いるクライアントの一つです。スクリプトはLuaです。ターミナル専用で、ネイティブのWindowsビルドはなく、 +Windowsの利用者はWSLの下で動かします。 + +## アクセシビリティ + +Blightmudにはここで3つの別々の要素があり、これは表の1行が担える以上のものです。 + +- **スクリーンリーダーに配慮したモード**(`--reader-mode`、または `reader_mode` の設定)。ターミナルの + UIを、リーダーが追える形に変えます。ステータス領域には対応していません。 +- **内蔵の音声読み上げ**。オプションのコンパイルとして提供され、スクリプトから使えるLuaのAPIが付きます + — 一致した行が読み上げられないようにする `tts.gag()` もあります。ドキュメントは、このTTSを + スクリーンリーダーと並べて動かすのが常に幸せな組み合わせとは限らない、と率直に書いています。 +- **MTTSの自動通知**。リーダーモードのとき、またはTTSを有効にしているとき、サーバーへ伝える自分自身の + 情報に `MTTS_SCREEN_READER` を加えるので、気にかけるゲームは対応を変えられます。 + +TinTin++と同じく、特定のスクリーンリーダーは名指しされていないので、これは文書化されたモードであって、 +ある製品との互換性が試験されたということではありません。 + +## 表が不明としている箇所 + +**MXP**、**MSP**、**ATCP**は、プロジェクトのREADMEにも同梱のヘルプにもまったく現れません。**MCCP**は +v2として文書化されており、v1も扱えるかどうかは確かめられませんでした。 diff --git a/content/reference/ja/client-mudlet.md b/content/reference/ja/client-mudlet.md new file mode 100644 index 0000000..6a1f7df --- /dev/null +++ b/content/reference/ja/client-mudlet.md @@ -0,0 +1,54 @@ +--- +kind: client +slug: mudlet +title: Mudlet +summary: クロスプラットフォームでLuaによるスクリプトを備え、この節でスクリーンリーダー対応が最も丁寧に文書化されているクライアント。 +home: https://www.mudlet.org/ +platform: Windows +platform: macOS +platform: Linux +capability: screen reader | yes | https://wiki.mudlet.org/w/Manual:Screen_Readers +capability: TLS | yes | https://wiki.mudlet.org/w/Manual:Supported_Protocols +capability: UTF-8 | yes | https://wiki.mudlet.org/w/Manual:Supported_Protocols +capability: MCCP | unknown | +capability: GMCP | yes | https://wiki.mudlet.org/w/Manual:Supported_Protocols +capability: MSDP | yes | https://wiki.mudlet.org/w/Manual:Supported_Protocols +capability: ATCP | yes | https://wiki.mudlet.org/w/Manual:Supported_Protocols +capability: MXP | yes | https://wiki.mudlet.org/w/Manual:Supported_Protocols +capability: MSP | yes | https://wiki.mudlet.org/w/Manual:Supported_Protocols +capability: scripting | yes | https://github.com/Mudlet/Mudlet +see-also: clients/blightmud +see-also: clients/tintin +see-also: protocols/gmcp +see-also: connecting +--- + +Mudletはグラフィカルなクライアントで、マッパー、パッケージの仕組み、そして自身の機能の大半がそれに +対して書かれているLuaのAPIを備えています。GPLで、活発にリリースされており、現代の戦闘MUDを始める人への +定番の推薦です。 + +## アクセシビリティ + +これはこの節で最も強い、文書に裏づけられた事例を持つクライアントであり、ここで「文書化されている」が +何を意味するのかを書き出しておく価値があります。珍しいことだからです。 + +Mudletには**スクリーンリーダーについてのマニュアルの章**があり、OSごとのページではWindowsのNarrator、 +NVDA、JAWS、LinuxのOrca、macOSのVoiceOverが名指しされています。クライアント内には `mudlet access on` +というコマンドがあり、届いたゲームのテキストをリーダーを通じて読み上げる選択肢もあります。さらに、 +スクリーンリーダーの使用をMTTSでサーバーへ通知する設定もあるので、望むゲームは対応を変えられます。 + +うまく動かないところについても率直です。自身のWindowsのページは、JAWSは他のリーダーのようには出力 +ウィンドウを読まないと述べ、代わりにNarratorかNVDAを勧めています。自分のアクセシビリティ対応が実用に +ならない場合を公開するプロジェクトは、チェックマークを公開するプロジェクトより良い情報を与えて +くれます。 + +## 表が不明としている箇所 + +**MCCP**。MudletのソースはMCCPのv1とv2を実装していますが、マニュアルの対応プロトコルのページはそれを +挙げておらず、この節の規則は、機能の主張はプロジェクト自身のドキュメントを典拠にする、というものです。 +ヘッダーから定数を読み出すのは同じ行為ではないので、このセルは不明としています。 + +## エンコーディングについての注記 + +Mudletの既定のサーバーデータのエンコーディングはUTF-8ではなくASCIIであり、CHARSETのネゴシエーションは +4.10で入りました。新しいプロファイルでゲームのテキストが化けるなら、まず見るべきはその設定です。 diff --git a/content/reference/ja/client-mushclient.md b/content/reference/ja/client-mushclient.md new file mode 100644 index 0000000..efd3541 --- /dev/null +++ b/content/reference/ja/client-mushclient.md @@ -0,0 +1,51 @@ +--- +kind: client +slug: mushclient +title: MUSHclient +summary: 長く定着しているWindowsのクライアント。5つのスクリプト言語、プロトコル対応の大半が置かれているプラグインのアーキテクチャ、そして緩やかになったリリースの歴史。 +home: https://www.mushclient.com/ +platform: Windows +platform: Linux (Wine) +capability: screen reader | unknown | +capability: TLS | unknown | +capability: UTF-8 | unknown | +capability: MCCP | yes | https://www.mushclient.com/mushclient/mccp.htm +capability: GMCP | unknown | +capability: MSDP | unknown | +capability: ATCP | yes | https://www.mushclient.com/gmcp +capability: MXP | yes | https://www.mushclient.com/mushclient/doc/general/features.html +capability: MSP | yes | https://github.com/nickgammon/mushclient/blob/master/plugins/msp.xml +capability: scripting | yes | https://www.mushclient.com/mushclient/doc/general/features.html +see-also: clients/mudlet +see-also: clients/potato +see-also: protocols/mccp +--- + +MUSHclientはNick GammonによるWindowsのクライアントで、MITライセンス、そして長い期間、Windowsを使う人に +とっての既定の答えでした。Lua、VBScript、JScript、PerlScript、Pythonでスクリプトが書け、その機能の +多くはコアではなくプラグインが担っています — これは本物のアーキテクチャ上の選択であり、上の表の +いくつもの行が見た目より答えにくい理由でもあります。 + +最後にタグの付いたリリースは**2019年3月の5.06**です。リポジトリには今もコミットが続いており、出荷されて +いない5.07のリリースノートも存在します。 + +## これほど多くの行が不明としている理由 + +どれも正直な答えが「確かめられなかった」という場合であり、その理由はそれぞれ違います。 + +- **GMCP** — プロジェクト自身のこれについてのページが示しているのは、あなたが書けるであろう*例*としての + プラグインであって、クライアントが持つ機能ではありません。それは対応を出荷することとは違うので、 + このセルは「はい」ではなく不明です。 +- **TLS** — 文書化されている方法は外部の `stunnel` プロセスです。OpenSSLによるTLSを加えるコミットは + 2026年にmasterブランチへ入りましたが、どのリリースにも含まれていないので、今日利用者がインストール + できるもので指し示せるものがありません。 +- **UTF-8** — CHARSETのネゴシエーションは未リリースの5.07のノートに現れますが、出荷済みのバージョンの + ドキュメントには、当サイトが探した限りどこにもありません。 +- **MSDP** — どちらとも何もありません。 +- **スクリーンリーダー** — WindowsのSAPIを使う音声読み上げのプラグインがクライアントに同梱されて + いますが、それはスクリーンリーダー対応と同じものではありません。マニュアルにアクセシビリティの節は + なく、作者は自身のフォーラムで、出力ウィンドウがリーダーにとって扱いにくい理由を説明しています。 + 現在行という概念がないのです。答えを確かめられなかったので、表も答えを出していません。 + +このどれも*いいえ*ではありません。いくつかは十分に「はい」でありうるのに、当サイトがそれを示せなかった +というだけです。 diff --git a/content/reference/ja/client-potato.md b/content/reference/ja/client-potato.md new file mode 100644 index 0000000..e9bd8cc --- /dev/null +++ b/content/reference/ja/client-potato.md @@ -0,0 +1,46 @@ +--- +kind: client +slug: potato +title: Potato MUSHclient +summary: MUSHのプレイヤーのために書かれた、クロスプラットフォームのTcl/Tkクライアント。エンコーディングへの対応は良好で、ドキュメントはプロトコルの大半について何も語りません。 +home: https://www.potatomushclient.com/ +platform: Windows +platform: Linux +platform: macOS +capability: screen reader | unknown | +capability: TLS | yes | https://github.com/potatomushclient/potato/wiki/ConfigureWorldsBasics +capability: UTF-8 | yes | https://github.com/potatomushclient/potato/wiki/Features +capability: MCCP | unknown | +capability: GMCP | unknown | +capability: MSDP | unknown | +capability: ATCP | unknown | +capability: MXP | unknown | +capability: MSP | unknown | +capability: scripting | yes | https://github.com/potatomushclient/potato/wiki/FAQs +see-also: clients/beipmu +see-also: clients/mushclient +see-also: collaborative-roleplay +--- + +PotatoはMUSHでの遊びのために作られたTcl/Tkのクライアントです — 複数のワールド、スポーンウィンドウ、 +そして戦闘のコマンドではなくポーズを打っていることを前提にした既定値の一式。同じソースからWindows、 +Linux、macOSで動き、macOSのビルドはたいてい1つか2つバージョンが遅れています。 + +文字エンコーディングをネゴシエートし、完全なUnicodeを話します。この趣味のMUSH側にとっては、実際上 +これが最も重要な機能です。 + +文書化された制限が一つあります。最初からSSLであるポートへの接続には対応していますが、設定のページ自身 +が、STARTTLS方式のネゴシエートされるSSLには**対応していない**と述べています。 + +## 6つの行が不明としている理由 + +当サイトは、プロジェクトのホームページ、ダウンロードのページ、103個あるウィキのヘルプファイルすべて、 +そしてソースツリー全体を、GMCP、MSDP、MCCP、MXP、MSP、ATCPについて検索しました。そのどれについても、 +文書化された記述はありません。いくつかに触れる*コード*はありますが、この節はコードを機能の主張には +変えません — ヘッダーにある定数を根拠に「はい」と言う表は、プロジェクトが一度もしなかった約束を +することになります。 + +スクリーンリーダーの行も、同じやり方でたどり着いた同じ答えです。プロジェクトが公開しているすべてを +対象に、大文字小文字を区別せず「screen reader」「text-to-speech」、NVDA、JAWS、VoiceOver、 +「accessibility」「visually impaired」「blind」を掃いたところ、何も出てきませんでした。これは +ソフトウェアについての発見ではありません。 diff --git a/content/reference/ja/client-tintin.md b/content/reference/ja/client-tintin.md new file mode 100644 index 0000000..e8e6b00 --- /dev/null +++ b/content/reference/ja/client-tintin.md @@ -0,0 +1,52 @@ +--- +kind: client +slug: tintin +title: TinTin++ +summary: 独自のスクリプト言語を持つターミナルのクライアント。電話機を含むあらゆるプラットフォームで動き、文書化されたスクリーンリーダーモードがあります。 +home: https://tintin.mudhalla.net/ +platform: Linux +platform: macOS +platform: Windows +platform: Android +platform: iOS +capability: screen reader | yes | https://tintin.mudhalla.net/manual/screen_reader.php +capability: TLS | yes | https://github.com/scandum/tintin +capability: UTF-8 | yes | https://github.com/scandum/tintin +capability: MCCP | yes | https://tintin.mudhalla.net/ +capability: GMCP | yes | https://tintin.mudhalla.net/manual/event.php +capability: MSDP | yes | https://tintin.mudhalla.net/manual/msdp.php +capability: ATCP | unknown | +capability: MXP | unknown | +capability: MSP | unknown | +capability: scripting | yes | https://github.com/scandum/tintin +see-also: clients/blightmud +see-also: clients/mudlet +see-also: protocols/msdp +see-also: protocols/ttype +--- + +TinTin++はコマンドラインのクライアントで、GPL 3、活発にリリースされており、ここにある何よりも多くの +場所で動きます — AndroidとiOSも含めて。スクリプト言語は独自のもので、簡潔で、非常に多くのことが +できます。他のクライアントがGUIでやることのかなりの部分が、ここでは `#config` の1行です。 + +同じ作者が**MSSP**と**MSDP**のプロトコル仕様を維持しており、この節のプロトコルのページの多くが同じ +サイトを典拠にしているのは、そのためです。 + +## アクセシビリティ + +TinTin++には**スクリーンリーダーモード**(`#config screen reader on`、または起動時の `-s`)のための +専用のマニュアルページがあります。有効にすると2つのことが起きます。読み上げても意味をなさない視覚的な +要素を取り除くか変えること、そしてスクリーンリーダーの使用を[MTTS](/reference/protocols/ttype)を通じて +サーバーへ伝えることで、ゲームは自分の出力を合わせられます。 + +これは文書化されたモードであって、特定のリーダーで試験したという主張ではありません — そのページに +製品名は挙がっていません。動作するリーダーを名指しするクライアントよりは明確に弱い証拠であり、何も +ないよりは明確に強い証拠です。 + +## 表が不明としている箇所 + +**MXP**と**MSP**はどちらも、プロジェクトのサイトにコミュニティのスクリプトがありますが、スクリプトが +あることはクライアントがそのプロトコルに対応していることではありません — MXPのものは、すべてのMUDで +動くとは限らないとはっきり述べています。どちらについても、ネイティブの対応は確かめられませんでした。 +**ATCP**はどちらとも何も見つかりませんでした。なお、ATCPはおおむねGMCPに取って代わられており、GMCPには +TinTin++が対応しています。 diff --git a/content/reference/ja/client-tinyfugue.md b/content/reference/ja/client-tinyfugue.md new file mode 100644 index 0000000..900328e --- /dev/null +++ b/content/reference/ja/client-tinyfugue.md @@ -0,0 +1,43 @@ +--- +kind: client +slug: tinyfugue +title: TinyFugue +summary: 古典的なUNIXのターミナルクライアント。上流は2007年以降リリースしておらず、メンテナンスされているフォークがそれを前へ運んでいます。 +home: https://tinyfugue.sourceforge.net/ +platform: Linux +platform: macOS +platform: BSD +capability: screen reader | unknown | +capability: TLS | yes | https://tinyfugue.sourceforge.net/ +capability: UTF-8 | unknown | +capability: MCCP | yes | https://tinyfugue.sourceforge.net/ +capability: GMCP | unknown | +capability: MSDP | unknown | +capability: ATCP | unknown | +capability: MXP | unknown | +capability: MSP | unknown | +capability: scripting | yes | https://tinyfugue.sourceforge.net/ +see-also: clients/tintin +see-also: clients/blightmud +--- + +TinyFugue — 「tf」 — は、MUSHの世界の大きな部分が20年にわたって使ったターミナルのクライアントで、入力と +出力の別々のペイン、独自のマクロ言語、そして競合のいくつかより長生きした一連の習慣を備えています。 + +**上流は休眠しています**。最後のリリースは2007年1月の5.0 beta 8です。今もビルドが通り、今も動きます。 + +メンテナンスされているフォーク *TinyFugue Rebirth* は活発にリリースされており、GMCP、ATCP、ICUによる +ワイド文字への対応、そして本来のマクロ言語と並ぶPythonとLuaのスクリプトを加えています。上の表が説明して +いるのは**上流**です。「TinyFugue」が指すのはそちらだからです。今日インストールするなら、まずフォークを +見てみる価値があります。 + +## このクライアントのドキュメントにある罠 + +上流のドキュメントには**「non-visual mode」**という項目があります。これは支援技術についてのものでは +なく — 入力を最下行に閉じ込めておくことに関するものです — スクリーンリーダーにも、音声にも、視覚に +障害のある利用者にも、どこにも触れていません。キーワード検索で組み立てた機能表なら、そのファイル名を +「はい」に変えてしまうでしょう。この表は不明としています。ドキュメントが裏づけているのはそこまでだから +です。 + +UTF-8も同じ形の答えです。文書化されているエンコーディングへの対応は8ビットのISO 8859の文字セットに +ついてのもので、UTF-8についての上流の記述は、どちらとも見つかりませんでした。 diff --git a/content/reference/ja/client-vipmud.md b/content/reference/ja/client-vipmud.md new file mode 100644 index 0000000..e3c5319 --- /dev/null +++ b/content/reference/ja/client-vipmud.md @@ -0,0 +1,40 @@ +--- +kind: client +slug: vipmud +title: VIP Mud +summary: 目の見えないプレイヤーのために一から作られた商用のWindowsクライアント。7つのスクリーンリーダーを名指しする一方で、プロトコル対応についてはほとんど何も公開していません。 +home: https://www.gmagames.com/vipmud.shtml +platform: Windows +capability: screen reader | yes | https://www.gmagames.com/vipmud.shtml +capability: TLS | unknown | +capability: UTF-8 | unknown | +capability: MCCP | unknown | +capability: GMCP | unknown | +capability: MSDP | unknown | +capability: ATCP | unknown | +capability: MXP | unknown | +capability: MSP | yes | https://www.gmagames.com/vipmud.shtml +capability: scripting | yes | https://www.gmagames.com/vipmud.shtml +see-also: clients/mudlet +see-also: clients/blightmud +--- + +VIP Mudは、この節で設計の前提が*まるごと*アクセシビリティであるただ一つのクライアントです。商用で — +30ドル、30日間は全機能を試用でき、その後も機能を減らした形で動き続けます — Windowsのプログラムです。 + +ここでは群を抜いて強いアクセシビリティの主張であり、しかも珍しく具体的です。製品ページは**JAWS、 +Window-Eyes、System Access、NVDA、Cobra、SuperNova/Hal、そしてMicrosoft SAPI**を、そのままで動作する +ものとして名指しし、この問題を真剣に考えた人でなければ出てこない機能を説明しています。ウィンドウごと・ +出力の種類ごとに異なる声、スパムを表示はしたまま読み上げからは外すこと、そしてASCIIアートを抑える +いくつもの方法 — ASCIIアートは、MUDがスクリーンリーダーへ送るもののなかで最も敵対的なものです。 + +## 表の残りが空である理由 + +ベンダーが公開しているのがマニュアルではなくマーケティングのページだからです。そこにはGMCP、MSDP、 +MCCP、MXP、ATCP、TLS、文字エンコーディングへの言及が一つもありません。製品を「a Telnet-based client」と +説明して、そこで終わりです。**不明が9つ並ぶことは、そのソフトウェアへの判定ではありません。** 利用 +できる情報源が1ページしかないときに表がこう見えるというだけであり、それを9つの「いいえ」として公開 +すれば、それらすべてを十分にこなしうる製品についての嘘になります。 + +さらに確かめられなかったことが2つあります。現行バージョンのリリース日と、今も活発に開発されているか +どうかです — ベンダーは2025年2月に買収されており、製品ページには2016年の著作権表示が載っています。 diff --git a/content/reference/ja/codebase-aresmush.md b/content/reference/ja/codebase-aresmush.md new file mode 100644 index 0000000..99f9443 --- /dev/null +++ b/content/reference/ja/codebase-aresmush.md @@ -0,0 +1,33 @@ +--- +kind: codebase +slug: aresmush +title: AresMUSH +summary: Rubyで書かれた現代的なロールプレイ用サーバー。Webのフロントエンドと、softcodeではなく機能として組み込まれたシーン用ツールを備えています。 +codebase: AresMUSH +home: https://aresmush.com/ +see-also: collaborative-roleplay +see-also: codebases/pennmush +see-also: codebases/evennia +--- + +AresMUSHは、**共同ロールプレイ**をまっすぐに狙ったサーバーとしては広く使われているなかで最も新しく、 +自らが後を継ぐTinyMUSHの系統とは異なる立場を取っています。PennMUSHのゲームがシーンシステムや +キャラクターシート、ジョブキューを、その場にいた誰かが書いたsoftcodeから組み上げるのに対し、Aresは +それらを機能として同梱し、ゲームのスタッフにはプログラムするのではなく設定することを期待します。 + +**Webポータル**が付属します — キャラクターのウィキ、シーンのログ、フォーラム、そしてゲーム本体まで、 +すべてブラウザーから到達できます。ログを後から読むことが当たり前のジャンルにとって、これは程度ではなく +種類の違いと言えるほどの差です。 + +設定はYAMLで行い、拡張はRubyのプラグインです。プレイヤー向けのゲーム内プログラミング言語はなく、 +そこが取引になっています。渡されるロープは短く、ロープによる怪我も少なく、そしてMUSHという系統の名前の +由来でもある、即興的な建築の文化もまた少なくなります。 + +## 外から見たときの姿 + +MSSPはありません。ログイン前の `WHO` には応答し、その答えは素の数値ではなく**プレイヤーごとの一覧**で、 +当サイトのパーサーはその構造から数えます。実測したゲームでは、telnetオプションは一つも +ネゴシエートされませんでした。 + +新しいロールプレイのゲームでこれとPennMUSHのどちらを選ぶか迷っているなら、問いはおおよそ、設定する +システムが欲しいのか、それとも書くシステムが欲しいのか、ということになります。 diff --git a/content/reference/ja/codebase-circlemud.md b/content/reference/ja/codebase-circlemud.md new file mode 100644 index 0000000..efe874b --- /dev/null +++ b/content/reference/ja/codebase-circlemud.md @@ -0,0 +1,27 @@ +--- +kind: codebase +slug: circlemud +title: CircleMUD +summary: 教材に使えるほど丁寧に文書化されたDikuMUDの派生。膨大にフォークされ、今も動いています。 +codebase: CircleMUD +home: https://www.circlemud.org/ +see-also: codebases/dikumud +see-also: codebases/tbamud +see-also: codebases/rom +--- + +CircleMUDはDikuMUDの派生ですが、その際立った特徴はゲームの仕組みではありませんでした。**ドキュメント** +でした。Jeremy Elsonのリリースは整っていて、コメントが付き、コーディングガイドが添えられており、その結果、 +人々がCを学び、そこからMUDを動かし、まず何かをリバースエンジニアリングしなくてもフォークできる +コードベースになりました。 + +その帰結として、稼働しているゲームの非常に多くが何世代も離れたCircleの派生であり、しかもプレイヤーの目に +触れる場所にはその名前がどこにも現れないことがよくあります。 + +Circle本体の開発はとうに終わっており、その続きが**tbaMUD**です。今日メンテナンスされているCircleの +ゲームは、たいていtbaMUDとしてメンテナンスされています。 + +## 外から見たときの姿 + +MSSPがあり、要求すれば応答します。ログイン画面に `WHO` はありません — Dikuのファミリーは一般に +それを提供しないので、ログイン画面しか読まないディレクトリはここでは何も見えません。 diff --git a/content/reference/ja/codebase-cobramush.md b/content/reference/ja/codebase-cobramush.md new file mode 100644 index 0000000..df9e654 --- /dev/null +++ b/content/reference/ja/codebase-cobramush.md @@ -0,0 +1,23 @@ +--- +kind: codebase +slug: cobramush +title: CobraMUSH +summary: 独自のdivisionと権限のモデルを持つPennMUSHのフォーク。稼働数は少ないものの、今も応答しています。 +codebase: CobraMUSH +home: https://cobramush.org/ +see-also: codebases/pennmush +see-also: codebases/rhostmush +--- + +CobraMUSHはPennMUSHからフォークし、*division* モデルを加えました。親が使うwizard/royaltyというフラットな +区別に代えて、委譲できる権限を備えた管理権限の階層を置くものです。スタッフの権限をすべて渡すことなく、 +その一部だけを渡したいゲームが、その支持層です。 + +PennMUSH向けに書かれたsoftcodeはたいてい動き、違いはまさにこのフォークの主題だった領域に集中しています。 + +## 外から見たときの姿 + +MSSPはなく、ログイン前の `WHO` は機能し、実測したゲームではtelnetオプションはまったく +ネゴシエートされませんでした。最後の点は批判ではありません。何もネゴシエートしないサーバーは +ネゴシエーションを間違えようのないサーバーであり、素のソケットを流れる素のテキストは、この趣味の +どのクライアントも扱えるものです。 diff --git a/content/reference/ja/codebase-coffeemud.md b/content/reference/ja/codebase-coffeemud.md new file mode 100644 index 0000000..81a202d --- /dev/null +++ b/content/reference/ja/codebase-coffeemud.md @@ -0,0 +1,27 @@ +--- +kind: codebase +slug: coffeemud +title: CoffeeMUD +summary: Javaで書かれたMUDサーバー。当サイトが探査したなかで最大のMSSPレポートを持ち、対応プロトコルの幅も異例の広さです。 +codebase: CoffeeMUD +home: https://www.coffeemud.net/ +see-also: codebases/dikumud +see-also: protocols/mssp +--- + +CoffeeMUDはJavaのMUDサーバーで、機能の面が異例に広く — 自前のWebサーバー、メール、フォーラム、そして +大きなクラスとスキルのシステムを同梱しています — この趣味では数少ない、Cで書かれていないサーバーの +一つです。 + +活発にメンテナンスされており、カタログのこの一帯の基準からすれば、それは声に出して言う価値のあることです。 + +## 外から見たときの姿 + +MSSPと**MCCP2**があります。さらにCoffeeMUDは、試した20のサーバーのうち*平文*の `MSSP-REQUEST` 形式にも +応答した3つだけのサーバーの一つです — telnetオプションより古く、今でもときおり見かける変種です。 + +そのMSSPレポートは当サイトが実測したなかで最大で、**47フィールド**あり、9つの別々のポートについて +`PORT` が9回別々に報告されています。これは壊れた形式ではありません。MSSPの変数はリストであり、多値の +`PORT` を一つの文字列に潰してしまうクローラーは、`"80" "23" "4201"` から整数 `80234201` を作り出します +— これは当プロジェクトが世に出し、そして直したバグであり、ここのパーサーが値を終始リストのまま保つ +理由でもあります。 diff --git a/content/reference/ja/codebase-dikumud.md b/content/reference/ja/codebase-dikumud.md new file mode 100644 index 0000000..606251d --- /dev/null +++ b/content/reference/ja/codebase-dikumud.md @@ -0,0 +1,35 @@ +--- +kind: codebase +slug: dikumud +title: DikuMUD +summary: 戦闘型MUDファミリーの根。レベル、クラス、装備、エリアファイル — そして一世代分の派生を形づくったライセンス。 +codebase: DikuMUD +home: https://dikumud.com/ +see-also: codebases/circlemud +see-also: codebases/rom +see-also: codebases/smaug +see-also: mush-mud-muck-moo +--- + +DikuMUDはコペンハーゲン大学のDatalogisk Institutで書かれ、1991年に公開されたもので、人が何の限定もなく +「MUD」と言うときに指すもののほとんどの祖先です。レベル、キャラクタークラス、ヒットポイント、mob、 +装備スロット、ビルダーがオフラインで書くエリアファイル形式 — 語彙のすべてがここから来ており、Dikuの +ソースを一度も見たことのないゲームでも、その形を受け継いでいます。 + +そのライセンスも話の一部です。Dikuは無償で使えましたが、アクセスに課金することを禁じ、元のクレジットを +表示するよう求めました。この条項こそ、そこから何度もフォークを重ねたゲームのログイン画面に「Dikuの +クレジット」が現れる理由です。 + +直系の子孫 — **Merc**、続いて**ROM**、**CircleMUD**、**SMAUG**、**tbaMUD**、そのほか何十も — は、 +これまで存在したあらゆるMUDの一覧の大きな割合を占めています。 + +## 外から見たときの姿 + +Dikuのファミリーは**MSSP**のファミリーです。MUSH側がログイン画面の `WHO` を通じて接続数を公開し、MSSPは +まったく提供しないのに対して、Diku系のサーバーは圧倒的にtelnetオプション70へ構造化されたレポートで +応答します。ここに載る数値は、そこから来ています。 + +**MCCP2** — ストリームの圧縮 — もこのファミリーではよくあるもので、これをネゴシエートしながら +ストリームを展開できないクライアントは、接続画面をまるごとバイナリのノイズとして受け取ることになる、と +知っておく価値があります。それは当プロジェクト自身のtelnetライブラリにあった実在の不具合で、すでに +修正されています。[MCCP](/reference/protocols/mccp)を参照してください。 diff --git a/content/reference/ja/codebase-evennia.md b/content/reference/ja/codebase-evennia.md new file mode 100644 index 0000000..6daea85 --- /dev/null +++ b/content/reference/ja/codebase-evennia.md @@ -0,0 +1,33 @@ +--- +kind: codebase +slug: evennia +title: Evennia +summary: 完成したゲームではなく、Pythonのフレームワーク。2つのEvenniaのゲームに共通するものが配管だけ、ということもありえます。 +codebase: Evennia +home: https://www.evennia.com/ +see-also: codebases/aresmush +see-also: collaborative-roleplay +see-also: protocols/gmcp +--- + +Evenniaはゲームではなく**MU\*のフレームワーク**です — それがこれについて最初に知るべきことであり、 +Evenniaのゲーム同士を比べることを無意味にしている当のものでもあります。DjangoとTwistedの上に建てられた +Pythonのライブラリで、アカウント、オブジェクト、部屋、コマンド、永続化の層、そしてネットワークスタックを +与えたうえで、ゲームそのものはあなたが書くことを期待します。 + +その帰結として、「Evenniaで動いている」は、「PennMUSHで動いている」よりもはるかに少ないことしか +ゲームについて教えてくれません。Evennia上には戦闘MUDもあれば、ロールプレイのゲームもあり、両者は語彙を +共有していません。2つのEvenniaのゲームに共通するコマンドが一つもない、ということもありえます。 + +すでにPythonを知っている開発者にとっては、何もないところから動く世界までの最短経路であり、2010年代 +半ば以降の新しいゲームのかなりの部分がここから始まっています。 + +## 外から見たときの姿 + +Evenniaは**MSSP**を提供し、それを通じて接続数を公開します。実測したゲームでは**MCCP2** — 圧縮 — も +ネゴシエートしていました。これはtelnetを真剣に扱ったスタックらしい特徴です。 + +Evenniaはフレームワークなので、あるゲームが何をネゴシエートするかは、部分的にはそのゲームの判断です。 +プロトコルの各ページにある採用状況の数値は、サーバーが実際に当サイトへ提供したものの件数であって、 +フレームワークにできることの件数ではありません。そしてEvenniaでは、その2つは多くの場合よりも +離れています。 diff --git a/content/reference/ja/codebase-fluffos.md b/content/reference/ja/codebase-fluffos.md new file mode 100644 index 0000000..267f776 --- /dev/null +++ b/content/reference/ja/codebase-fluffos.md @@ -0,0 +1,33 @@ +--- +kind: codebase +slug: fluffos +title: FluffOS +summary: メンテナンスが続くMudOSの後継であり、生き残っているLPMudの多くが動いているドライバー。ゲームはCではなくLPCで書かれます。 +codebase: FluffOS +home: https://www.fluffos.info/ +see-also: codebases/dikumud +see-also: mush-mud-muck-moo +--- + +LPMudの伝統は、Dikuとは違うところで世界を分けます。**ドライバー** — オブジェクト指向のインタプリタを +走らせるCのプログラム — があり、そして**mudlib** があります。mudlibはゲームまるごとで、**LPC** で書かれ、 +ドライバーが読み込みます。部屋も戦闘もコマンドもログインの手順も、すべてmudlibのオブジェクトであり、 +ドライバーはそのどれについても知りません。 + +そのためLPMudは、戦闘システムから想像されるよりも精神においてMUSHに近くなります。ゲームはゲームの中に +住む言語で書かれ、ドライバーを共有する2つのLPMudが、それ以外に何も共有していないこともありえます。 + +**MudOS**は長年にわたって支配的なドライバーでした。**FluffOS**はそのメンテナンスの続く続編であり、今日 +稼働しているLPのゲームが乗っている可能性が最も高いものです。よく知られたmudlib — Nightmare、Lima、 +Discworld独自のもの — は、さらに別のプロジェクトです。 + +## 外から見たときの姿 + +実測したFluffOSのゲームでは、MSSPと**MCCP2**がありました。MudOSは、当サイトの調査でMSSPとログイン画面の +`WHO` の*両方*に応答した2つだけのコードベースの一つでしたが、返ってきた `WHO` は数値ではなく +プレイヤーごとの一覧でした。 + +mudlibこそがゲームなので、個々のLPのゲームが何をネゴシエートするかは、ドライバーの判断であると同時に +mudlibの判断でもあります — プロトコルの各ページにある採用状況の数値は、サーバーが実際に当サイトへ +提供したものを数えたものであり、このファミリーについては、他の場合よりコードベースについての信号として +弱くなります。 diff --git a/content/reference/ja/codebase-moo.md b/content/reference/ja/codebase-moo.md new file mode 100644 index 0000000..89396d6 --- /dev/null +++ b/content/reference/ja/codebase-moo.md @@ -0,0 +1,29 @@ +--- +kind: codebase +slug: moo +title: MOO +summary: オブジェクト指向で、すべて内側から編集され、ゲームエンジンであると同時に研究と教育のプラットフォームでもあります。 +codebase: MOO +home: https://www.ipomoea.org/moo/ +see-also: mush-mud-muck-moo +see-also: codebases/muck +--- + +MOO — *MUD, Object-Oriented* — は、「世界が自分自身を編集する」という考えを、この趣味の何よりも遠くまで +押し進めています。最初のサーバーであるLambdaMOOが同梱するのは、小さなCのコアとデータベースです。 +利用者が体験するもののほぼすべては、**MOOの言語で、動いているデータベースの中で、それを使っている人々の +手によって**書かれます。部屋に対応するソースファイルは存在しません。 + +その性質は、MOOにゲームの外での生を与えました。90年代を通じて、教育や会議、研究に使われ — +Diversity University、BioMOO、Jay's House — MOOについての技術文献は、この領域のコードベースにしては +不釣り合いなほど学術的です。 + +今日の稼働数は小さいものの、確かにゼロではなく、残っているサーバーは何十年も連続して動き続けていることが +よくあります。 + +## 外から見たときの姿 + +MSSPはなく、実測したゲームでは当サイトが解釈できる `WHO` もありませんでした。代わりにあったのは、 +接続画面の中の *"one of three players are active"* という一文です — このクローラーに綴り字で書かれた +数値を読む処理があるのは、これが理由です。数字だけを見るパーサーはそこに接続数をまったく見出さず、 +そのゲームを永遠に不明として報告し続けたことでしょう。 diff --git a/content/reference/ja/codebase-muck.md b/content/reference/ja/codebase-muck.md new file mode 100644 index 0000000..3293c1b --- /dev/null +++ b/content/reference/ja/codebase-muck.md @@ -0,0 +1,29 @@ +--- +kind: codebase +slug: muck +title: MUCK +summary: Forth風の独自のゲーム内言語を持つTinyMUDの子孫。MUSH側とは異なる社交の文化を持っています。 +codebase: MUCK +home: https://www.fuzzball.org/ +see-also: mush-mud-muck-moo +see-also: codebases/tinymush +see-also: codebases/moo +--- + +MUCK — 実際にはほぼ常に**Fuzzball MUCK**のこと — は、MUSHの系統の子孫ではなくその兄弟です。どちらも +TinyMUDから来ており、どちらもゲームの中にプログラミング言語を置いています。 + +目に見える違いはその言語です。MUF(*Multi-User Forth*)はスタック指向で、MUSHのsoftcodeとはまるで読み味が +違います。一方に堪能なビルダーも、他方では初心者です。その上には、MUSHならsoftcodeがやることに使われる、 +より小さなインラインの式言語MPIが載っています。 + +文化の面では、MUCKはこの趣味の社交とファンダムの世界の大きな部分の本拠地です。そうしたゲームは、 +始まりと終わりのあるシーンではなく、その場にいることと会話を中心に組み立てられる傾向があり、これは +ロールプレイMUSHの伝統との実際の違いであって、テーマの問題ではありません。 + +## 外から見たときの姿 + +MSSPはありません。ログイン前の `WHO` があり、数値で答えます。実測したゲームではtelnetオプションは +ネゴシエートされませんでした — そして調査から、覚えておく価値のある細部が一つあります。その `WHO` の +返答は末尾が空白で終わり、改行がありませんでした。素朴なパーサーが何も報告しなくなるのは、こういうものが +原因です。 diff --git a/content/reference/ja/codebase-pennmush.md b/content/reference/ja/codebase-pennmush.md new file mode 100644 index 0000000..d494a30 --- /dev/null +++ b/content/reference/ja/codebase-pennmush.md @@ -0,0 +1,47 @@ +--- +kind: codebase +slug: pennmush +title: PennMUSH +summary: 最も広く使われているMUSHサーバー。softcode、長いリリースの歴史、そして当サイトの調査でMSSPとログイン前のWHOの両方に応答した2つだけのコードベースの一つ。 +codebase: PennMUSH +home: https://www.pennmush.org/ +see-also: codebases/tinymux +see-also: codebases/rhostmush +see-also: codebases/cobramush +see-also: mush-mud-muck-moo +see-also: protocols/mssp +--- + +PennMUSHは1991年のフォークを経てTinyMUSHから受け継がれたもので、長く続いているロールプレイMUSHの多くが +動かしているサーバーです。その決定的な特徴は**softcode**です。関数型の式言語で、しかるべきビットが +立っている人なら誰でもゲームの内側から編集でき、あるMUSHの振る舞いの大きな部分がそれで書かれています。 +PennMUSHのゲームは、設定されるというより、プレイヤーたちによってプログラムされるものです。 + +バージョンは `1.8.8p0` のように読めます — メジャー、マイナー、そしてパッチレベル — そしてパッチレベルは +よく動きます。パッチレベルがいくつか遅れたバージョンでゲームが動いていることはよくあり、特筆すべき +ことではありません。 + +## 外から見たときの姿 + +PennMUSHは、当サイト自身の38サーバーの調査で、探査する経路の*両方*に応答した2つだけのコードベースの +一つです。求めればMSSPを提供し、ログイン画面で打ち込まれた `WHO` にも答えます。そして実測したゲームでは +その2つが一致していました — これは聞こえるより珍しいことで、そのおかげでPennMUSHは、他のサーバーを +試すときの対照になりました。 + +ログイン前の `WHO` は、便利さを超えた意味を持ちます。MUSHのファミリーがそもそも接続数を公開する手立てが +それだからです — このファミリーの残りの大半は、MSSPをまったく提供しません。この分断こそ、当サイトが +1層ではなく4層を探査する理由であり、それについては[MSSP](/reference/protocols/mssp)を参照してください。 + +現代のPennMUSHではCHARSETのネゴシエーションが普通であり、だからアクセント付きの名前も道中を無事に +生き延びます。 + +## 関連するサーバー + +PennMUSH、**TinyMUX**、**RhostMUSH**、**CobraMUSH**は、共通の祖先と共有された語彙を持つ4つのサーバー +です — 一つを知っているビルダーなら、努力すれば別のもののsoftcodeも読めます。互換性はありません。 +データベースは変換なしにこれらの間を移動しませんし、関数ライブラリも意味のある形で異なります。 + +## SharpMUSH + +PennMUSH互換を目指す.NETによる再実装が、当サイトと同じ作者によって開発中です。このページのどの内容も +そこから実測されたものではなく、カタログにそのゲームはありません。 diff --git a/content/reference/ja/codebase-rhostmush.md b/content/reference/ja/codebase-rhostmush.md new file mode 100644 index 0000000..2f5a7f1 --- /dev/null +++ b/content/reference/ja/codebase-rhostmush.md @@ -0,0 +1,27 @@ +--- +kind: codebase +slug: rhostmush +title: RhostMUSH +summary: 深い権限モデルと大きな組み込み関数群で知られるMUSHサーバー。MSSPはなく、ログイン前のWHOに応答します。 +codebase: RhostMUSH +home: https://github.com/RhostMUSH/trunk +see-also: codebases/pennmush +see-also: codebases/tinymux +see-also: codebases/cobramush +--- + +RhostMUSHは、TinyMUSHから派生した広く使われているサーバーの4番目であり、管理のモデルが最も精緻なもの +です。権限とフラグのシステムは親戚たちよりかなり細かい粒度を持っており、ゲームがこれを選ぶ理由は +たいていそこにあります。 + +組み込みの関数ライブラリは大きく、Rhost向けに書かれたsoftcodeは、他のサーバーにはない関数を使った部分を +書き直さないかぎり、PennMUSHやTinyMUXへきれいには移植できないことがよくあります。 + +## 外から見たときの姿 + +MSSPはありません。ログイン前の `WHO` があり、数値で答えます。CHARSETはネゴシエートされます。 + +この組み合わせ — MSSPはなく、`WHO` は機能する — はMUSHファミリーの署名であり、当サイトがそもそも +ログイン画面を探査する理由です。当サイト自身の調査が示す限り、MSSPのファミリーと `WHO` のファミリーは +ほぼ交わりません。28のコードベースがMSSPを通じて接続数を公開し、7つが `WHO` を通じて公開し、 +両方を通じて公開するのは2つだけです。 diff --git a/content/reference/ja/codebase-rom.md b/content/reference/ja/codebase-rom.md new file mode 100644 index 0000000..a063c48 --- /dev/null +++ b/content/reference/ja/codebase-rom.md @@ -0,0 +1,28 @@ +--- +kind: codebase +slug: rom +title: ROM +summary: Mercの最もよく知られた子孫であり、90年代のMUDの大きな割合がその上に建てられた戦闘エンジン。 +codebase: ROM +see-also: codebases/dikumud +see-also: codebases/smaug +see-also: protocols/mccp +--- + +ROM — *Rivers of MUD* — は**Merc**の派生で、そのMerc自体もDikuMUDの派生ですが、定着したのはこちらでした。 +その戦闘モデル、スキルと呪文のシステム、そしてエリアの形式は、90年代とその後の膨大な数のゲームの +出発点となり、とりわけROM 2.4は、この趣味で最も多くフォークされたソースの一つです。 + +Dikuの系統の他と同じく元のクレジットを表示する要件を引き継いでいるので、系統をほかの方法では特定 +できないゲームでも、ログイン画面にDiku、Merc、ROMの名を挙げていることがよくあります。 + +## 外から見たときの姿 + +実測したゲームでは、MSSP、CHARSET、そして**MCCP2**がありました。 + +ROMは、当プロジェクトが自分自身の圧縮のバグを証明した相手のサーバーです。当サイトの探査はMCCP2を +ネゴシエートし、サーバーは正しく圧縮を始めましたが、依存しているtelnetライブラリはストリームを一度も +展開しませんでした — その結果、接続画面は置換文字の壁として届き、当サイトは短いあいだそれをゲームの側の +落ち度として記録していました。ペイロードは素のzlibの呼び出しできれいに展開でき、それによって話は +曖昧でなくなりました。修正は上流で行われています。顛末は[MCCP](/reference/protocols/mccp)のページに +あります。外から見ると壊れたゲームとまったく同じに見える不具合の、よい例だからです。 diff --git a/content/reference/ja/codebase-smaug.md b/content/reference/ja/codebase-smaug.md new file mode 100644 index 0000000..f062508 --- /dev/null +++ b/content/reference/ja/codebase-smaug.md @@ -0,0 +1,27 @@ +--- +kind: codebase +slug: smaug +title: SMAUG +summary: 大きなテーマ世界のために作られたMercの派生。神々と次元界、そして親の数倍の規模のエリア群を備えています。 +codebase: SMAUG +home: https://www.smaug.org/ +see-also: codebases/dikumud +see-also: codebases/rom +--- + +SMAUG — *Simulated Medieval Adventure multi-User Game* — はRealms of Despairから生まれた、簡素化では +なく肥大の方向に進んだMercの派生です。ROMがMercの戦闘を引き締めたのに対し、SMAUGは足しました。神々、 +次元界、より手の込んだエリアとmobのプログラム、はるかに大きな標準の世界、そしてそれに見合って大きな +ソースツリーを。 + +これを動かしているゲームは大きなテーマものであることが多く、いくつかは25年にわたって連続して +動き続けています。 + +## 外から見たときの姿 + +MSSPがあり、要求すれば応答します。実測したゲームでは、それ以外は何もネゴシエートされませんでした。 + +接続数を比べる人への注意です。当サイトの調査でSMAUGのサーバーはMSSPを通じて報告し、ログイン画面の +`WHO` には答えませんでした。したがってここの数値はゲーム自身のMSSPの `PLAYERS` です — サーバーが行う +主張であり、独立に実測できなかったからこそ額面どおりに受け取っているものです。その数値が2つのどちらから +来たのかは、ゲームのページに表示されます。 diff --git a/content/reference/ja/codebase-tbamud.md b/content/reference/ja/codebase-tbamud.md new file mode 100644 index 0000000..4db660f --- /dev/null +++ b/content/reference/ja/codebase-tbamud.md @@ -0,0 +1,24 @@ +--- +kind: codebase +slug: tbamud +title: tbaMUD +summary: メンテナンスが続くCircleMUDの後継であり、「今日Diku系のMUDを始めたい」への通常の答え。 +codebase: tbaMUD +home: https://tbamud.com/ +see-also: codebases/circlemud +see-also: codebases/dikumud +--- + +tbaMUD — *The Builder Academy MUD* — は、CircleMUD自身の開発が止まったところから引き継ぎ、以来それを +ビルドの通る、パッチの当たった、文書化された状態に保ってきました。自前のフォークではなく既知の良い土台 +からレベルと戦闘のMUDを始めるなら、その土台がこれです。 + +Circleのコードベースの美点をそのまま引き継いでいます。読めるC、文書化されたエリアファイル形式、そして +読者がこれを初めてやると想定したビルダー向けのマニュアルです。The Builder Academy自体も、人々がその上で +建てることを学ぶ教育用のゲームとして稼働しています。 + +## 外から見たときの姿 + +MSSPがあり、要求すれば応答します。探査したゲームでは実測のゼロでした — 当サイトではこれは空欄ではなく +埋まったセルです。接続でき、数値を読み取り、その数値がゼロだった、ということです。これは接続数を +数えられなかったゲームとは別の事実であり、ここでこの2つが一緒にされることはありません。 diff --git a/content/reference/ja/codebase-tinymush.md b/content/reference/ja/codebase-tinymush.md new file mode 100644 index 0000000..dcc1834 --- /dev/null +++ b/content/reference/ja/codebase-tinymush.md @@ -0,0 +1,38 @@ +--- +kind: codebase +slug: tinymush +title: TinyMUSH +summary: MUSHの系統の祖先であり、今もゲームを動かしています。自分のネゴシエーションのバイトが次に送るコマンドを壊しうることを、このクローラーに教えた相手です。 +codebase: TinyMUSH +home: https://github.com/TinyMUSH/TinyMUSH +see-also: codebases/pennmush +see-also: codebases/tinymux +see-also: mush-mud-muck-moo +--- + +TinyMUSHは、PennMUSH、TinyMUX、RhostMUSH、CobraMUSHという系統のすべてが降りてきた場所であり、今も +稼働しています。開発は絶えたのではなく、静かなのです。 + +## 外から見たときの姿 + +MSSPはありません。ログイン前の `WHO` があり、`0 Players logged in, 22 record, no maximum.` という形の +一文で答えます。 + +## このゲームが当サイトに見つけたバグ + +TinyMUSHにここで一段落を割く価値があるのは、当サイト自身のクローラーの不具合を露わにしたゲームだから +であり、その訂正は「実測」が何を意味するはずなのかのよい例になっているからです。 + +当サイトの探査は、何週間ものあいだTinyMUSHを*接続数不明*として読んでいました。記録にあった推測は、 +その返答に末尾の改行がないというものでした。改行はあります。回線から取ったキャプチャによれば、本当の +原因は当サイトの側にありました。**TinyMUSHはログイン画面でtelnetを解釈しません**。そのため接続時に送る +`IAC DO MSSP` の3バイトが、誰かが打ち込んだかのようにその入力バッファへ落ちます。次に読まれる行は +`WHO` ではなく、3つの制御バイトに続く `WHO` であり、それはこのサーバーが持たないコマンドです — だから +接続画面を出し直し、プレイヤーについては何も言いません。 + +現在、探査はネゴシエーションのあとに素の改行を1つ送り、それが生んだものは何であれ捨てます。その出力は +*当サイト*が送ることを選んだバイトへの反応であり、したがってゲームの接続画面でもその答えでもないから +です。TinyMUSHはいま正しく読めており、探査は3分の1の時間で終わるようになりました。 + +確かめなかったディレクトリなら、「このゲームはプレイヤー数を報告しない」と、そのゲームが存在するかぎり +公開し続けたことでしょう。そしてその一文が語っていたのは、当サイトのことだったはずです。 diff --git a/content/reference/ja/codebase-tinymux.md b/content/reference/ja/codebase-tinymux.md new file mode 100644 index 0000000..447a09a --- /dev/null +++ b/content/reference/ja/codebase-tinymux.md @@ -0,0 +1,35 @@ +--- +kind: codebase +slug: tinymux +title: TinyMUX +summary: もう一つの大きなMUSHサーバー。PennMUSHのものと議論になるほど近いsoftcode、MSSPはまったくなし、そして機能するログイン前のWHO。 +codebase: TinyMUX +home: https://www.tinymux.org/ +see-also: codebases/pennmush +see-also: codebases/tinymush +see-also: codebases/rhostmush +see-also: mush-mud-muck-moo +--- + +TinyMUXは、定着したロールプレイMUSHの多くが動かしている2つのサーバーのうちの2番目で、多くのプレイヤーに +とって、これとPennMUSHのどちらを選ぶかは、そのゲームのスタッフがどちらを先に覚えたかの問題です。 +バージョンは `2.12` のような形で読めます。 + +PennMUSHと同じくTinyMUSHの子孫であり、そのsoftcodeは、両者を行き来するビルダーが学び直すのではなく +翻訳をしていると言えるほど近いものです。違いは実在します — 関数ライブラリ、いくつかの解釈の細部、 +`@` コマンド群 — そしてそれこそ、データベースを両者の間で移すことをエクスポートではなくプロジェクトに +してしまうたぐいのものです。 + +## 外から見たときの姿 + +**MSSPはありません。** TinyMUXはこのオプションをまったく提供せず、そのためAresMUSH、MUCK、RhostMUSH、 +CobraMUSH、TinyMUSHとともに、MSSPだけを見るディレクトリにはそもそも見えない、この趣味の側に立っています。 +その接続数はログイン画面の `WHO` から来ており、素の数値で答えます。 + +CHARSETはネゴシエートします。非ASCIIのテキストで親戚の大半より優位に立つのは、そのおかげです。 + +## 接続数の出どころ + +TinyMUXのゲームについて当サイトの数値を別のディレクトリのものと比べるなら、当サイトはログイン画面の +`WHO` を読んでおり、たいていのクローラーはそれを読まない、という点に注意してください。MSSPだけを土台に +したディレクトリは、こうしたゲームを接続数がまったくないものとして報告するか、そもそも一覧に載せません。 diff --git a/content/reference/ja/orientation-collaborative-roleplay.md b/content/reference/ja/orientation-collaborative-roleplay.md new file mode 100644 index 0000000..fab9218 --- /dev/null +++ b/content/reference/ja/orientation-collaborative-roleplay.md @@ -0,0 +1,66 @@ +--- +kind: orientation +slug: collaborative-roleplay +title: 共同ロールプレイがしたい +summary: 求めているのが他の人と一緒にシーンを書くことなら、これがその近道です — 探すべきコードベース、邪魔をしないクライアント、そして最初の晩に何が起きるか。 +see-also: mush-mud-muck-moo +see-also: connecting +see-also: codebases/pennmush +see-also: codebases/aresmush +see-also: codebases/evennia +see-also: clients/mudlet +--- + +ここでいう*共同ロールプレイ*とは、あなたと他の人たちが、キャラクターとして、文章で、たいていは +リアルタイムに、1つのシーンを一緒に書くことです。ゲームがそうしろと言わない限り、誰もイニシアチブを +振ったりしません。これは「MUD」という言葉が覆い隠しているホビーの一面であり、今も生きています。 + +## 探すべきコードベース + +この文化が生きているのはTinyMUDの系統です。そのサーバーが、何かを倒す人のためではなく、何かを作る人の +ために作られているからです。 + +- **[PennMUSH](/reference/codebases/pennmush)**と**[TinyMUX](/reference/codebases/tinymux)** — + 2大MUSHサーバーです。名前を聞いたことのある長寿のロールプレイゲームは、たいていどちらかで + 動いています。 +- **[AresMUSH](/reference/codebases/aresmush)** — 現代的で、ロールプレイのために作られており、 + ウェブのフロントエンドとシーンの記録を、誰かが書いたソフトコードではなく一級の機能として備えて + います。 +- **[Evennia](/reference/codebases/evennia)** — 完成したゲームではなく、Pythonのフレームワークです。 + これを土台にしたゲームは実に多様で、比較的新しいロールプレイゲームのいくつかはEvenniaです。 +- **[MUCK](/reference/codebases/muck)** — 独自の文化を持ち、きわめて社交的で、ファンダムの世界を + 探しているなら知っておく価値があります。 + +目録はこのいずれでも絞り込めます。各コードベースのページから、それを動かしていると当サイトが実測した +ゲームへリンクしています。 + +## 邪魔をしないクライアント + +必ずしも必要ではありません — ここにあるゲームはどれも素のtelnetを話しますし、お使いのOSにはおそらく +すでにクライアントが入っています。ただしシーンを書くうえでは、他の用途よりも重みを増すものが3つ +あります。 + +1. **長い段落に耐える入力行。** 何文にもわたるポーズを打つことになります。 +2. **ログ。** 後からそのシーンを読み返したくなります。 +3. **UTF-8。** 名前にはアクセント記号が入ります。 + +[クライアント](/reference)のセクションをご覧ください。スクリーンリーダーをお使いなら、そこの機能表には +そのための行があります — すべてのクライアントについて答えを確かめられたわけではなく、確かめられな +かったところでは、表は推測をせずに*不明*と記しています。 + +## 最初の晩は実際にどんなものか + +ロールプレイゲームの多くには**申請の手続き**があります。ゲストとして接続し、ヘルプファイルをいくつか +読み、スタッフに読んでもらうためのキャラクターを書き起こします。これには1日かかることも、2週間かかる +こともあります。締め出すこと自体が目的ではなく — ゲームが共有の舞台設定の筋を通すための仕組みです — +とはいえ「ログインして遊ぶ」という形になることは、めったにありません。 + +見た瞬間に誰も接続していないゲームは、ここではごく普通のことです。各ゲームのページにある活動の +ヒートマップのほうが、今の接続数よりも役に立ちます。毎晩15人いて午前4時には誰もいないゲームは、健全な +ゲームを間違った時間に見ているだけです。 + +## 当サイトに分からないこと + +そこにいる人たちが良い人かどうか。スタッフが公正かどうか。その舞台設定を楽しめるかどうか。当サイトが +実測するのはサーバーです。ここに評価はありませんし、これから付くこともありません — それは意図した +決定であって、欠落ではありません。 diff --git a/content/reference/ja/orientation-connecting.md b/content/reference/ja/orientation-connecting.md new file mode 100644 index 0000000..9791f6e --- /dev/null +++ b/content/reference/ja/orientation-connecting.md @@ -0,0 +1,46 @@ +--- +kind: orientation +slug: connecting +title: 接続のしかた +summary: ホストとポート、そしてtelnet。ゲームのページに載っているアドレスが何を意味し、それをどうするか。 +see-also: mush-mud-muck-moo +see-also: protocols/tls +see-also: protocols/charset +--- + +ここに掲載しているゲームはどれも**ホストとポート**で応答し、その下にあるプロトコルはtelnetです — +実際のところ、生のTCP接続の上に、任意のネゴシエーションが少しだけ乗ったものです。 + + telnet mush.pennmush.org 4201 + +これで通じますし、多くのシステムにはすでに入っています。ただし遊び方としては貧弱です。システムの +`telnet`には、名に値するローカルエコーの制御も、ログも、履歴もなく、ASCIIを超えるものはすべて壊して +しまいます。ゲームが応答するかどうかを確かめるには適した道具ですが、一晩を過ごすには向いていません。 + +## ゲームのページのアドレスが伝えること + +各ゲームのページには、当サイトが実測したエンドポイントを載せ、**TLS**を観測したものには印を付けて +います。TLSのポートがあるゲームには、暗号化して接続できます。そのポート番号は、たいてい平文のものとは +別です。 + +ゲームに複数のポートがある場合、それは別々のゲームではなく、同じ世界へ別の道から到達していることが +多いものです。当サイトは実測したものを並べるだけで、どれが正式なものかを推測することはしません。 + +## クライアントを選ぶ + +[クライアント](/reference)のセクションには、機能表の付いたページが1つずつあります。何かを入れる前に +確かめておく価値があるのは、次の3つです。 + +- **UTF-8を扱えるか。** ゲームが英語だけのものでないなら、これは最初の晩に問題になります。 +- **TLSを扱えるか。** ゲームが提供している場合にしか関係しませんが、今では提供しているものが + いくつもあります。 +- **スクリーンリーダーを使うなら、そのプロジェクトは対応を文書化しているか。** これはクライアントの + 比較で最も抜け落ちやすい行なので、当サイトでは最初の行に置いています — そして誰も答えを確かめて + いないところでは、*不明*と記します。 + +## 何も応答しないとき + +応答しないゲームが、必ずしもなくなったわけではありません。ゲームはホストを移りますし、DNSは失効します +し、ファイアウォールには言い分があります。当サイトは、これまでに実測したすべてのゲームを — 何年も前に +応答しなくなったものも含めて — 残しており、毎週ノックし続けています。ですから何かを結論づける前に見る +場所は[アーカイブ](/archive)です。 diff --git a/content/reference/ja/orientation-families.md b/content/reference/ja/orientation-families.md new file mode 100644 index 0000000..df6e4c5 --- /dev/null +++ b/content/reference/ja/orientation-families.md @@ -0,0 +1,81 @@ +--- +kind: orientation +slug: mush-mud-muck-moo +title: MUSH、MUD、MUCK、MOO — 言葉の意味するもの +summary: 4つの伝統を指す4つの言葉であり、そのどれもジャンルではありません。これらが実際に伝えていること。 +see-also: collaborative-roleplay +see-also: connecting +see-also: codebases/pennmush +see-also: codebases/aresmush +see-also: codebases/muck +see-also: codebases/moo +see-also: codebases/evennia +--- + +これらの言葉はどれも**サーバーソフトウェアの系統**を指す名前であって、ゲームの種類を指すものでは +ありません。これらについて知っておけることの中で、これがずば抜けて役に立ちます。「これはMUSHですか、 +MUDですか」という問いにろくな答えが返らないことが多いのも、そのためです。正直な答えはたいてい +*どちらでもあり、あなたが本当に訊きたかったのは文化のことだ*、というものです。 + +## MUD + +最も古い言葉であり、今では最も広い言葉です。*Multi-User Dungeon* — バートルとトラブショーによる1978年 +のゲーム — として始まり、90年代半ばには、テキストによるあらゆる多人数世界を指す総称になっていました。 + +狭い意味で使われるときは、**DikuMUDとLPMudの系統**を指します。レベル、戦闘、装備、そしてビルダーが +あらかじめ書いた部屋を記述するエリアファイルを中心に組み立てられたサーバーです。誰かが「MUDを遊んで +いる」と言い、それが何か具体的なものを指しているなら、たいていはこれです。 + +一覧では、それぞれの系統を単独で見られます。[DikuMUDのゲーム](/games?lineage=DikuMUD)と +[LPMudのゲーム](/games?lineage=LPMud)です。 + +## MUSH + +TinyMUDの系統から出た*Multi-User Shared Hallucination*です。この語を決めているのはテーマではなく +**ソフトコード**です。MUSHのサーバーは、プレイヤーがゲームの内側から使うプログラミング言語を備えて +いて、ビルド権限を持つプレイヤーは、ソースファイルに触れることも、何かを再起動することもなく、部屋や +オブジェクトや振る舞いを作れます。 + +この1つの設計判断が文化を生みました。MUSHは自動化された仕組みが薄く、人の手による仕組みが濃くなり +がちです — スタッフが回すプロット、書かれたシーン、申請の手続き — 遊んでいる人が、そのまま作っている +人でもあるからです。 + +PennMUSH、TinyMUSH、TinyMUX、RhostMUSH、CobraMUSH、AresMUSHはいずれもこの系統に属していますが、そう +名乗るものは1つもありません。MSSPには公開できる`MUSH`という値がなく、PennMUSH以外はそもそもMSSPを +まったく公開しないからです。したがって、これらをまとめるのは当サイトが読み取ったことではなく、当サイト +が行っていることです。[MUSHのゲーム](/games?lineage=MUSH)が、現れる場所すべてで*導出*と記されているのは +そのためです。 + +## MUCK + +MUSHと同じくTinyMUDの子孫で、独自のソフトコード(Forth系の言語であるMUF)を持ち、社交的な世界と +ファーリーファンダムの世界という強い伝統があります。技術的にはMUSHに近く、文化的には、両方を遊ぶ人が +同じものだとは言わない程度に異なります — [MUCKのゲーム](/games?lineage=MUCK)。 + +## MOO + +*MUD, Object-Oriented*です。「ゲームが自分自身を編集する」という発想の最も純粋な形で、MOOの中身は +ほぼすべて、それを使う人たちが内側から、MOOのプログラミング言語で書いたものです。LambdaMOOがその祖先 +であり、MOOは歴史的に、ゲームと同じくらい教育と研究の場でも親しまれてきました。 +[MOOのゲーム](/games?lineage=MOO)。 + +## では、実際には何を訊けばよいのか + +4文字の言葉よりも、次の3つの問いのほうがよほど役に立ちます。 + +1. **戦闘はあるか、そしてそれは自動化されているか。** これはどの名前よりも確実に、Diku/LPの系統と + TinyMUDの系統を分けます。 +2. **誰が作るのか。** スタッフだけか、それともビルドのビットを持つ人なら誰でもか。 +3. **遊びは予定されたものか、それとも流れているものか。** 約束して行うシーンとポーズによる + ロールプレイか、それともログインしてすぐ動き出すのか。 + +当サイトの一覧は、最初の問いの一部には答えられます。あるゲームについて当サイトが実測した +**コードベース**は、そのサーバーがどの伝統から来ているかを教えてくれますし、**系統**のファセットは、 +その答えを絞り込みに使えるようにしたものです。文化までは分かりませんし、このページはそのふりを +しません。 + +そのファセットについて、注意を1つ。このページこそ、それに初めて出会う場所だからです。コードベースは +実測ですが、系統は実測ではありません。それはゲームが伝えてきた内容についての*当サイトの*まとめで +あり、*実測*や*自己申告*と並ぶ独自の札 — **導出** — の下に置かれています。親が争いなく定まらない +コードベースは、最も近いものに押し込むのではなく、どの系統からも外します。そうしたゲームのいくつかは +`FAMILY Custom`を公開して、自らの言葉で当サイトと同じことを言っています。 diff --git a/content/reference/ja/protocol-atcp.md b/content/reference/ja/protocol-atcp.md new file mode 100644 index 0000000..4198145 --- /dev/null +++ b/content/reference/ja/protocol-atcp.md @@ -0,0 +1,30 @@ +--- +kind: protocol +slug: atcp +title: ATCP +summary: GMCPの前身。ペイロードの形式がより緩い帯域外データで、大部分は置き換えられましたが、削除しなかったサーバーでは今もネゴシエートされています。 +protocol: ATCP +see-also: protocols/gmcp +see-also: protocols/msdp +see-also: clients/mudlet +--- + +ATCP — Achaea Telnet Client Protocol — はtelnetオプション200であり、MUDのテキストと並べて構造化 +データを送るという発想が初めて広く実装された場所です。サーバーがモジュール名とペイロードを送り、 +クライアントがそれを振り分けます。 + +ペイロードの形式は[GMCP](/reference/protocols/gmcp)のJSONよりも緩く、GMCPが取って代わったのは基本的に +そのためです。ATCPに対応するクライアントも、今では非推奨として記載し、代わりにGMCPを案内するのが +普通です。 + +## 今も残っている理由 + +有効にしたままでも何も壊れないからです。2008年にATCPを実装し、2014年にGMCPを追加したサーバーは、 +たいてい今も両方をネゴシエートしますし、両方に対応するクライアントは提供されたほうを使います。 + +新しく実装するなら、これを選ぶ理由はありません。 + +## 当サイトが実測するもの + +当サイトが観測したハンドシェイクでtelnetオプション200を提供したサーバーです。ここの数値が低いのは +想定どおりで、それはこのプロトコルの古さの話であって、それ以外の何かの話ではありません。 diff --git a/content/reference/ja/protocol-charset.md b/content/reference/ja/protocol-charset.md new file mode 100644 index 0000000..2faaec1 --- /dev/null +++ b/content/reference/ja/protocol-charset.md @@ -0,0 +1,40 @@ +--- +kind: protocol +slug: charset +title: CHARSET +summary: エンコーディングを取り決めるための、RFC 2066のtelnetオプション。ゲームのアクセント付きの名前が無事に届く理由であり、これがないときに起きる分かりにくい失敗の原因でもあります。 +protocol: CHARSET +home: https://www.rfc-editor.org/rfc/rfc2066 +see-also: protocols/ttype +see-also: connecting +see-also: codebases/tinymux +--- + +CHARSETはtelnetオプション42で、RFC 2066で規定されています。一方が文字セットの一覧を提示し、もう一方が +そこから1つを選び、両者はバイトと文字の対応について合意します。 + +実際には、このネゴシエーションは**UTF-8**に落ち着くか、そもそも行われないかのどちらかです。MUSHの系統は +MUDの系統よりもはっきり多くネゴシエートしており — TinyMUX、RhostMUSH、PennMUSHはいずれも行います — +これは名前の入った文章を書く人たちの集まりであることの表れです。 + +## これがないと何が起きるか + +クライアントは推測するしかなく、たいていの推測はASCIIかLatin-1です。ASCIIと推測すれば0x7Fを超える +バイトはすべて疑問符になり、UTF-8のサーバーをLatin-1と推測すればアクセント付きの文字はどれも2つの +記号に化けます。どちらの失敗もゲームのせいに見えますが、そうではありません。 + +クローラーにとっては、これが特定の場所で効いてきます。当サイトが使っているtelnetライブラリは、現在の +エンコーディングの既定値をASCIIにしており、この既定値は無害ではありません — CHARSETを一度も +ネゴシエートしないサーバー、つまり大半のサーバーでは、すべてのバイトがこれで復号されます。だからこそ +当サイトは意図してその初期値を与えています。 + +## CHARSETが届かない唯一の場所 + +MSSPのフィールド名と値は、CHARSETが何に落ち着いたかに関係なくASCIIとして復号されます。 +サブネゴシエーションはテキストではなくコマンドであり、仕様がCHARSETの適用範囲をテキストに限って +いるからです。これは仕様に適合していると言えなくもありませんが、情報は失われます。MSSPの`NAME`が +`Café Noir`のゲームは`Caf? Noir`と報告し、元のバイトは当サイトが手を出せるどの段階よりも前に +消えています。 + +当サイトの自己申告のフィールドで文字化けが見えるのに、ゲーム自身の出力では見えない場合、理由はこれで +あり、当サイトの側からは復元できません。 diff --git a/content/reference/ja/protocol-gmcp.md b/content/reference/ja/protocol-gmcp.md new file mode 100644 index 0000000..82541e0 --- /dev/null +++ b/content/reference/ja/protocol-gmcp.md @@ -0,0 +1,43 @@ +--- +kind: protocol +slug: gmcp +title: GMCP +summary: Generic Mud Communication Protocol — テキストと並んで流れる構造化されたJSONメッセージであり、現代のクライアントの多くが土台にしている帯域外チャネルです。 +protocol: GMCP +home: https://www.mudhalla.net/tintin/protocols/gmcp/ +see-also: protocols/msdp +see-also: protocols/atcp +see-also: clients/mudlet +--- + +GMCPはtelnetオプション201です。ネゴシエートが済むと、サーバーは**構造化データを帯域外で**送れる +ようになります。パッケージ名とJSONのペイロードが、テキストと同じストリームで届きますが、テキストの +一部ではありません。 + +`Char.Vitals { "hp": 412, "maxhp": 500 }` が定番の例です。クライアントはこれだけで体力バーを動かせて、 +文章から数値を拾い集める必要がありません。それがこのプロトコルの要点そのものです — テキストの +パターンマッチで作ったステータス表示は、ゲームがプロンプトを変えた日に壊れますが、GMCPで作ったものは +壊れません。 + +パッケージの名前空間は標準化されたものではなく、慣習によるものです。`Char`、`Room`、`Comm`、`Client` +は広く使われていますが、そこから先はゲームが必要なものを考え出すので、あるゲームが何を送ってくるかは、 +たいていクライアントに教えてやる必要があります。 + +## ATCPに取って代わった理由 + +GMCPは[ATCP](/reference/protocols/atcp)の後継です。ATCPは同じ仕事を、より緩いペイロード形式で +こなしていました。改良点はJSONで、移行は2010年代半ばにはおおむね完了していました。両方に対応する +ゲームは珍しくありませんが、ATCPだけに対応する新しいゲームは珍しいでしょう。 + +## 当サイトが実測するもの + +ここに数えられるのは、**当サイトが観測したハンドシェイクで、そのゲームのサーバーがGMCPを提供した** +場合です。これは、ゲームのMSSPが`GMCP 1`と述べていることとは別の主張です。このホビーのプロトコル表の +大半は後者を土台にしていますが、両者はしばしば食い違います。 + +当サイト自身の経緯から、実測についての注記が1つあります。ある時期、[MCCP](/reference/protocols/mccp) +も併せてネゴシエートするサーバーでは、GMCPが見えていませんでした。当サイトのtelnetライブラリが圧縮を +ネゴシエートしながらストリームを展開しておらず、圧縮の開始位置より後はすべて雑音になっていたためです。 +調査したサーバーのうち少なくとも1つは、実は最初からGMCPを話していたことが分かりました。よく知っている +系統についてこのページの数値が低く見えるなら、まず疑うべきはこの種の欠陥です — 相手側ではなく、 +当サイトの側の。 diff --git a/content/reference/ja/protocol-mccp.md b/content/reference/ja/protocol-mccp.md new file mode 100644 index 0000000..104bb7d --- /dev/null +++ b/content/reference/ja/protocol-mccp.md @@ -0,0 +1,41 @@ +--- +kind: protocol +slug: mccp +title: MCCP +summary: ストリームの圧縮。安上がりで広く普及しており、このプロジェクトの歴史で最も学ぶところの多い不具合を生んだプロトコルです。 +protocol: MCCP +home: https://www.mudhalla.net/tintin/protocols/mccp/ +see-also: codebases/rom +see-also: codebases/dikumud +see-also: protocols/gmcp +--- + +MCCPは、サーバーからクライアントへのストリームをzlibで圧縮します。バージョン1はtelnetオプション85で、 +事実上は歴史上のものです。**バージョン2**はオプション86で、現代のサーバーがネゴシエートするのは +こちらです。サーバーが`IAC SB MCCP2 IAC SE`を送った後は、続くバイトはすべて1本の連続したzlib +ストリームの一部になります。 + +テキストのプロトコルでは本当に効果があり — MUDの出力は非常によく圧縮されます — DikuとLPの系統では +よく使われていて、当サイトが調査したコードベースのおよそ3分の1がネゴシエートします。 + +## 失敗の仕方と、それがここで重要な理由 + +MCCP2をネゴシエートしておきながらストリームを展開しないクライアントは、**圧縮の開始位置から先は +バイナリのごみ**を受け取ります。エラーでも切断でもありません。接続画面は置換文字の壁として届き、その +後に来るもの — `WHO`の応答、その後のMSSP、セッションのすべて — は失われます。 + +これは仮定の話ではありません。当サイトのtelnetライブラリがまさにそうしていました。オプションを +ネゴシエートし、「圧縮が有効になった」というコールバックを発火させながら、1バイトも展開して +いませんでした。ペイロードは素のzlib呼び出しできれいに展開できたので、正しいのはサーバーの側で、 +そうでないのは当サイトの側だと疑いようがありませんでした。調査した38のコードベースのうち13が影響を +受け、その間、それらのサーバーが圧縮の開始*より後*に何をネゴシエートしたのかは観測できませんでした +— つまり、当サイトによるそれらの機能の記録は、実際より少なく見せていたことになります。 + +これは上流で修正されました。後続の欠陥 — 展開器を接続の間ずっと保持せず、読み取りのたびに作り直して +いるため、大きな接続画面の途中で失敗するというもの — は報告済みで未解決のままであり、最も大きな画面の +末尾に影響します。 + +読む人に持ち帰ってほしいことが2つあります。**このページのプロトコルの数値は、このホビーの実測で +あると同時に、当サイトのクローラーの実測でもある**ということ。そして、それが誤っていたと分かっている +箇所については、当サイトはそう明記します。もう1つ、クライアントを書いているなら、MCCPをネゴシエート +するのは簡単で、正しく展開するところに手間がかかるということです。 diff --git a/content/reference/ja/protocol-msdp.md b/content/reference/ja/protocol-msdp.md new file mode 100644 index 0000000..fb286ea --- /dev/null +++ b/content/reference/ja/protocol-msdp.md @@ -0,0 +1,30 @@ +--- +kind: protocol +slug: msdp +title: MSDP +summary: Mud Server Data Protocol — GMCPと同じ仕事を、コンパクトなバイナリ符号化と、GMCPにはない探索の仕組みでこなします。 +protocol: MSDP +home: https://www.mudhalla.net/tintin/protocols/msdp/ +see-also: protocols/gmcp +see-also: clients/tintin +see-also: clients/blightmud +--- + +MSDPはtelnetオプション69で、[GMCP](/reference/protocols/gmcp)と同じ問題を解きます。テキストと並べて +構造化データを送り、クライアントが文章から数値を拾い集めずに済むようにする、というものです。 + +違いは2つあります。MSDPの符号化は**バイナリでコンパクト**であり — 変数と値はJSONで包むのではなく、 +1バイトの制御文字で標されます — さらにMSDPは**探索**のやり取りを定めています。クライアントは +`COMMANDS`や`REPORTABLE_VARIABLES`などを`LIST`で問い合わせ、そのゲームが何に対応しているかを教えて +もらえます。GMCPに相当するものはなく、だからGMCPのクライアントは、たいていゲームごとに設定してやる +必要があります。 + +実際には普及ではGMCPが勝ち、MSDPは実装したサーバーとクライアントに、しばしばGMCPと並んで残って +います。 + +## 当サイトが実測するもの + +ここに数えられるのは、当サイトが観測したハンドシェイクで、そのゲームのサーバーがMSDPを提供した場合 +です。このセクションのどの数値とも同じで、これは肯定的な観測であり、残りがその反対だということには +なりません — 数えられていないゲームは、MSDPを実装していないのかもしれませんし、単にまだ当サイトが +そのハンドシェイクを読み取っていないだけかもしれません。 diff --git a/content/reference/ja/protocol-msp.md b/content/reference/ja/protocol-msp.md new file mode 100644 index 0000000..c7221c3 --- /dev/null +++ b/content/reference/ja/protocol-msp.md @@ -0,0 +1,40 @@ +--- +kind: protocol +slug: msp +title: MSP +summary: MUD Sound Protocol — サーバーが音声ファイルを指定し、クライアントがそれを再生します。古く、単純で、他の2つと取り違えやすいプロトコルです。 +protocol: MSP +home: https://www.zuggsoft.com/zmud/msp.htm +see-also: protocols/mxp +see-also: clients/vipmud +--- + +MSPを使うと、サーバーはクライアントに音を鳴らすよう頼めます。ファイル名、音量、繰り返し回数、そして +クライアントが持っていない場合の取得先URLを並べた、括弧で囲まれた指示です。telnetオプション90で +ネゴシエートしますが、何もネゴシエートしないサーバーが、テキストのストリームに帯域内で送ることも +できます。 + +本当に古く、それでいて本当に今も使われています — テキストのゲームにおける環境音は、言葉の響きよりも +大きな効果があり、クライアントの画面表示ではなく音の手がかりを頼りにしているプレイヤーにとっては、 +飾り以上のものです。 + +## これではない3つのもの + +このセクションのクライアント表では、ここで慎重にならざるをえませんでした。その理由は書き留めておく +価値があります。 + +- **MCMP** — Mud Client Media Protocol — は、似た仕事をする別のプロトコルです。MSPではなくMCMPを + 実装しているクライアントが少なくとも1つあり、片方をもう片方として読み取れば、誰もしていない主張を + 表に載せることになります。 +- **クライアント自身の「音を鳴らす」スクリプト呼び出し**はMSPではありません。それはスクリプトの指示で + ローカルのファイルを再生するものです。MSPは、サーバーがクライアントに何を再生するかを伝えるもの + です。 +- **同梱プラグインによる対応は、そのように書くだけの価値があります。** あるクライアントのMSP対応は、 + telnetのネゴシエーションを明示的に行わないプラグインとして同梱されており、MSPを帯域内で送る + サーバーでは動きますが、ネゴシエートすることを前提とするサーバーでは動きません。 + +## 当サイトが実測するもの + +telnetオプション90を提供したサーバーです。MSPはネゴシエーションなしに帯域内で送られることが多いため、 +この数値は実際の普及を、見積もれない分だけ少なく示しています — これはハンドシェイクで見える範囲の +限界であって、このプロトコルについての知見ではありません。 diff --git a/content/reference/ja/protocol-mssp.md b/content/reference/ja/protocol-mssp.md new file mode 100644 index 0000000..e49a2d5 --- /dev/null +++ b/content/reference/ja/protocol-mssp.md @@ -0,0 +1,58 @@ +--- +kind: protocol +slug: mssp +title: MSSP +summary: Mud Server Status Protocol — ゲームがクローラーに自分自身を伝える手段です。そこで報告されるものはすべて実測ではなく自己申告であり、当サイトはこの2つを分けて扱います。 +protocol: MSSP +home: https://www.mudhalla.net/tintin/protocols/mssp/ +see-also: protocols/gmcp +see-also: codebases/dikumud +see-also: codebases/pennmush +--- + +MSSPはtelnetオプション70です。クローラーが`IAC DO MSSP`を送ると、対応しているサーバーは、自分自身を +説明する名前と値の組の表を返します — 名前、接続数、コードベース、稼働時間、ホスト名、ポート、 +ジャンル、そのほか公開したいと考えたものです。 + +このホビーで機械可読なディレクトリ項目に最も近いものであり、いくつかのディレクトリがそもそも存在して +いるのは、これのおかげです。 + +## MSSPレポートの内容はすべて主張である + +当サイトが既存のどのディレクトリとも異なるのは、この点です。MSSPレポートは、ゲームが自分自身について +*語ったもの*です。MSSPの表にある`GMCP 1`が意味するのは、誰かが設定ファイルに`1`と打ち込んだ、それも +おそらく2011年に、ということだけです。サーバーがGMCPを提供している証拠ではありませんし、両者は +興味深いと言える程度にはよく食い違います。 + +そのため、MSSPから得た事実には当サイトで**自己申告**の札を付けます。そして同じ事実を実測できる場合 — +機能であれば、そのオプションが実際にネゴシエートされるかどうかを見ることで実測できます — 両方を、 +それぞれの古さを添えて並べて表示します。MSSPで6年間GMCPを自己申告していながら、ハンドシェイクでは +一度も提供したことのないゲーム。それは知る価値のある事実であり、他のどこにも見つけられません。 + +当サイトが意図してまったく算入しない唯一のフィールドが`CREATED`です。手で打ち込まれた1行にすぎず、 +これを何かに算入すれば、その何かは造作なく操作できるものになってしまいます。 + +## 誰が応じるのか + +MSSPは**DikuとLP**の側の答えです。当サイトが行った38コードベースの調査では、28がMSSPで接続数を公開し、 +7つがログイン画面の`WHO`で公開し、両方を行ったのは2つだけでした — この2つの系統はほぼ完全に重なり +ません。AresMUSH、TinyMUX、MUCK、RhostMUSH、CobraMUSH、TinyMUSHはMSSPをまったく提供しません。 + +1つではなく4つの層を探査することの、これが経験的な根拠です。**MSSPだけを土台にしたクローラーには、 +MUSHの系統の大半が見えません**。それはこのホビーの大きな部分であり、当サイトが想定する読者の大半でも +あります。 + +## 待つのではなく、こちらから訊く + +MSSPに完全に対応しているサーバーの多くは、自分からは決して差し出してきません — `IAC DO MSSP`には +応じますが、それ以外では何も言いません。そのため、`IAC WILL NAWS`で始めて待つだけのクローラーは、 +それらのゲームを何も公開していないものとして報告します。それは、クローラー自身の沈黙からサーバーに +ついて立てた主張です。当サイトは接続時に`IAC DO MSSP`を送ります。 + +## 平文の形式 + +クライアントがログイン画面で`MSSP-REQUEST`という行をそのまま送る、古い変種があります。当サイトは +これを実測しました。試した20のゲームのうち3つが応じ、その3つはいずれもtelnetオプション70にも応じて +いたので、オプションで届く範囲を超えるものには何も届きませんでした。8つのサーバーはこの要求を +**キャラクター名**として読み取り、そう返答して、見知らぬ相手に許されているログイン試行を1回 +消費しました。当サイトはこれを送りません。 diff --git a/content/reference/ja/protocol-mxp.md b/content/reference/ja/protocol-mxp.md new file mode 100644 index 0000000..fa71a97 --- /dev/null +++ b/content/reference/ja/protocol-mxp.md @@ -0,0 +1,39 @@ +--- +kind: protocol +slug: mxp +title: MXP +summary: MUD eXtension Protocol — テキストのストリームに埋め込まれたHTMLに似たマークアップで、クリックできるリンク、画像、フォームをもたらします。仕様は広く行き渡っていますが、実装はまちまちです。 +protocol: MXP +home: https://www.zuggsoft.com/zmud/mxp.htm +see-also: protocols/pueblo +see-also: clients/mushclient +see-also: clients/mudlet +--- + +MXPは、サーバーが送るテキストに、HTMLに似た小さなマークアップ言語を埋め込みます。クリックできる +コマンドのための``、リンクのための``、色とフォントの要素、そしてサーバーが独自のタグを +定義するための仕組みです。telnetオプション91でネゴシエートします。 + +設計上の問題は本質的で、しかも興味深いものです。マークアップはテキストと同じストリームを流れるので、 +サーバーはマークアップに*見える*テキストに注意しなければならず、クライアントは何を描画するかに注意 +しなければなりません。MXPがセキュリティレベルを定めているのは、まさにこのためです — 他のプレイヤーの +発言の行に混ざって届いたタグは、サーバー自身が出力したタグとは別物です。 + +## 求められる理由はクリックできること + +MXPが実際に使われている用途の大半は、`north`やアイテム名を、クリックできるものに変えることです。 +新しいプレイヤーにとってこれは大きな違いであり、複雑さにもかかわらずこのプロトコルが実装され続けて +いる理由でもあります。 + +## もう一方がPueblo + +[Pueblo](/reference/protocols/pueblo)はMXPより古く、似た仕事を、より文字どおりHTMLの形をした別の方法で +こなします。片方に対応しているクライアントがもう片方には対応していないことは多く、機能一覧を読むときに +両者は取り違えやすいものです — このセクションのクライアント表で、当サイトが気をつけなければならな +かった間違いでもあります。 + +## 当サイトが実測するもの + +当サイトが観測したハンドシェイクでtelnetオプション91を提供したサーバーです。MXPは帯域外のプロトコル +ほど頻繁にはネゴシエートされません。その一因は、価値の多くが、まったくネゴシエートせずにマークアップを +ただ送って期待するサーバーによって実現されていることにあります — それは当サイトからは見えません。 diff --git a/content/reference/ja/protocol-pueblo.md b/content/reference/ja/protocol-pueblo.md new file mode 100644 index 0000000..5136856 --- /dev/null +++ b/content/reference/ja/protocol-pueblo.md @@ -0,0 +1,31 @@ +--- +kind: protocol +slug: pueblo +title: Pueblo +summary: 同名のクライアントから生まれた、MUDにHTMLを載せる古いほうの方式。MUSH側のクライアントでは今も対応されており、MXPと取り違えられることがよくあります。 +protocol: PUEBLO +home: https://pueblo.sourceforge.net/ +see-also: protocols/mxp +see-also: clients/beipmu +--- + +Puebloは90年代半ばに同名のクライアントから生まれ、MUDのテキストを豊かにするのに直接的な方法を +取りました。サーバーに**HTML**を送らせ、クライアントにそれを描画させる、というものです。サーバーは +接続時の1行でPuebloへの対応を告げ、クライアントが応答すると、そこから先はストリームがマークアップを +運べるようになります。 + +このホビーではMUD側よりMUSH側に広まり、対応しているMUSHのサーバーは、今もたいてい対応したままです。 + +## MXPではない + +[MXP](/reference/protocols/mxp)は後発の方式で、より広く実装されています。両者は似た仕事をしますが +互換性はなく、クライアントのPuebloへの対応をMXPへの対応として読み取ること — またはその逆 — は、 +クライアントの比較をまとめるときに最も犯しやすい間違いです。このセクションのクライアントのページが +両者を分けているのはそのためであり、あるプロジェクトが片方だけを文書化している場合、もう片方は*不明* +と記します。 + +## 当サイトが実測するもの + +Puebloのハンドシェイクは通常の意味でのtelnetオプションではないため、当サイトが観測できる範囲は、 +ネゴシエートされるプロトコルの場合より狭くなります。ここの数値が低い場合は、普及についての言明では +なく、当サイトから何が見えているかについての言明として読むべきです。 diff --git a/content/reference/ja/protocol-tls.md b/content/reference/ja/protocol-tls.md new file mode 100644 index 0000000..c26e259 --- /dev/null +++ b/content/reference/ja/protocol-tls.md @@ -0,0 +1,36 @@ +--- +kind: protocol +slug: tls +title: TLS +summary: 暗号化された接続。ネゴシエートによる切り替えではなく別のポートであるのが普通で、当サイトが訊くのではなく接続して確かめる唯一の機能です。 +protocol: TLS +see-also: connecting +see-also: protocols/charset +see-also: clients/potato +--- + +telnetは平文です。MU\*に送るものはすべて — パスワードも含めて — ゲームがTLSを提供していない限り、 +経路上のあらゆるものに読める状態でネットワークを渡ります。 + +このホビーでTLSといえば、ほぼ必ず**最初のバイトからTLSを話す2つ目のポート**のことであり、帯域内での +切り替えではありません。4201に平文のポート、4202にTLSのポートというのがよくある形です。ネゴシエート +する変種もありますが、少なくとも1つのクライアントの文書が非対応と明記する程度には珍しいものです。 + +## ゲームのページがこれを特別に示す理由 + +TLSは、当サイトで*実際にやってみる*ことによって確かめている唯一の機能です。あるエンドポイントにTLSの +印が付いているのは、当サイトがそこに対してTLSハンドシェイクを完了したからです。訊くという手順はなく、 +自己申告するフィールドもありません。そのため、この目録の中で最も濁りのない実測になっています。 + +ゲームのTLSのポートと平文のポートをまとめず、別々のエンドポイントとして掲載しているのも、そのため +です。両者は別のものについての別の実測です。 + +## 実際的な助言 + +遊んでいるゲームがTLSのポートを提供しているなら、そちらを使ってください。提供しておらず、それが気に +なるなら、頼んでみてください — 管理者にとっては小さな手間ですし、これが当たり前になっていない理由は、 +誰かが反対しているからというより、たいていは誰も頼まなかったからです。 + +頼りにする前に、使っているクライアントが対応しているかを確かめてください。[クライアント](/reference) +のセクションにあるもののいくつかは対応しています。少なくとも1つは、代わりに外部の`stunnel`プロセスを +使う回避策を文書化していて、それは実際に動きますが、大半の人がやる気になるよりは手間のかかる設定です。 diff --git a/content/reference/ja/protocol-ttype.md b/content/reference/ja/protocol-ttype.md new file mode 100644 index 0000000..f4d2237 --- /dev/null +++ b/content/reference/ja/protocol-ttype.md @@ -0,0 +1,44 @@ +--- +kind: protocol +slug: ttype +title: TTYPEとMTTS +summary: クライアントが、自分は何であり何ができるかをサーバーに伝えるための仕組み — クライアントがそう伝えることを選べば、スクリーンリーダーが使われていることも含めて。 +protocol: TTYPE +home: https://www.mudhalla.net/tintin/protocols/mtts/ +see-also: protocols/charset +see-also: clients/tintin +see-also: clients/blightmud +--- + +TTYPEはRFC 1091によるtelnetオプション24です。サーバーがクライアントに端末の種類を尋ね、クライアントが +答えます。歴史的には、その答えは`VT100`か`ANSI`でした。 + +**MTTS** — Mud Terminal Type Standard — は、その上に慣習を重ねたものです。クライアントは3回答えます。 +自分の名前、端末の種類、そして`MTTS `であり、そのビットが機能を申告します。256色、 +トゥルーカラー、UTF-8、MNES、帯域外でのMSP — そしてとりわけ**`MTTS_SCREEN_READER`**です。 + +## スクリーンリーダーのビット + +最後のものは、立ち止まって見る価値があります。このホビーのプロトコル群の中で、アクセシビリティが一級の +概念になっている唯一の場所だからです。 + +このビットを立てるクライアントは、スクリーンリーダーが使われていることをサーバーに伝えています。それに +気づいたサーバーは、そのように振る舞いを変えられます。ASCIIアートを出さない、部屋の説明を囲む飾り罫を +落とす、表の組み方を変える、といったことです。[TinTin++](/reference/clients/tintin)と +[Blightmud](/reference/clients/blightmud)はどちらもこれを告知しますし、 +[Mudlet](/reference/clients/mudlet)にはそのための設定があります。 + +あるゲームが実際にそれに応じて動くかどうかは別の問題であり、当サイトに実測できる問題でもありません — +サーバーに、何を変えるつもりなのかを尋ねることはできないからです。 + +## ここでクローラーが負う務め + +クローラーはTTYPEで自分が何者かを名乗りますし、そうすべきです。当サイトのクローラーは情報用のURLを +添えて名乗るので、ログを読む管理者は、自分のゲームに接続してきたのが誰なのか、そしてやめてほしいと +伝えるにはどうすればよいのかを知ることができます。`ANSI`とだけ答えてそれ以上は何も答えないクローラーは、 +設計からして匿名であり、そうする正当な理由はありません。 + +## 当サイトが実測するもの + +当サイトとTTYPEをネゴシエートしたサーバーです。これは*当サイト*のほうが尋ねられる側になる数少ない +オプションの1つなので、ここの数値は、尋ねる気のあったサーバーの数だという点に注意してください。 diff --git a/content/reference/nl/client-atlantis.md b/content/reference/nl/client-atlantis.md new file mode 100644 index 0000000..45abd2b --- /dev/null +++ b/content/reference/nl/client-atlantis.md @@ -0,0 +1,47 @@ +--- +kind: client +slug: atlantis +title: Atlantis +summary: Een client die alleen op macOS draait, met een lang leven en een lange bèta. Van zijn scripting staat gedocumenteerd dat die niet meer werkt, en dat is de ene eerlijke "nee" in dit onderdeel. +home: https://www.riverdark.net/atlantis/ +platform: macOS +capability: screen reader | unknown | +capability: TLS | yes | https://www.riverdark.net/atlantis/history.php +capability: UTF-8 | yes | https://www.riverdark.net/atlantis/history.php +capability: MCCP | yes | https://www.riverdark.net/atlantis/history.php +capability: GMCP | unknown | +capability: MSDP | unknown | +capability: ATCP | unknown | +capability: MXP | unknown | +capability: MSP | unknown | +capability: scripting | no | https://www.riverdark.net/atlantis/ +see-also: clients/mudlet +see-also: protocols/charset +--- + +Atlantis is een native macOS-client die er al is sinds Mac OS X 10.3 en die in het Catalina-tijdperk +naar 64 bit is bijgewerkt. Hij kan overweg met tekensetonderhandeling volgens RFC 2066 en met +Unicode, wat beter is dan zijn leeftijd doet vermoeden, en hij doet MCCP en SSL. + +## De ene "nee" in dit onderdeel + +Zijn scripting liep via Perl, door de CamelBones-brug, en de eigen homepage van het project zegt dat +die niet meer werkt — Apple veranderde zijn omgang met Perl en de auteur van de bibliotheek is +enkele jaren geleden overleden. Dat is een *afwezigheid met bron*, en dat is iets anders dan een +onbekende; het is de enige cel in het hele clientonderdeel die er een draagt. Overal elders was het +eerlijke antwoord dat we het niet konden vaststellen. + +## Alles wat we niet konden vaststellen + +De versiegeschiedenis is volledig en openbaar en noemt **MCCP**, **SSL** en +**tekensetonderhandeling** — en noemt nergens GMCP, MSDP, ATCP of MSP. MXP komt één keer voor, als +iets dat bedoeld was voor een versie na 1.0.0, die er niet gekomen is. + +Er is een Perl-aanroep `Atlantis::Speak()` in de scripting-API, en het zou makkelijk zijn die te +lezen als ondersteuning voor schermlezers. Dat is het niet: het is een gescripte +tekst-naar-spraakaanroep in een scriptingsysteem waarvan het project zegt dat het niet werkt. +VoiceOver, "toegankelijk" en "schermlezer" komen niet voor op de homepage, de downloadpagina, de +volledige versiegeschiedenis of de gearchiveerde gebruikershandleiding. + +De huidige download is 0.9.9.8, nog altijd formeel een bèta, zonder dat er ergens op de site een +releasedatum gepubliceerd is. diff --git a/content/reference/nl/client-beipmu.md b/content/reference/nl/client-beipmu.md new file mode 100644 index 0000000..4df2b2c --- /dev/null +++ b/content/reference/nl/client-beipmu.md @@ -0,0 +1,47 @@ +--- +kind: client +slug: beipmu +title: BeipMU +summary: Een Windows-client gericht op de MUSH-kant van de hobby, met ondersteuning voor schermlezers in het uitvoervenster en Pueblo in plaats van MXP. +home: https://beipdev.github.io/BeipMU/ +platform: Windows +capability: screen reader | yes | https://github.com/BeipDev/BeipMU/blob/master/Assets/Changes.txt +capability: TLS | yes | https://beipdev.github.io/BeipMU/ +capability: UTF-8 | yes | https://beipdev.github.io/BeipMU/ +capability: MCCP | unknown | +capability: GMCP | yes | https://github.com/BeipDev/BeipMU/blob/master/Documentation/GMCP.md +capability: MSDP | unknown | +capability: ATCP | unknown | +capability: MXP | unknown | +capability: MSP | unknown | +capability: scripting | yes | https://beipdev.github.io/BeipMU/ +see-also: clients/mushclient +see-also: clients/potato +see-also: collaborative-roleplay +--- + +BeipMU is een Windows-client onder de MIT-licentie, die actief uitgebracht wordt, en een van de +weinige die gebouwd is met MUSH-achtig spel voor ogen in plaats van met gevecht-MUD's — meerdere +invoervensters, spawn windows, en een tekstengine die lange alinea's verwacht. Scripting gaat +standaard in JavaScript, met andere ActiveScript-engines beschikbaar. + +## Toegankelijkheid + +Het uitvoervenster implementeert de `IAccessible`-interface van Windows, bewust toegevoegd als stap +richting bruikbaarheid voor slechtziende spelers, en er is een **Speak**-triggeractie voor +tekst-naar-spraak. Nergens wordt een bepaalde schermlezer genoemd, en er is geen hoofdstuk over +toegankelijkheid in de documentatie. + +Eén waarschuwing als je gaat zoeken: een pagina in de eigen documentatie van het project zegt nog +steeds dat BeipMU geen spraaksynthese kan gebruiken. Die pagina is verouderd — de changelog en de +eigen issue-reacties van de onderhouder dateren beide van later. + +## Twee makkelijke misverstanden over deze client + +**BeipMU implementeert MCMP, niet MSP.** Het zijn verschillende protocollen met vergelijkbare namen +en vergelijkbare doelen, en de een als de ander lezen zou een bewering in deze tabel zetten die +niemand gedaan heeft. De MSP-rij zegt daarom onbekend. + +**Hij ondersteunt Pueblo, niet MXP.** Pueblo is het oudere schema voor HTML in een MUD en MXP het +latere; BeipMU documenteert basale Pueblo-stijlen en klikbare links. Over MXP is het niet +vastgesteld, in welke richting dan ook. diff --git a/content/reference/nl/client-blightmud.md b/content/reference/nl/client-blightmud.md new file mode 100644 index 0000000..9db52ff --- /dev/null +++ b/content/reference/nl/client-blightmud.md @@ -0,0 +1,50 @@ +--- +kind: client +slug: blightmud +title: Blightmud +summary: Een moderne terminalclient in Rust, met Lua-scripting, ingebouwde tekst-naar-spraak en een schermlezermodus die zichzelf aan de server bekendmaakt. +home: https://github.com/Blightmud/Blightmud +platform: Linux +platform: macOS +platform: Windows (WSL only) +capability: screen reader | yes | https://github.com/Blightmud/Blightmud +capability: TLS | yes | https://github.com/Blightmud/Blightmud +capability: UTF-8 | yes | https://github.com/Blightmud/Blightmud +capability: MCCP | yes | https://github.com/Blightmud/Blightmud +capability: GMCP | yes | https://github.com/Blightmud/Blightmud +capability: MSDP | yes | https://github.com/Blightmud/Blightmud +capability: ATCP | unknown | +capability: MXP | unknown | +capability: MSP | unknown | +capability: scripting | yes | https://github.com/Blightmud/Blightmud +see-also: clients/tintin +see-also: clients/mudlet +see-also: protocols/ttype +--- + +Blightmud is een terminalclient geschreven in Rust, GPL 3, en behoort tot de actiefst uitgebrachte +clients in dit onderdeel. Scripting gaat in Lua. Hij draait alleen in de terminal: er is geen native +Windows-build, en Windows-gebruikers draaien hem onder WSL. + +## Toegankelijkheid + +Blightmud heeft hier drie afzonderlijke onderdelen, en dat is meer dan één rij kan dragen: + +- Een **schermlezervriendelijke modus** (`--reader-mode`, of de instelling `reader_mode`) die de + terminalinterface verandert in iets wat een lezer kan volgen. Het statusgebied wordt niet + ondersteund. +- **Ingebouwde tekst-naar-spraak**, als optionele compilatie, met een Lua-API die een script kan + gebruiken — inclusief een `tts.gag()` om te voorkomen dat een gevonden regel uitgesproken wordt. + De documentatie is er openhartig over dat zijn TTS naast een schermlezer draaien niet altijd een + gelukkige combinatie is. +- **Automatische MTTS-aankondiging**: in schermlezermodus of met TTS aan voegt hij + `MTTS_SCREEN_READER` toe aan wat hij de server over zichzelf vertelt, zodat een spel dat erom + geeft zich kan aanpassen. + +Net als bij TinTin++ wordt geen bepaalde schermlezer genoemd, dus dit is een gedocumenteerde modus +en geen geteste compatibiliteit met een product. + +## Waar de tabel onbekend zegt + +**MXP**, **MSP** en **ATCP** komen nergens voor in de README van het project of in de meegeleverde +help. **MCCP** is gedocumenteerd als v2; of v1 ook aangekund wordt hebben we niet vastgesteld. diff --git a/content/reference/nl/client-mudlet.md b/content/reference/nl/client-mudlet.md new file mode 100644 index 0000000..205d7dc --- /dev/null +++ b/content/reference/nl/client-mudlet.md @@ -0,0 +1,57 @@ +--- +kind: client +slug: mudlet +title: Mudlet +summary: Platformonafhankelijk, met Lua-scripting, en de client met de grondigst gedocumenteerde ondersteuning voor schermlezers in dit onderdeel. +home: https://www.mudlet.org/ +platform: Windows +platform: macOS +platform: Linux +capability: screen reader | yes | https://wiki.mudlet.org/w/Manual:Screen_Readers +capability: TLS | yes | https://wiki.mudlet.org/w/Manual:Supported_Protocols +capability: UTF-8 | yes | https://wiki.mudlet.org/w/Manual:Supported_Protocols +capability: MCCP | unknown | +capability: GMCP | yes | https://wiki.mudlet.org/w/Manual:Supported_Protocols +capability: MSDP | yes | https://wiki.mudlet.org/w/Manual:Supported_Protocols +capability: ATCP | yes | https://wiki.mudlet.org/w/Manual:Supported_Protocols +capability: MXP | yes | https://wiki.mudlet.org/w/Manual:Supported_Protocols +capability: MSP | yes | https://wiki.mudlet.org/w/Manual:Supported_Protocols +capability: scripting | yes | https://github.com/Mudlet/Mudlet +see-also: clients/blightmud +see-also: clients/tintin +see-also: protocols/gmcp +see-also: connecting +--- + +Mudlet is een grafische client met een kaartfunctie, een pakketsysteem en een Lua-API waartegen het +grootste deel van zijn eigen functies geschreven is. Hij is GPL, wordt actief uitgebracht, en is de +gebruikelijke aanbeveling voor wie op een moderne gevecht-MUD begint. + +## Toegankelijkheid + +Dit is de client met de sterkste gedocumenteerde papieren in dit onderdeel, en het is de moeite +waard uit te spellen wat "gedocumenteerd" hier betekent, want het is ongewoon. + +Mudlet heeft een **hoofdstuk over schermlezers in de handleiding**, pagina's per besturingssysteem +die Narrator, NVDA en JAWS op Windows, Orca op Linux en VoiceOver op macOS bij naam noemen, een +commando `mudlet access on` in de client zelf, en een optie om binnenkomende speltekst via de lezer +aan te kondigen. Er is ook een instelling die het gebruik van een schermlezer via MTTS aan de server +aankondigt, zodat een spel zich kan aanpassen als het dat wil. + +Het is ook openhartig over waar het niet goed werkt: de eigen Windows-pagina zegt dat JAWS het +uitvoervenster niet leest zoals andere lezers dat doen, en beveelt in plaats daarvan Narrator of +NVDA aan. Een project dat het geval publiceert waarin zijn toegankelijkheidsondersteuning onwerkbaar +is, geeft je betere informatie dan een project dat een vinkje publiceert. + +## Waar de tabel onbekend zegt + +**MCCP.** De broncode van Mudlet implementeert MCCP v1 en v2, maar de pagina met ondersteunde +protocollen in de handleiding noemt het niet, en de regel in dit onderdeel is dat een uitspraak over +een mogelijkheid de eigen documentatie van het project aanhaalt. Een constante uit een headerbestand +lezen is niet dezelfde handeling, dus de cel zegt onbekend. + +## Aantekening over codering + +De standaardcodering van Mudlet voor servergegevens is ASCII in plaats van UTF-8, en +CHARSET-onderhandeling kwam in 4.10. Komt de tekst van een spel er verkeerd uit op een vers profiel, +dan is die instelling de eerste plek om te kijken. diff --git a/content/reference/nl/client-mushclient.md b/content/reference/nl/client-mushclient.md new file mode 100644 index 0000000..4118a0f --- /dev/null +++ b/content/reference/nl/client-mushclient.md @@ -0,0 +1,53 @@ +--- +kind: client +slug: mushclient +title: MUSHclient +summary: De al lang gevestigde Windows-client. Vijf scripttalen, een plug-inarchitectuur waar het grootste deel van zijn protocolondersteuning in zit, en een releasegeschiedenis die vertraagd is. +home: https://www.mushclient.com/ +platform: Windows +platform: Linux (Wine) +capability: screen reader | unknown | +capability: TLS | unknown | +capability: UTF-8 | unknown | +capability: MCCP | yes | https://www.mushclient.com/mushclient/mccp.htm +capability: GMCP | unknown | +capability: MSDP | unknown | +capability: ATCP | yes | https://www.mushclient.com/gmcp +capability: MXP | yes | https://www.mushclient.com/mushclient/doc/general/features.html +capability: MSP | yes | https://github.com/nickgammon/mushclient/blob/master/plugins/msp.xml +capability: scripting | yes | https://www.mushclient.com/mushclient/doc/general/features.html +see-also: clients/mudlet +see-also: clients/potato +see-also: protocols/mccp +--- + +MUSHclient is de Windows-client van Nick Gammon, onder de MIT-licentie, en lange tijd het +standaardantwoord voor iedereen op Windows. Er wordt in gescript met Lua, VBScript, JScript, +PerlScript en Python, en veel van wat hij doet wordt gedragen door plug-ins in plaats van door de +kern — wat een echte architectuurkeuze is en tegelijk de reden dat verscheidene rijen hierboven +lastiger te beantwoorden zijn dan ze lijken. + +De laatste getagde release is **5.06, uit maart 2019**. Er wordt nog steeds aan de repository +gecommit, en er zijn release notes voor een 5.07 die niet uitgebracht is. + +## Waarom zoveel rijen onbekend zeggen + +Bij elk daarvan is het eerlijke antwoord "we konden het niet vaststellen", en de redenen +verschillen: + +- **GMCP** — de eigen pagina van het project erover toont een *voorbeeld* van een plug-in die je zou + kunnen schrijven, niet een functie die de client heeft. Dat is iets anders dan ondersteuning + uitleveren, dus de cel zegt onbekend in plaats van ja. +- **TLS** — de gedocumenteerde methode is een extern `stunnel`-proces. Een commit die TLS op basis + van OpenSSL toevoegde belandde in 2026 op de master-branch en zit in geen enkele release, dus er + is niets dat een gebruiker vandaag kan installeren en waar wij naar kunnen wijzen. +- **UTF-8** — CHARSET-onderhandeling komt voor in de niet-uitgebrachte 5.07-notities en nergens waar + wij het in de documentatie van een uitgebrachte versie konden vinden. +- **MSDP** — niets in welke richting dan ook. +- **Schermlezer** — er wordt een tekst-naar-spraak-plug-in op basis van Windows SAPI met de client + meegeleverd, en dat is niet hetzelfde als ondersteuning voor schermlezers. Er is geen paragraaf + over toegankelijkheid in de handleiding, en de auteur heeft in zijn eigen forum beschreven waarom + het uitvoervenster lastig werkbaar is voor een lezer: het kent geen begrip van een huidige regel. + We konden geen antwoord vaststellen, dus de tabel geeft er geen. + +Geen van deze is een *nee*. Verscheidene zijn heel goed mogelijk ja en we konden het niet aantonen. diff --git a/content/reference/nl/client-potato.md b/content/reference/nl/client-potato.md new file mode 100644 index 0000000..48d1df0 --- /dev/null +++ b/content/reference/nl/client-potato.md @@ -0,0 +1,48 @@ +--- +kind: client +slug: potato +title: Potato MUSHclient +summary: Een platformonafhankelijke Tcl/Tk-client geschreven voor MUSH-spelers. Goede ondersteuning voor codering, en documentatie die over de meeste protocollen helemaal niets zegt. +home: https://www.potatomushclient.com/ +platform: Windows +platform: Linux +platform: macOS +capability: screen reader | unknown | +capability: TLS | yes | https://github.com/potatomushclient/potato/wiki/ConfigureWorldsBasics +capability: UTF-8 | yes | https://github.com/potatomushclient/potato/wiki/Features +capability: MCCP | unknown | +capability: GMCP | unknown | +capability: MSDP | unknown | +capability: ATCP | unknown | +capability: MXP | unknown | +capability: MSP | unknown | +capability: scripting | yes | https://github.com/potatomushclient/potato/wiki/FAQs +see-also: clients/beipmu +see-also: clients/mushclient +see-also: collaborative-roleplay +--- + +Potato is een Tcl/Tk-client gebouwd voor MUSH-spel — meerdere werelden, spawn windows, en een set +standaardinstellingen die ervan uitgaat dat je poses typt en geen gevechtscommando's. Hij draait +vanuit dezelfde broncode op Windows, Linux en macOS, waarbij de macOS-builds meestal een versie of +twee achterlopen. + +Hij onderhandelt over tekencodering en spreekt volledig Unicode, wat voor de MUSH-kant van de hobby +de mogelijkheid is die er in de praktijk het meest toe doet. + +Let op één gedocumenteerde beperking: hij ondersteunt verbinden met een poort die vanaf het begin +SSL is, en zijn eigen configuratiepagina zegt dat onderhandelde SSL in STARTTLS-stijl **niet** +ondersteund wordt. + +## Waarom zes rijen onbekend zeggen + +We hebben de homepage van het project, zijn downloadpagina, alle 103 helpbestanden van zijn wiki en +zijn hele bronboom doorzocht op GMCP, MSDP, MCCP, MXP, MSP en ATCP. Er is over geen ervan een +gedocumenteerde uitspraak. Er is wél *code* die enkele ervan aanraakt, en dit onderdeel maakt van +code geen uitspraak over een mogelijkheid — een tabel die "ja" zegt op grond van een constante in +een headerbestand doet een belofte die het project nooit gedaan heeft. + +De rij over schermlezers is hetzelfde antwoord, langs dezelfde weg bereikt: een zoektocht zonder +onderscheid tussen hoofd- en kleine letters naar "screen reader", "text-to-speech", NVDA, JAWS, +VoiceOver, "accessibility", "visually impaired" en "blind" door alles wat het project publiceert, +leverde helemaal niets op. Dat is geen bevinding over de software. diff --git a/content/reference/nl/client-tintin.md b/content/reference/nl/client-tintin.md new file mode 100644 index 0000000..99365bf --- /dev/null +++ b/content/reference/nl/client-tintin.md @@ -0,0 +1,53 @@ +--- +kind: client +slug: tintin +title: TinTin++ +summary: Een terminalclient met een eigen scripttaal, op elk platform inclusief telefoons, en een gedocumenteerde schermlezermodus. +home: https://tintin.mudhalla.net/ +platform: Linux +platform: macOS +platform: Windows +platform: Android +platform: iOS +capability: screen reader | yes | https://tintin.mudhalla.net/manual/screen_reader.php +capability: TLS | yes | https://github.com/scandum/tintin +capability: UTF-8 | yes | https://github.com/scandum/tintin +capability: MCCP | yes | https://tintin.mudhalla.net/ +capability: GMCP | yes | https://tintin.mudhalla.net/manual/event.php +capability: MSDP | yes | https://tintin.mudhalla.net/manual/msdp.php +capability: ATCP | unknown | +capability: MXP | unknown | +capability: MSP | unknown | +capability: scripting | yes | https://github.com/scandum/tintin +see-also: clients/blightmud +see-also: clients/mudlet +see-also: protocols/msdp +see-also: protocols/ttype +--- + +TinTin++ is een client voor de opdrachtregel, GPL 3, wordt actief uitgebracht, en draait op meer +plekken dan wat dan ook hier — waaronder Android en iOS. De scripttaal is een eigen taal, beknopt, +en tot heel veel in staat; een aanzienlijk deel van wat andere clients in de grafische interface +doen is hier een `#config`-regel. + +Dezelfde auteur onderhoudt de protocolspecificaties voor **MSSP** en **MSDP**, en daarom halen +zoveel van de protocolpagina's in dit onderdeel dezelfde site aan. + +## Toegankelijkheid + +TinTin++ heeft een eigen handleidingpagina voor de **schermlezermodus** (`#config screen reader on`, +of `-s` bij het starten). Die aanzetten doet twee dingen: het verwijdert of verandert visuele +elementen die hardop voorgelezen nergens op slaan, en het meldt het gebruik van een schermlezer aan +de server via [MTTS](/reference/protocols/ttype), zodat een spel zijn eigen uitvoer kan aanpassen. + +Dat is een gedocumenteerde modus, geen bewering dat er met een bepaalde lezer getest is — op de +pagina wordt geen product genoemd. Het is merkbaar zwakker bewijs dan een client die de lezers noemt +waarmee hij werkt, en merkbaar sterker dan niets. + +## Waar de tabel onbekend zegt + +Voor **MXP** en **MSP** bestaan er allebei scripts uit de gemeenschap op de site van het project, en +een script is niet de client die een protocol ondersteunt — dat van MXP zegt ronduit dat het +misschien niet op elke MUD werkt. Ingebouwde ondersteuning voor een van beide is niet vastgesteld. +Over **ATCP** vonden we in geen enkele richting iets; merk op dat ATCP grotendeels vervangen is door +GMCP, en dat ondersteunt TinTin++ wel. diff --git a/content/reference/nl/client-tinyfugue.md b/content/reference/nl/client-tinyfugue.md new file mode 100644 index 0000000..19bb835 --- /dev/null +++ b/content/reference/nl/client-tinyfugue.md @@ -0,0 +1,46 @@ +--- +kind: client +slug: tinyfugue +title: TinyFugue +summary: De klassieke UNIX-terminalclient. Upstream heeft sinds 2007 niets uitgebracht; een onderhouden fork draagt hem verder. +home: https://tinyfugue.sourceforge.net/ +platform: Linux +platform: macOS +platform: BSD +capability: screen reader | unknown | +capability: TLS | yes | https://tinyfugue.sourceforge.net/ +capability: UTF-8 | unknown | +capability: MCCP | yes | https://tinyfugue.sourceforge.net/ +capability: GMCP | unknown | +capability: MSDP | unknown | +capability: ATCP | unknown | +capability: MXP | unknown | +capability: MSP | unknown | +capability: scripting | yes | https://tinyfugue.sourceforge.net/ +see-also: clients/tintin +see-also: clients/blightmud +--- + +TinyFugue — "tf" — is de terminalclient die een groot deel van de MUSH-wereld twee decennia lang +gebruikt heeft, met aparte deelvensters voor invoer en uitvoer, een eigen macrotaal, en een stel +gewoonten die verscheidene van zijn concurrenten overleefd hebben. + +**Upstream ligt stil**: de laatste release is 5.0 bèta 8, van januari 2007. Hij bouwt nog steeds en +hij werkt nog steeds. + +Een onderhouden fork, *TinyFugue Rebirth*, wordt actief uitgebracht en voegt GMCP, ATCP, +ondersteuning voor brede tekens via ICU, en scripting in Python en Lua naast de eigen macrotaal toe. +De tabel hierboven beschrijft **upstream**, want dat is waar "TinyFugue" naar verwijst; installeer +je vandaag, dan is de fork het eerst bekijken waard. + +## De valstrik in de documentatie van deze client + +Upstream heeft een documentatieonderwerp met de naam **"non-visual mode"**. Dat gaat niet over +hulptechnologie — het gaat erover de invoer op de onderste regel te houden — en het noemt nergens +een schermlezer, spraak of blinde gebruikers. Een mogelijkhedentabel die met trefwoorden bij elkaar +gezocht is, zou van die bestandsnaam een ja maken. Deze zegt onbekend, want dat is wat de +documentatie draagt. + +UTF-8 is een antwoord van dezelfde vorm: de gedocumenteerde ondersteuning voor codering geldt de +8-bits ISO 8859-tekensets, en we vonden bij upstream in geen enkele richting een uitspraak over +UTF-8. diff --git a/content/reference/nl/client-vipmud.md b/content/reference/nl/client-vipmud.md new file mode 100644 index 0000000..c7442c3 --- /dev/null +++ b/content/reference/nl/client-vipmud.md @@ -0,0 +1,43 @@ +--- +kind: client +slug: vipmud +title: VIP Mud +summary: Een commerciële Windows-client die van de grond af voor blinde spelers gebouwd is. Hij noemt zeven schermlezers — en publiceert vrijwel niets over zijn protocolondersteuning. +home: https://www.gmagames.com/vipmud.shtml +platform: Windows +capability: screen reader | yes | https://www.gmagames.com/vipmud.shtml +capability: TLS | unknown | +capability: UTF-8 | unknown | +capability: MCCP | unknown | +capability: GMCP | unknown | +capability: MSDP | unknown | +capability: ATCP | unknown | +capability: MXP | unknown | +capability: MSP | yes | https://www.gmagames.com/vipmud.shtml +capability: scripting | yes | https://www.gmagames.com/vipmud.shtml +see-also: clients/mudlet +see-also: clients/blightmud +--- + +VIP Mud is de ene client in dit onderdeel waarvan het *hele* ontwerpuitgangspunt toegankelijkheid +is. Hij is commercieel — dertig dollar, met een volledige proefperiode van dertig dagen waarna hij +blijft werken met een beperkte set functies — en het is een Windows-programma. + +Het is met afstand de sterkste aanspraak op toegankelijkheid hier, en ongewoon genoeg is die +specifiek. De productpagina noemt **JAWS, Window-Eyes, System Access, NVDA, Cobra, SuperNova/Hal en +Microsoft SAPI** als werkend zonder verdere ingrepen, en beschrijft functies die alleen ergens op +slaan als je goed over het probleem hebt nagedacht: verschillende stemmen per venster en per soort +uitvoer, spam uit de spraak weren terwijl die nog wel getoond wordt, en verscheidene manieren om +ASCII-kunst te onderdrukken — het meest vijandige dat een MUD naar een schermlezer stuurt. + +## Waarom de rest van de tabel leeg is + +Omdat de leverancier een marketingpagina publiceert en geen handleiding. Niets daarop noemt GMCP, +MSDP, MCCP, MXP, ATCP, TLS of tekencodering; het beschrijft het product als "a Telnet-based client" +en laat het daarbij. **Negen keer onbekend op een rij is geen oordeel over de software.** Het is hoe +een matrix eruitziet wanneer de enige beschikbare bron één pagina is, en het als negen keer nee +publiceren zou een leugen zijn over een product dat het allemaal heel goed zou kunnen doen. + +Nog twee dingen die we niet konden vaststellen: enige releasedatum voor de huidige versie, en of hij +nog actief ontwikkeld wordt — de leverancier is in februari 2025 overgenomen, en de productpagina +draagt een copyright uit 2016. diff --git a/content/reference/nl/codebase-aresmush.md b/content/reference/nl/codebase-aresmush.md new file mode 100644 index 0000000..eb69b88 --- /dev/null +++ b/content/reference/nl/codebase-aresmush.md @@ -0,0 +1,34 @@ +--- +kind: codebase +slug: aresmush +title: AresMUSH +summary: Een moderne rollenspelserver in Ruby, met een webfront-end en scènegereedschap ingebouwd in plaats van in softcode geschreven. +codebase: AresMUSH +home: https://aresmush.com/ +see-also: collaborative-roleplay +see-also: codebases/pennmush +see-also: codebases/evennia +--- + +AresMUSH is de nieuwste veelgebruikte server die zich volledig op **gezamenlijk rollenspel** richt, +en neemt een andere positie in dan de TinyMUSH-lijn die hij opvolgt. Waar een PennMUSH-spel zijn +scènesysteem, zijn personagebladen en zijn takenwachtrij opbouwt uit softcode die geschreven is door +wie er toevallig was, levert Ares die als kant-en-klare functies mee en verwacht het dat de staf van +een spel ze instelt in plaats van programmeert. + +Het komt met een **webportaal** — personagewiki's, scènelogs, forums en het spel zelf, allemaal +bereikbaar vanuit een browser — wat voor een genre waarin mensen de logs achteraf lezen een verschil +in soort is en niet in graad. + +De configuratie staat in YAML; uitbreidingen zijn Ruby-plug-ins. Er is geen programmeertaal in het +spel voor spelers, en dat is de ruil: minder touw, minder touwgerelateerd letsel, en minder van de +improviserende bouwcultuur waar de MUSH-lijn haar naam aan dankt. + +## Hoe het er van buitenaf uitziet + +Geen MSSP. Het beantwoordt een `WHO` vóór het inloggen, en dat antwoord is een **lijst per speler** +in plaats van een kaal getal, dat onze parser op structuur telt. Op het spel dat we gemeten hebben +werden geen telnet-opties onderhandeld. + +Kies je tussen dit en PennMUSH voor een nieuw rollenspel, dan is de vraag ruwweg of je een systeem +wilt dat je instelt of een systeem dat je schrijft. diff --git a/content/reference/nl/codebase-circlemud.md b/content/reference/nl/codebase-circlemud.md new file mode 100644 index 0000000..41ef132 --- /dev/null +++ b/content/reference/nl/codebase-circlemud.md @@ -0,0 +1,28 @@ +--- +kind: codebase +slug: circlemud +title: CircleMUD +summary: De DikuMUD-afgeleide die goed genoeg gedocumenteerd was om les mee te geven. Enorm veel geforkt; draait nog steeds. +codebase: CircleMUD +home: https://www.circlemud.org/ +see-also: codebases/dikumud +see-also: codebases/tbamud +see-also: codebases/rom +--- + +CircleMUD is een DikuMUD-afgeleide waarvan het onderscheidende kenmerk nooit een spelmechaniek was: +het was de **documentatie**. De uitgave van Jeremy Elson was schoon, van commentaar voorzien en +vergezeld van een programmeergids, en daardoor werd het de codebase waarop mensen C leerden, een MUD +draaiden en forkten zonder eerst iets te hoeven reverse-engineeren. + +Het gevolg is dat een zeer groot aantal draaiende spellen Circle-afgeleiden zijn die er verscheidene +generaties van verwijderd liggen, vaak zonder dat het woord ergens voorkomt waar een speler het +ziet. + +De ontwikkeling van Circle zelf is allang afgerond; **tbaMUD** is de voortzetting, en een +Circle-spel dat vandaag onderhouden wordt, wordt meestal als tbaMUD onderhouden. + +## Hoe het er van buitenaf uitziet + +MSSP, beantwoord op verzoek. Geen `WHO` op het inlogscherm — de Diku-familie biedt die over het +algemeen niet aan, en dat is de reden dat een gids die alleen inlogschermen leest hier niets ziet. diff --git a/content/reference/nl/codebase-cobramush.md b/content/reference/nl/codebase-cobramush.md new file mode 100644 index 0000000..d0dd681 --- /dev/null +++ b/content/reference/nl/codebase-cobramush.md @@ -0,0 +1,25 @@ +--- +kind: codebase +slug: cobramush +title: CobraMUSH +summary: Een PennMUSH-fork met een eigen model van divisies en bevoegdheden. Kleine verspreiding, antwoordt nog steeds. +codebase: CobraMUSH +home: https://cobramush.org/ +see-also: codebases/pennmush +see-also: codebases/rhostmush +--- + +CobraMUSH is van PennMUSH afgesplitst en voegde een *divisie*model toe — een hiërarchie van +administratief gezag met delegeerbare bevoegdheden, in plaats van het vlakke onderscheid tussen +wizard en royalty dat zijn ouder hanteert. Spellen die stukjes stafbevoegdheid willen uitdelen +zonder alles uit te delen, zijn zijn publiek. + +Softcode die voor PennMUSH geschreven is draait grotendeels, en de verschillen zitten geconcentreerd +in precies het gebied waar de fork om begonnen was. + +## Hoe het er van buitenaf uitziet + +Geen MSSP, een werkende `WHO` vóór het inloggen, en op het spel dat we gemeten hebben werden er +helemaal geen telnet-opties onderhandeld. Dat laatste is geen kritiek: een server die nergens over +onderhandelt is een server die onderhandeling niet fout kan doen, en platte tekst over een platte +socket is het ene ding dat elke client in deze hobby aankan. diff --git a/content/reference/nl/codebase-coffeemud.md b/content/reference/nl/codebase-coffeemud.md new file mode 100644 index 0000000..2dfe2be --- /dev/null +++ b/content/reference/nl/codebase-coffeemud.md @@ -0,0 +1,29 @@ +--- +kind: codebase +slug: coffeemud +title: CoffeeMUD +summary: Een MUD-server in Java, met het grootste MSSP-rapport van alles wat we gepeild hebben en een ongewoon breed protocoloppervlak. +codebase: CoffeeMUD +home: https://www.coffeemud.net/ +see-also: codebases/dikumud +see-also: protocols/mssp +--- + +CoffeeMUD is een MUD-server in Java met een ongewoon breed functieoppervlak — hij komt met een eigen +webserver, mail, forums en een groot klassen- en vaardighedensysteem, en het is een van de weinige +servers in de hobby die niet in C geschreven is. + +Hij wordt actief onderhouden, wat naar de maatstaven van dit deel van de catalogus het hardop zeggen +waard is. + +## Hoe het er van buitenaf uitziet + +MSSP en **MCCP2**, en CoffeeMUD is een van de slechts drie servers van de twintig die we probeerden +die ook de *platte-tekstvorm* `MSSP-REQUEST` beantwoordde — een variant die ouder is dan de +telnet-optie en die je nog af en toe tegenkomt. + +Zijn MSSP-rapport is het grootste dat we gemeten hebben: **47 velden**, waaronder `PORT` dat negen +keer afzonderlijk gemeld wordt voor negen afzonderlijke poorten. Dat is geen misvorming. +MSSP-variabelen zijn lijsten, en een crawler die een meerwaardige `PORT` platslaat tot één string +produceert het getal `80234201` uit `"80" "23" "4201"` — een bug die dit project heeft uitgeleverd +en hersteld, en de reden dat de parser hier waarden overal als lijsten bewaart. diff --git a/content/reference/nl/codebase-dikumud.md b/content/reference/nl/codebase-dikumud.md new file mode 100644 index 0000000..99251a2 --- /dev/null +++ b/content/reference/nl/codebase-dikumud.md @@ -0,0 +1,36 @@ +--- +kind: codebase +slug: dikumud +title: DikuMUD +summary: De wortel van de familie gevecht-MUD's. Levels, klassen, uitrusting en areabestanden — en een licentie die een generatie afgeleiden vormgaf. +codebase: DikuMUD +home: https://dikumud.com/ +see-also: codebases/circlemud +see-also: codebases/rom +see-also: codebases/smaug +see-also: mush-mud-muck-moo +--- + +DikuMUD, geschreven aan het Datalogisk Institut van de Universiteit van Kopenhagen en uitgebracht in +1991, is de voorouder van het meeste waar mensen op doelen als ze zonder nadere aanduiding "MUD" +zeggen. Levels, personageklassen, hit points, mobs, uitrustingsplekken, een areabestandsformaat dat +een bouwer offline schrijft — de hele woordenschat komt hiervandaan, en spellen die nooit +Diku-broncode gezien hebben erven nog altijd zijn vorm. + +De licentie is deel van het verhaal. Diku was vrij te gebruiken maar verbood geld vragen voor +toegang en eiste dat de oorspronkelijke credits getoond werden, en door die clausule verschijnen "de +Diku-credits" op het inlogscherm van spellen die er verscheidene forks van verwijderd liggen. + +De directe afstammelingen — **Merc**, daarna **ROM**, **CircleMUD**, **SMAUG**, **tbaMUD** en +tientallen andere — vormen een groot deel van elke MUD-lijst die ooit bestaan heeft. + +## Hoe het er van buitenaf uitziet + +De Diku-familie is de **MSSP**-familie. Waar de MUSH-kant een telling publiceert via een `WHO` op +het inlogscherm en helemaal geen MSSP aanbiedt, beantwoorden servers uit de Diku-lijn overweldigend +vaak telnet-optie 70 met een gestructureerd rapport, en daar komen hun getallen hier vandaan. + +**MCCP2** — streamcompressie — komt in deze familie ook veel voor, en het is de moeite waard te +weten dat een client die erover onderhandelt maar de stream niet kan uitpakken het hele +verbindingsscherm als binaire ruis ontvangt. Dat was een echt gebrek in de eigen telnet-bibliotheek +van dit project en het is hersteld; zie [MCCP](/reference/protocols/mccp). diff --git a/content/reference/nl/codebase-evennia.md b/content/reference/nl/codebase-evennia.md new file mode 100644 index 0000000..722c38d --- /dev/null +++ b/content/reference/nl/codebase-evennia.md @@ -0,0 +1,34 @@ +--- +kind: codebase +slug: evennia +title: Evennia +summary: Een Python-framework in plaats van een afgerond spel. Twee Evennia-spellen kunnen niets gemeen hebben behalve het leidingwerk. +codebase: Evennia +home: https://www.evennia.com/ +see-also: codebases/aresmush +see-also: collaborative-roleplay +see-also: protocols/gmcp +--- + +Evennia is een **MU\*-framework**, geen spel — dat is het eerste wat je erover moet weten en het is +wat Evennia-spellen onderling vergelijken zinloos maakt. Het is een Python-bibliotheek gebouwd op +Django en Twisted die je accounts, objecten, kamers, commando's, een persistentielaag en de +netwerkstack geeft, en die vervolgens verwacht dat jij het spel schrijft. + +Het gevolg is dat "draait op Evennia" je veel minder over een spel vertelt dan "draait op PennMUSH" +doet. Er zijn gevecht-MUD's op Evennia en er zijn rollenspellen op Evennia en ze delen geen enkele +woordenschat. Twee Evennia-spellen hebben mogelijk geen enkel commando gemeen. + +Voor een ontwikkelaar die al Python kent is dit de kortste weg van niets naar een draaiende wereld, +en het is waar een flink deel van de nieuwe spellen sinds het midden van de jaren 2010 begonnen is. + +## Hoe het er van buitenaf uitziet + +Evennia biedt **MSSP** aan, en publiceert daarmee een spelerstelling. Op het spel dat we gemeten +hebben onderhandelde het ook over **MCCP2** — compressie — wat kenmerkend is voor een stack die zijn +telnet serieus nam. + +Omdat Evennia een framework is, is wat een bepaald spel onderhandelt deels de beslissing van dat +spel. De adoptiecijfers op de protocolpagina's zijn tellingen van wat servers ons daadwerkelijk +aangeboden hebben, niet van wat het framework kan, en voor Evennia liggen die twee verder uit elkaar +dan voor de meeste. diff --git a/content/reference/nl/codebase-fluffos.md b/content/reference/nl/codebase-fluffos.md new file mode 100644 index 0000000..dc626d4 --- /dev/null +++ b/content/reference/nl/codebase-fluffos.md @@ -0,0 +1,34 @@ +--- +kind: codebase +slug: fluffos +title: FluffOS +summary: De onderhouden opvolger van MudOS, en de driver waarop de meeste overgebleven LPMud-spellen draaien. Het spel is in LPC geschreven, niet in C. +codebase: FluffOS +home: https://www.fluffos.info/ +see-also: codebases/dikumud +see-also: mush-mud-muck-moo +--- + +De LPMud-traditie deelt de wereld anders in dan Diku. Er is een **driver** — een C-programma dat een +objectgeoriënteerde interpreter draait — en een **mudlib**, en die is het hele spel, geschreven in +**LPC** en geladen door de driver. Kamers, gevecht, commando's en de inlogreeks zijn allemaal +mudlib-objecten; de driver weet van geen van alle. + +Dat maakt een LPMud in geest dichter bij een MUSH dan zijn gevechtssystemen doen vermoeden: het spel +is geschreven in een taal die binnen het spel leeft, en twee LPMuds die een driver delen delen +mogelijk niets anders. + +**MudOS** was jarenlang de dominante driver; **FluffOS** is de onderhouden voortzetting ervan en is +waar een draaiend LP-spel vandaag het meest waarschijnlijk op draait. Bekende mudlibs — Nightmare, +Lima, die van Discworld zelf — zijn opnieuw aparte projecten. + +## Hoe het er van buitenaf uitziet + +MSSP en **MCCP2** op het FluffOS-spel dat we gemeten hebben. MudOS was een van slechts twee +codebases in ons onderzoek die *zowel* MSSP als een `WHO` op het inlogscherm beantwoordde, al was de +`WHO` die het gaf een opsomming per speler in plaats van een telling. + +Omdat de mudlib het spel is, is wat een bepaald LP-spel onderhandelt evenzeer een beslissing van de +mudlib als van de driver — de adoptiecijfers op de protocolpagina's tellen wat servers ons +daadwerkelijk aangeboden hebben, wat voor deze familie een zwakker signaal over de codebase is dan +elders. diff --git a/content/reference/nl/codebase-moo.md b/content/reference/nl/codebase-moo.md new file mode 100644 index 0000000..af76b53 --- /dev/null +++ b/content/reference/nl/codebase-moo.md @@ -0,0 +1,29 @@ +--- +kind: codebase +slug: moo +title: MOO +summary: Objectgeoriënteerd, volledig van binnenuit bewerkt, en evenzeer een onderzoeks- en onderwijsplatform als een spelengine. +codebase: MOO +home: https://www.ipomoea.org/moo/ +see-also: mush-mud-muck-moo +see-also: codebases/muck +--- + +MOO — *MUD, Object-Oriented* — voert het idee dat "de wereld zichzelf bewerkt" verder door dan wat +dan ook in de hobby. LambdaMOO, de oorspronkelijke server, levert een kleine C-kern en een database; +vrijwel alles wat een gebruiker ervaart is geschreven **in de MOO-taal, binnen de draaiende +database, door de mensen die haar gebruiken**. Er is geen bronbestand voor een kamer. + +Die eigenschap gaf MOO's een leven buiten spellen. Door de jaren negentig heen werden ze gebruikt +voor onderwijs, conferenties en onderzoek — Diversity University, BioMOO, Jay's House — en de +technische literatuur over MOO is onevenredig academisch voor een codebase in deze hoek. + +De verspreiding is vandaag klein maar werkelijk niet nul, en de servers die er nog zijn draaien vaak +al tientallen jaren onafgebroken. + +## Hoe het er van buitenaf uitziet + +Geen MSSP, en geen `WHO` die we konden ontleden op het spel dat we gemeten hebben. Wat het wel had +was een zin in zijn verbindingsscherm die luidde *"one of three players are active"* — en daar komt +de lezer voor voluit geschreven getallen in deze crawler vandaan. Een parser die alleen naar cijfers +kijkt ziet daar helemaal geen telling, en zou dat spel voor altijd als onbekend gemeld hebben. diff --git a/content/reference/nl/codebase-muck.md b/content/reference/nl/codebase-muck.md new file mode 100644 index 0000000..0e1e6b9 --- /dev/null +++ b/content/reference/nl/codebase-muck.md @@ -0,0 +1,31 @@ +--- +kind: codebase +slug: muck +title: MUCK +summary: Een TinyMUD-afstammeling met een eigen Forth-achtige taal in het spel, en een sociale cultuur die van de MUSH-kant verschilt. +codebase: MUCK +home: https://www.fuzzball.org/ +see-also: mush-mud-muck-moo +see-also: codebases/tinymush +see-also: codebases/moo +--- + +MUCK — in de praktijk vrijwel altijd **Fuzzball MUCK** — is eerder een broer of zus van de MUSH-lijn +dan een afstammeling ervan: beide komen van TinyMUD, en beide zetten een programmeertaal binnen in +het spel. + +De taal is het zichtbare verschil. MUF (*Multi-User Forth*) is stapelgebaseerd en leest in niets als +MUSH-softcode; een bouwer die de ene vloeiend beheerst is beginner in de andere. Daarboven zit MPI, +een kleinere taal voor inline-expressies die gebruikt wordt voor de dingen die softcode op een MUSH +zou doen. + +Cultureel is MUCK het thuis van een groot deel van de sociale en fandomwerelden van de hobby. Die +spellen zijn meestal gebouwd rond aanwezigheid en gesprek in plaats van rond scènes met een begin en +een eind, wat een echt verschil met de rollenspel-MUSH-traditie is en geen kwestie van thema. + +## Hoe het er van buitenaf uitziet + +Geen MSSP. Een `WHO` vóór het inloggen die met een telling antwoordt. Geen telnet-opties +onderhandeld op het spel dat we gemeten hebben — en één detail uit het onderzoek is het bewaren +waard: zijn `WHO`-antwoord eindigde op een spatie zonder regeleinde, en dat is het soort ding +waardoor een naïeve parser helemaal niets meldt. diff --git a/content/reference/nl/codebase-pennmush.md b/content/reference/nl/codebase-pennmush.md new file mode 100644 index 0000000..b1625b6 --- /dev/null +++ b/content/reference/nl/codebase-pennmush.md @@ -0,0 +1,52 @@ +--- +kind: codebase +slug: pennmush +title: PennMUSH +summary: De meest verspreide MUSH-server. Softcode, een lange releasegeschiedenis, en een van slechts twee codebases in ons onderzoek die zowel MSSP als een WHO vóór het inloggen beantwoorden. +codebase: PennMUSH +home: https://www.pennmush.org/ +see-also: codebases/tinymux +see-also: codebases/rhostmush +see-also: codebases/cobramush +see-also: mush-mud-muck-moo +see-also: protocols/mssp +--- + +PennMUSH stamt via een fork uit 1991 af van TinyMUSH, en het is de server waarop de meeste +langlopende rollenspel-MUSHes draaien. Zijn bepalende kenmerk is **softcode**: een functionele +expressietaal, van binnen het spel bewerkt door iedereen met de juiste bit gezet, waarin een groot +deel van het gedrag van een willekeurige MUSH geschreven is. Een PennMUSH-spel wordt niet zozeer +ingesteld als wel geprogrammeerd door zijn spelers. + +Versies lezen als `1.8.8p0` — een major, een minor en een patchlevel — en de patchlevel beweegt +vaak. Spellen draaien geregeld een versie die enkele patchlevels achterloopt, wat niets bijzonders +is. + +## Hoe het er van buitenaf uitziet + +PennMUSH is een van slechts twee codebases in ons eigen onderzoek onder 38 servers die *beide* +routes beantwoordde die wij peilen. Het biedt MSSP aan wanneer erom gevraagd wordt, en het +beantwoordt een `WHO` die op het inlogscherm getypt wordt, en op het spel dat we gemeten hebben +waren die twee het eens — wat zeldzamer is dan het klinkt, en waardoor PennMUSH de controle werd +waartegen we andere servers getoetst hebben. + +De `WHO` vóór het inloggen doet meer dan gemak: het is de manier waarop de MUSH-familie überhaupt +een spelerstelling publiceert, aangezien de rest van de familie meestal geen enkele MSSP aanbiedt. +Zie [MSSP](/reference/protocols/mssp) voor waarom die scheiding de reden is dat deze site vier lagen +peilt in plaats van één. + +CHARSET-onderhandeling is normaal op moderne PennMUSH, en daarom overleven namen met accenten de +reis. + +## Verwante servers + +PennMUSH, **TinyMUX**, **RhostMUSH** en **CobraMUSH** zijn vier servers met een gemeenschappelijke +voorouder en een gedeelde woordenschat — een bouwer die de ene kent, kan met moeite de softcode van +een andere lezen. Ze zijn niet compatibel: een database gaat niet zonder conversie van de een naar +de ander, en de functiebibliotheken verschillen op manieren die uitmaken. + +## SharpMUSH + +Een herimplementatie in .NET die op compatibiliteit met PennMUSH mikt is in ontwikkeling, door +dezelfde auteur als deze site. Niets op deze pagina is daaraan gemeten, en het heeft geen spellen in +de catalogus. diff --git a/content/reference/nl/codebase-rhostmush.md b/content/reference/nl/codebase-rhostmush.md new file mode 100644 index 0000000..4cf9f41 --- /dev/null +++ b/content/reference/nl/codebase-rhostmush.md @@ -0,0 +1,29 @@ +--- +kind: codebase +slug: rhostmush +title: RhostMUSH +summary: Een MUSH-server die bekendstaat om een diep rechtenmodel en een grote ingebouwde functieverzameling. Geen MSSP; beantwoordt een WHO vóór het inloggen. +codebase: RhostMUSH +home: https://github.com/RhostMUSH/trunk +see-also: codebases/pennmush +see-also: codebases/tinymux +see-also: codebases/cobramush +--- + +RhostMUSH is de vierde van de veelgebruikte servers die van TinyMUSH afstammen, en degene met het +meest uitgewerkte administratieve model: zijn rechten- en vlaggensysteem is aanzienlijk fijnmaziger +dan dat van zijn verwanten, en dat is de gebruikelijke reden dat een spel ervoor kiest. + +Zijn ingebouwde functiebibliotheek is groot, en softcode die voor Rhost geschreven is gaat vaak niet +schoon over naar PennMUSH of TinyMUX zonder dat de delen herschreven worden die functies gebruikten +die de andere niet hebben. + +## Hoe het er van buitenaf uitziet + +Geen MSSP. Een `WHO` vóór het inloggen die met een telling antwoordt. Er wordt over CHARSET +onderhandeld. + +Die combinatie — geen MSSP, een werkende `WHO` — is het handschrift van de MUSH-familie, en het is +de reden dat deze site het inlogscherm überhaupt peilt. Op grond van ons eigen onderzoek zijn de +MSSP- en de `WHO`-familie vrijwel disjunct: 28 codebases publiceren een telling via MSSP, zeven via +`WHO`, en slechts twee via beide. diff --git a/content/reference/nl/codebase-rom.md b/content/reference/nl/codebase-rom.md new file mode 100644 index 0000000..3168c92 --- /dev/null +++ b/content/reference/nl/codebase-rom.md @@ -0,0 +1,31 @@ +--- +kind: codebase +slug: rom +title: ROM +summary: De bekendste afstammeling van Merc, en de gevechtsengine waarop een groot deel van de MUD's uit de jaren negentig gebouwd is. +codebase: ROM +see-also: codebases/dikumud +see-also: codebases/smaug +see-also: protocols/mccp +--- + +ROM — *Rivers of MUD* — is een afgeleide van **Merc**, dat zelf een DikuMUD-afgeleide is, en het is +degene die is blijven hangen. Zijn gevechtsmodel, zijn vaardigheden- en spreukensysteem en zijn +areaformaat waren het beginpunt voor een enorm aantal spellen door de jaren negentig heen en daarna, +en ROM 2.4 in het bijzonder is een van de meest geforkte stukken broncode in de hobby. + +Zoals de rest van de Diku-lijn draagt het de oorspronkelijke crediteringseis mee, dus een spel +waarvan je de afstamming niet anders kunt vaststellen, noemt vaak Diku, Merc en ROM op zijn +inlogscherm. + +## Hoe het er van buitenaf uitziet + +MSSP, CHARSET en **MCCP2**, op het spel dat we gemeten hebben. + +ROM is de server waaraan dit project zijn eigen compressiebug heeft aangetoond. Onze peiling +onderhandelde MCCP2, de server begon correct te comprimeren, en de telnet-bibliotheek waar we van +afhangen pakte de stream nooit uit — dus kwam het verbindingsscherm aan als een muur van +vervangingstekens en hebben we dat even als de schuld van het spel vastgelegd. De payload liet zich +schoon uitpakken met een standaard zlib-aanroep, en dat maakte het onmiskenbaar. Het is upstream +hersteld; het verhaal staat op de pagina [MCCP](/reference/protocols/mccp), omdat het een goed +voorbeeld is van een gebrek dat er van buitenaf precies uitziet als een kapot spel. diff --git a/content/reference/nl/codebase-smaug.md b/content/reference/nl/codebase-smaug.md new file mode 100644 index 0000000..15fee74 --- /dev/null +++ b/content/reference/nl/codebase-smaug.md @@ -0,0 +1,27 @@ +--- +kind: codebase +slug: smaug +title: SMAUG +summary: Een Merc-afgeleide gebouwd voor grote thematische werelden, met goden, dimensies en een areaverzameling die vele malen groter is dan die van zijn ouder. +codebase: SMAUG +home: https://www.smaug.org/ +see-also: codebases/dikumud +see-also: codebases/rom +--- + +SMAUG — *Simulated Medieval Adventure multi-User Game* — kwam voort uit Realms of Despair en is een +Merc-afgeleide die groeide in plaats van vereenvoudigde. Waar ROM het gevecht van Merc strakker +maakte, voegde SMAUG toe: goden, dimensies, uitgebreidere area- en mobprogramma's, een veel grotere +standaardwereld, en een navenant grotere bronboom. + +Spellen die erop draaien zijn meestal de grote thematische, en verschillende draaien al +vijfentwintig jaar onafgebroken. + +## Hoe het er van buitenaf uitziet + +MSSP, beantwoord op verzoek, en verder niets onderhandeld op het spel dat we gemeten hebben. + +Let op voor wie tellingen vergelijkt: SMAUG-servers in ons onderzoek rapporteerden via MSSP en +beantwoordden geen `WHO` op het inlogscherm, dus het getal hier is de eigen MSSP-`PLAYERS` van het +spel — een bewering die de server doet, die alleen op haar woord geloofd wordt omdat we het niet +onafhankelijk konden meten. De spelpagina vermeldt van welke van de twee een gegeven cijfer kwam. diff --git a/content/reference/nl/codebase-tbamud.md b/content/reference/nl/codebase-tbamud.md new file mode 100644 index 0000000..7ff566e --- /dev/null +++ b/content/reference/nl/codebase-tbamud.md @@ -0,0 +1,25 @@ +--- +kind: codebase +slug: tbamud +title: tbaMUD +summary: De onderhouden voortzetting van CircleMUD, en het gebruikelijke antwoord op "ik wil vandaag een MUD in Diku-stijl beginnen". +codebase: tbaMUD +home: https://tbamud.com/ +see-also: codebases/circlemud +see-also: codebases/dikumud +--- + +tbaMUD — *The Builder Academy MUD* — pakte CircleMUD op waar de eigen ontwikkeling daarvan ophield +en is het sindsdien blijven bouwen, patchen en documenteren. Wil iemand een MUD met levels en +gevecht beginnen vanaf een basis die bekend goed is in plaats van vanaf een eigen fork, dan is dit +die basis. + +Het draagt de deugden van de Circle-codebase verder: leesbare C, een gedocumenteerd +areabestandsformaat, en een bouwershandleiding die ervan uitgaat dat je dit nog nooit gedaan hebt. +The Builder Academy zelf draait als een leerspel waar mensen erop leren bouwen. + +## Hoe het er van buitenaf uitziet + +MSSP, beantwoord op verzoek. Een gemeten nul op het spel dat we gepeild hebben — wat op deze site +een gevulde cel is en geen lege: we kwamen binnen, we lazen het getal, en het getal was nul. Dat is +een ander feit dan een spel dat we niet konden tellen, en die twee worden hier nooit samengevoegd. diff --git a/content/reference/nl/codebase-tinymush.md b/content/reference/nl/codebase-tinymush.md new file mode 100644 index 0000000..5cec23e --- /dev/null +++ b/content/reference/nl/codebase-tinymush.md @@ -0,0 +1,40 @@ +--- +kind: codebase +slug: tinymush +title: TinyMUSH +summary: De voorouder van de MUSH-lijn, met nog steeds draaiende spellen. Het leerde deze crawler dat zijn eigen onderhandelingsbytes het volgende commando kunnen breken dat hij stuurt. +codebase: TinyMUSH +home: https://github.com/TinyMUSH/TinyMUSH +see-also: codebases/pennmush +see-also: codebases/tinymux +see-also: mush-mud-muck-moo +--- + +TinyMUSH is waar de lijn van PennMUSH, TinyMUX, RhostMUSH en CobraMUSH allemaal van afstamt, en het +is nog steeds in gebruik. De ontwikkeling is stil, niet afwezig. + +## Hoe het er van buitenaf uitziet + +Geen MSSP. Een `WHO` vóór het inloggen die antwoordt met een zin in de vorm +`0 Players logged in, 22 record, no maximum.` + +## De bug die het in ons vond + +TinyMUSH is hier een alinea waard omdat het het spel is dat een gebrek in de eigen crawler van deze +site blootlegde, en de correctie is een goede illustratie van wat "gemeten" hoort te betekenen. + +Onze peiling las TinyMUSH wekenlang als *telling onbekend*. De gok die op papier stond was dat zijn +antwoord geen afsluitend regeleinde had. Dat heeft het wel. Van de lijn geplukt bleek de echte +oorzaak bij ons te liggen: **TinyMUSH ontleedt geen telnet op zijn inlogscherm**, dus de drie bytes +`IAC DO MSSP` die wij bij het verbinden sturen belanden in zijn invoerbuffer alsof iemand ze getypt +had. De volgende regel die het leest is niet `WHO` maar drie stuurbytes gevolgd door `WHO`, en dat +is geen commando dat het kent — dus toont het zijn verbindingsscherm opnieuw en zegt het niets over +spelers. + +De peiling stuurt nu een kaal regeleinde na het onderhandelen en gooit weg wat dat oplevert, want +die uitvoer is een reactie op bytes die *wij* verkozen te sturen en is daarom noch het +verbindingsscherm van het spel, noch zijn antwoord. TinyMUSH leest nu correct, en de peiling was in +een derde van de tijd klaar. + +Een gids die het niet nagekeken had, zou "dit spel meldt zijn spelers niet" gepubliceerd hebben +zolang het bestond, en die zin zou over ons gegaan zijn. diff --git a/content/reference/nl/codebase-tinymux.md b/content/reference/nl/codebase-tinymux.md new file mode 100644 index 0000000..7cb3498 --- /dev/null +++ b/content/reference/nl/codebase-tinymux.md @@ -0,0 +1,38 @@ +--- +kind: codebase +slug: tinymux +title: TinyMUX +summary: De andere grote MUSH-server. Softcode die dicht genoeg bij die van PennMUSH ligt om over te ruziën, helemaal geen MSSP, en een WHO vóór het inloggen die werkt. +codebase: TinyMUX +home: https://www.tinymux.org/ +see-also: codebases/pennmush +see-also: codebases/tinymush +see-also: codebases/rhostmush +see-also: mush-mud-muck-moo +--- + +TinyMUX is de tweede van de twee servers waarop de meeste gevestigde rollenspel-MUSHes draaien, en +voor veel spelers is de keuze tussen dit en PennMUSH een kwestie van welke de staf van hun spel het +eerst geleerd heeft. Versies lezen als `2.12` en dergelijke. + +Net als PennMUSH stamt het af van TinyMUSH, en de softcode ligt zo dicht bij elkaar dat een bouwer +die tussen de twee wisselt aan het vertalen is en niet aan het herleren. De verschillen zijn echt — +functiebibliotheken, een aantal hoeken van de parsing, de verzameling `@`-commando's — en het zijn +precies het soort dingen die het verplaatsen van een database tussen de twee eerder een project +maken dan een export. + +## Hoe het er van buitenaf uitziet + +**Geen MSSP.** TinyMUX biedt de optie helemaal niet aan, wat het samen met AresMUSH, MUCK, +RhostMUSH, CobraMUSH en TinyMUSH aan die kant van de hobby zet die een gids op MSSP alleen simpelweg +niet kan zien. Zijn spelerstelling komt van een `WHO` op het inlogscherm, die het met een kale +telling beantwoordt. + +Er wordt wél over CHARSET onderhandeld, en daarmee komt het op niet-ASCII-tekst beter uit de bus dan +de meeste van zijn verwanten. + +## Waar de tellingen vandaan komen + +Vergelijk je het getal van deze site voor een TinyMUX-spel met dat van een andere gids, houd er dan +rekening mee dat wij de `WHO` op het inlogscherm lezen en de meeste crawlers niet. Een gids die op +MSSP alleen gebouwd is, meldt deze spellen als hadden ze helemaal geen telling, of vermeldt ze niet. diff --git a/content/reference/nl/orientation-collaborative-roleplay.md b/content/reference/nl/orientation-collaborative-roleplay.md new file mode 100644 index 0000000..eeb8d16 --- /dev/null +++ b/content/reference/nl/orientation-collaborative-roleplay.md @@ -0,0 +1,66 @@ +--- +kind: orientation +slug: collaborative-roleplay +title: Je wilt gezamenlijk rollenspel +summary: Als je uit bent op het schrijven van scènes met andere mensen, is dit de korte weg — de codebases om naar te zoeken, een client die je niet tegenwerkt, en wat je op je eerste avond kunt verwachten. +see-also: mush-mud-muck-moo +see-also: connecting +see-also: codebases/pennmush +see-also: codebases/aresmush +see-also: codebases/evennia +see-also: clients/mudlet +--- + +Met *gezamenlijk rollenspel* bedoelen we: jij en andere mensen schrijven samen een scène, in +karakter, in proza, meestal in realtime. Niemand gooit initiatief tenzij het spel dat zegt. Dit is +het deel van de hobby dat achter het woord ‘MUD’ schuilgaat, en het leeft. + +## Zoek naar deze codebases + +Deze cultuur leeft in de TinyMUD-lijn, want die servers zijn gebouwd voor mensen die dingen maken en +niet voor mensen die dingen doden. + +- **[PennMUSH](/reference/codebases/pennmush)** en **[TinyMUX](/reference/codebases/tinymux)** — de + twee grote MUSH-servers. De meeste langlopende rollenspellen waar je van gehoord hebt draaien op + een van beide. +- **[AresMUSH](/reference/codebases/aresmush)** — modern, speciaal voor rollenspel gebouwd, met een + webfrontend en het vastleggen van scènes als eersterangsfuncties in plaats van als softcode die + iemand geschreven heeft. +- **[Evennia](/reference/codebases/evennia)** — een Python-framework in plaats van een afgerond spel. + Spellen die erop gebouwd zijn verschillen enorm; verschillende van de nieuwere rollenspellen zijn + Evennia. +- **[MUCK](/reference/codebases/muck)** — een eigen cultuur, sterk sociaal, en het kennen waard als + je op zoek bent naar de fandomwerelden. + +Je kunt de catalogus op elk hiervan filteren: elke codebasepagina linkt naar de spellen waarvan we +gemeten hebben dat ze erop draaien. + +## Een client die je niet tegenwerkt + +Je hebt er geen nodig — elk spel hier spreekt gewoon telnet, en je besturingssysteem heeft +waarschijnlijk al een client. Maar voor het schrijven van scènes doen drie dingen er meer toe dan +elders: + +1. **Een commandoregel die een lange alinea overleeft.** Je gaat poses van meerdere zinnen typen. +2. **Loggen.** Je wilt de scène achteraf hebben. +3. **UTF-8.** Namen hebben accenten. + +Zie het onderdeel [clients](/reference). Gebruik je een schermlezer, dan hebben de tabellen met +mogelijkheden daar een rij ervoor — we konden niet voor elke client een antwoord vaststellen, en waar +dat niet lukte, zegt de tabel *onbekend* in plaats van te gokken. + +## Hoe die eerste avond er echt uitziet + +De meeste rollenspellen hebben een **aanmeldprocedure**: je verbindt als gast, leest een paar +helpbestanden, en schrijft een personage uit dat de staf kan lezen. Dat kan een dag of twee weken +duren. Het is geen drempel om de drempel — het is hoe een spel een gedeelde setting samenhangend +houdt — maar het betekent wel dat ‘inloggen en spelen’ zelden de vorm ervan is. + +Spellen waar niemand is op het moment dat je kijkt zijn hier normaal, en het activiteitenraster op de +pagina van elk spel is nuttiger dan zijn actuele telling: een spel met vijftien mensen elke avond en +niemand om vier uur 's nachts is een gezond spel dat je op het verkeerde uur ziet. + +## Wat deze site je niet kan vertellen + +Of de mensen deugen. Of de staf eerlijk is. Of de setting er een is die jou bevalt. Wij meten +servers. Er is hier geen beoordeling en die komt er ook niet — dat is een bewust besluit, geen gat. diff --git a/content/reference/nl/orientation-connecting.md b/content/reference/nl/orientation-connecting.md new file mode 100644 index 0000000..c6285ea --- /dev/null +++ b/content/reference/nl/orientation-connecting.md @@ -0,0 +1,49 @@ +--- +kind: orientation +slug: connecting +title: Hoe je verbinding maakt +summary: Een host, een poort en telnet. Wat het adres op een spelpagina betekent en wat je ermee doet. +see-also: mush-mud-muck-moo +see-also: protocols/tls +see-also: protocols/charset +--- + +Elk spel dat hier vermeld staat antwoordt op een **host en een poort**, en het protocol eronder is +telnet — wat in de praktijk een kale TCP-verbinding betekent met een beetje optionele onderhandeling +erbovenop. + + telnet mush.pennmush.org 4201 + +Dat werkt, en op veel systemen staat het al geïnstalleerd. Het is ook een slechte manier om te +spelen: de `telnet` van het systeem heeft geen noemenswaardige controle over lokale echo, geen +logboek, geen geschiedenis, en hij verminkt alles boven ASCII. Het is het juiste gereedschap om te +controleren of een spel in de lucht is, en het verkeerde om er een avond in door te brengen. + +## Wat het adres op een spelpagina je vertelt + +Elke spelpagina somt de adressen op die we gemeten hebben, en markeert de adressen waarbij **TLS** +waargenomen is. Een spel met een TLS-poort is een spel waarmee je versleuteld verbinding kunt maken; +het poortnummer is meestal een ander dan dat van de gewone poort. + +Waar een spel meerdere poorten heeft, is dat vaak dezelfde wereld die op verschillende manieren +bereikt wordt en niet verschillende spellen. Wij vermelden wat we gemeten hebben en gokken niet welke +de officiële is. + +## Een client kiezen + +Het onderdeel [clients](/reference) heeft voor elk een pagina, met een tabel met mogelijkheden. De +drie dingen die het controleren waard zijn voordat je iets installeert: + +- **Doet hij UTF-8?** Als het spel niet alleen Engels is, kom je dit op je eerste avond tegen. +- **Doet hij TLS?** Alleen van belang als het spel het aanbiedt, maar verschillende doen dat + inmiddels. +- **Gebruik je een schermlezer: documenteert het project ondersteuning daarvoor?** Dit is de rij die + het vaakst ontbreekt in clientvergelijkingen, dus het is de eerste rij van de onze — en waar + niemand een antwoord heeft vastgesteld, staat er *onbekend*. + +## Als er niets antwoordt + +Een spel dat niet antwoordt is niet noodzakelijk weg. Spellen verhuizen van host, DNS verloopt, en +firewalls hebben hun eigen mening. Deze site bewaart elk spel dat ze ooit gemeten heeft — ook de +spellen die jaren geleden gestopt zijn met antwoorden — en blijft wekelijks aankloppen, dus het +[archief](/archive) is de plek om te kijken voordat je iets concludeert. diff --git a/content/reference/nl/orientation-families.md b/content/reference/nl/orientation-families.md new file mode 100644 index 0000000..df69738 --- /dev/null +++ b/content/reference/nl/orientation-families.md @@ -0,0 +1,82 @@ +--- +kind: orientation +slug: mush-mud-muck-moo +title: MUSH, MUD, MUCK, MOO — wat de woorden betekenen +summary: Vier woorden voor vier tradities, en geen ervan is een genre. Wat ze je werkelijk vertellen. +see-also: collaborative-roleplay +see-also: connecting +see-also: codebases/pennmush +see-also: codebases/aresmush +see-also: codebases/muck +see-also: codebases/moo +see-also: codebases/evennia +--- + +Elk van deze woorden noemt een **familie serversoftware**, niet een soort spel. Dat is het nuttigste +wat je erover kunt weten, en het is waarom ‘is dit een MUSH of een MUD?’ zo vaak slecht beantwoord +wordt: het eerlijke antwoord is meestal *allebei, en de vraag die je bedoelde ging over de cultuur*. + +## MUD + +De oudste term, en inmiddels de breedste. Het begon als *Multi-User Dungeon* — het spel van Bartle en +Trubshaw uit 1978 — en halverwege de jaren negentig was het het overkoepelende woord voor elke +tekstgebaseerde multiplayerwereld. + +In enge zin betekent het de **DikuMUD- en LPMud-lijnen**: servers gebouwd rond levels, gevechten, +uitrusting en een gebiedsbestand met kamers die een bouwer vooraf geschreven heeft. Zegt iemand ‘ik +speel een MUD’ en bedoelt diegene iets specifieks, dan is dit het meestal. + +De lijst kan je elke lijn apart tonen: [de DikuMUD-spellen](/games?lineage=DikuMUD) en +[de LPMud-spellen](/games?lineage=LPMud). + +## MUSH + +*Multi-User Shared Hallucination*, uit de TinyMUD-lijn. De bepalende eigenschap is niet het thema +maar de **softcode**: MUSH-servers leveren een programmeertaal mee die spelers vanuit het spel zelf +gebruiken, dus een speler met bouwrechten maakt kamers, objecten en gedrag zonder een bronbestand aan +te raken of iets opnieuw te starten. + +Die ene ontwerpbeslissing bracht de cultuur voort. MUSHes hebben doorgaans weinig geautomatiseerde +systemen en veel menselijke — plots die de staf draait, geschreven scènes, aanmeldprocedures — omdat +de mensen die spelen ook de mensen zijn die bouwen. + +PennMUSH, TinyMUSH, TinyMUX, RhostMUSH, CobraMUSH en AresMUSH zitten allemaal in deze lijn en geen +van alle zegt dat: MSSP kent geen waarde `MUSH` om te publiceren, en op PennMUSH na publiceert geen +van alle enige MSSP. Ze groeperen is dus iets wat wij doen en niet iets wat we lezen, en daarom is +[de MUSH-spellen](/games?lineage=MUSH) overal waar het voorkomt gemarkeerd als *afgeleid*. + +## MUCK + +Net als MUSH een afstammeling van TinyMUD, met eigen softcode (MUF, een Forth-achtige taal) en een +sterke traditie van sociale werelden en furry-fandomwerelden. Technisch dicht bij MUSH; cultureel +genoeg onderscheiden dat mensen die allebei spelen ze niet als hetzelfde zouden omschrijven — +[de MUCK-spellen](/games?lineage=MUCK). + +## MOO + +*MUD, Object-Oriented*. De zuiverste uitdrukking van het idee ‘het spel bewerkt zichzelf’: bijna +alles in een MOO is van binnenuit geschreven in de programmeertaal MOO, door de mensen die het +gebruiken. LambdaMOO is de voorvader, en MOO's zijn historisch even populair geweest in onderwijs en +onderzoek als in spellen. [De MOO-spellen](/games?lineage=MOO). + +## Wat moet je dan eigenlijk vragen? + +Drie vragen doen meer werk dan het woord van vier letters: + +1. **Zijn er gevechten, en zijn ze geautomatiseerd?** Dit scheidt de Diku/LP-lijn betrouwbaarder van + de TinyMUD-lijn dan welke naam ook. +2. **Wie bouwt er?** Alleen de staf, of iedereen met een bouwbit? +3. **Is het spel op afspraak of doorlopend?** Scènes op afspraak en rollenspel met poses, of + inloggen en gaan? + +De lijst op deze site kan een deel van de eerste vraag voor je beantwoorden: de **codebase** die we +voor een spel gemeten hebben vertelt je uit welke traditie zijn server komt, en het facet +**afstamming** is dat antwoord, filterbaar gemaakt. Het kan je niets over de cultuur vertellen, en +deze pagina zal niet doen alsof. + +Eén waarschuwing over dat facet, want deze pagina is waar iemand het zal tegenkomen. De codebase is +gemeten en de afstamming niet: die is *onze* groepering van wat een spel ons verteld heeft, gedragen +onder een eigen label — **afgeleid** — naast *gemeten* en *opgegeven*. Waar een codebase geen +onbetwiste ouder heeft, blijft hij buiten elke afstamming in plaats van onder de dichtstbijzijnde +geschaard te worden, en verschillende van die spellen zijn het in hun eigen woorden met ons eens, +door `FAMILY Custom` te publiceren. diff --git a/content/reference/nl/protocol-atcp.md b/content/reference/nl/protocol-atcp.md new file mode 100644 index 0000000..fd76391 --- /dev/null +++ b/content/reference/nl/protocol-atcp.md @@ -0,0 +1,31 @@ +--- +kind: protocol +slug: atcp +title: ATCP +summary: De voorloper van GMCP. Out-of-bandgegevens met een losser payloadformaat, grotendeels achterhaald, en nog altijd onderhandeld door servers die het nooit verwijderd hebben. +protocol: ATCP +see-also: protocols/gmcp +see-also: protocols/msdp +see-also: clients/mudlet +--- + +ATCP — het Achaea Telnet Client Protocol — is telnet-optie 200, en het is de plek waar het idee om +gestructureerde gegevens naast MUD-tekst te sturen voor het eerst breed werd uitgerold. Een server +stuurt een modulenaam en een payload; de client routeert die. + +Het payloadformaat is losser dan de JSON van [GMCP](/reference/protocols/gmcp), en dat is in wezen +waarom GMCP het vervangen heeft. Clients die ATCP ondersteunen documenteren het tegenwoordig +doorgaans als verouderd en verwijzen je door naar GMCP. + +## Waarom het er nog is + +Omdat er niets stukgaat door het aan te laten staan. Een server die ATCP in 2008 implementeerde en er +in 2014 GMCP bij deed, onderhandelt meestal nog over allebei, en een client die allebei ondersteunt +neemt wat hem aangeboden wordt. + +Voor een nieuwe implementatie is er geen reden om het te kiezen. + +## Wat we meten + +Servers die telnet-optie 200 aanboden in een handshake die we waargenomen hebben. Een laag cijfer is +hier te verwachten en gaat over ouderdom en verder over niets. diff --git a/content/reference/nl/protocol-charset.md b/content/reference/nl/protocol-charset.md new file mode 100644 index 0000000..6bea104 --- /dev/null +++ b/content/reference/nl/protocol-charset.md @@ -0,0 +1,39 @@ +--- +kind: protocol +slug: charset +title: CHARSET +summary: De telnet-optie uit RFC 2066 om een codering af te spreken. De reden dat namen met accenten in een spel de reis overleven, en de bron van een paar subtiele fouten wanneer hij ontbreekt. +protocol: CHARSET +home: https://www.rfc-editor.org/rfc/rfc2066 +see-also: protocols/ttype +see-also: connecting +see-also: codebases/tinymux +--- + +CHARSET is telnet-optie 42, gespecificeerd in RFC 2066. De ene kant biedt een lijst met tekensets +aan, de andere kiest er een, en daarna zijn beide het eens over hoe bytes op tekens worden afgebeeld. + +In de praktijk komt de onderhandeling uit op **UTF-8** of vindt ze helemaal niet plaats. De +MUSH-familie onderhandelt er merkbaar vaker over dan de MUD-familie — TinyMUX, RhostMUSH en PennMUSH +doen het alle drie — en dat past bij een bevolking die proza schrijft met namen erin. + +## Wat er gebeurt zonder + +Een client moet raden, en de gebruikelijke gok is ASCII of Latin-1. Gok ASCII en elke byte boven 0x7F +wordt een vraagteken; gok Latin-1 op een UTF-8-server en elk letterteken met een accent wordt twee +leestekens. Beide fouten zien eruit als de schuld van het spel en zijn dat niet. + +Voor een crawler bijt dit op één bepaalde plek. Onze eigen telnet-bibliotheek zet haar huidige +codering standaard op ASCII, en die standaardwaarde is niet onschuldig — het is waarmee elke byte +gedecodeerd wordt, voor elke server die nooit over CHARSET onderhandelt, en dat zijn de meeste. +Daarom stellen we hem bewust zelf in. + +## De ene plek waar CHARSET niet komt + +MSSP-veldnamen en -waarden worden als ASCII gedecodeerd, ongeacht waar CHARSET op uitkwam, want een +subonderhandeling is een commando en geen tekst, en de specificatie beperkt CHARSET tot tekst. Dat is +verdedigbaar conform en het is verliesgevend: een spel waarvan de MSSP-`NAME` `Café Noir` is, meldt +`Caf? Noir`, en de oorspronkelijke bytes zijn weg voordat iets wat wij in de hand hebben ze ziet. + +Zie je op deze site een verminkt teken in een opgegeven veld en niet in de uitvoer van het spel zelf, +dan is dat de reden, en van onze kant valt het niet meer te herstellen. diff --git a/content/reference/nl/protocol-gmcp.md b/content/reference/nl/protocol-gmcp.md new file mode 100644 index 0000000..27f741f --- /dev/null +++ b/content/reference/nl/protocol-gmcp.md @@ -0,0 +1,44 @@ +--- +kind: protocol +slug: gmcp +title: GMCP +summary: Het Generic Mud Communication Protocol — gestructureerde JSON-berichten naast de tekst, en het out-of-bandkanaal waar de meeste moderne clients tegenaan bouwen. +protocol: GMCP +home: https://www.mudhalla.net/tintin/protocols/gmcp/ +see-also: protocols/msdp +see-also: protocols/atcp +see-also: clients/mudlet +--- + +GMCP is telnet-optie 201. Zodra erover onderhandeld is, kan de server **gestructureerde gegevens out +of band** sturen: een pakketnaam en een JSON-payload, die in dezelfde stroom aankomen als de tekst +maar er geen deel van uitmaken. + +`Char.Vitals { "hp": 412, "maxhp": 500 }` is het klassieke voorbeeld. Een client kan daar een +levensbalk mee aansturen zonder het proza af te struinen op getallen, en dat is precies de bedoeling +— een statusweergave die op patroonherkenning in de tekst gebouwd is, breekt op de dag dat een spel +zijn prompt verandert, en een die op GMCP gebouwd is niet. + +De naamruimte van de pakketten berust op gewoonte en niet op een standaard. `Char`, `Room`, `Comm` en +`Client` zijn breed in gebruik; daarbuiten verzinnen spellen wat ze nodig hebben, en een client moet +meestal verteld worden wat een bepaald spel stuurt. + +## Waarom het ATCP verdrongen heeft + +GMCP is de opvolger van [ATCP](/reference/protocols/atcp), dat hetzelfde werk deed met een losser +payloadformaat. JSON was de verbetering, en halverwege de jaren 2010 was de overstap grotendeels +voltooid. Een spel dat allebei ondersteunt is niet ongewoon; een nieuw spel dat alleen ATCP +ondersteunt zou dat wel zijn. + +## Wat we meten + +Een spel telt hier mee wanneer **zijn server GMCP aanbood in een handshake die we waargenomen +hebben**. Dat is een andere bewering dan dat de MSSP van een spel `GMCP 1` zegt, waar de meeste +protocoltabellen in deze hobby op gebouwd zijn, en die twee wijken geregeld van elkaar af. + +Eén kanttekening bij de meting, uit onze eigen geschiedenis: een tijdlang konden we GMCP niet zien op +servers die ook over [MCCP](/reference/protocols/mccp) onderhandelden, doordat onze +telnet-bibliotheek over compressie onderhandelde zonder de stroom uit te pakken, en alles na het +compressiemarkeerpunt was voor ons ruis. Van minstens één server in ons onderzoek bleek dat hij al +die tijd GMCP sprak. Als een cijfer op deze pagina laag lijkt voor een familie die je goed kent, is +dat soort defect het eerste om te verdenken — bij ons, niet bij hen. diff --git a/content/reference/nl/protocol-mccp.md b/content/reference/nl/protocol-mccp.md new file mode 100644 index 0000000..fb1e400 --- /dev/null +++ b/content/reference/nl/protocol-mccp.md @@ -0,0 +1,44 @@ +--- +kind: protocol +slug: mccp +title: MCCP +summary: Compressie van de stroom. Goedkoop, breed uitgerold, en het protocol dat de leerzaamste bug in de geschiedenis van dit project opleverde. +protocol: MCCP +home: https://www.mudhalla.net/tintin/protocols/mccp/ +see-also: codebases/rom +see-also: codebases/dikumud +see-also: protocols/gmcp +--- + +MCCP comprimeert de stroom van server naar client met zlib. Versie 1 is telnet-optie 85 en is +feitelijk historisch; **versie 2** is optie 86 en is waarover moderne servers onderhandelen. Nadat de +server `IAC SB MCCP2 IAC SE` gestuurd heeft, hoort elke byte die volgt bij één doorlopende +zlib-stroom. + +Het is een echte besparing op een tekstprotocol — MUD-uitvoer laat zich uitzonderlijk goed +comprimeren — en het is gangbaar in de Diku- en LP-families, waar ruwweg een derde van de codebases +in ons onderzoek erover onderhandelt. + +## Hoe het misgaat, en waarom dat hier uitmaakt + +Een client die over MCCP2 onderhandelt en de stroom vervolgens niet uitpakt, ontvangt **binaire +rommel vanaf het compressiemarkeerpunt**. Geen foutmelding, geen verbroken verbinding: het +verbindingsscherm komt binnen als een muur van vervangingstekens, en alles daarna — het +`WHO`-antwoord, elke latere MSSP, de hele sessie — is verloren. + +Dit is niet hypothetisch. Onze eigen telnet-bibliotheek deed precies dat. Ze onderhandelde over de +optie, vuurde haar callback voor ‘compressie ingeschakeld’ af, en pakte geen enkele byte uit. De +payload liet zich met een gewone zlib-aanroep zonder problemen decomprimeren, en dat maakte +ondubbelzinnig duidelijk dat de servers gelijk hadden en wij niet. Dertien van de achtendertig +codebases in ons onderzoek waren geraakt, en zolang het duurde konden we niet waarnemen waarover die +servers *na* het begin van de compressie onderhandelden — dus ons beeld van hun mogelijkheden stelde +ze te laag voor. + +Het is upstream opgelost. Een vervolgdefect — de inflater die per leesbewerking opnieuw wordt +aangemaakt in plaats van voor de duur van de verbinding bewaard te blijven, wat halverwege een groot +verbindingsscherm misgaat — is gemeld en staat open, en treft de staart van de grootste schermen. + +Twee dingen die een lezer hieruit mee moet nemen. **Een protocolcijfer op deze pagina is net zozeer +een meting van onze crawler als van de hobby**, en waar we weten dat het fout is geweest, zeggen we +dat. En schrijf je een client: over MCCP onderhandelen is makkelijk, en het correct uitpakken is waar +het werk zit. diff --git a/content/reference/nl/protocol-msdp.md b/content/reference/nl/protocol-msdp.md new file mode 100644 index 0000000..fc99e17 --- /dev/null +++ b/content/reference/nl/protocol-msdp.md @@ -0,0 +1,31 @@ +--- +kind: protocol +slug: msdp +title: MSDP +summary: Het Mud Server Data Protocol — hetzelfde werk als GMCP, gedaan met een compacte binaire codering en een ontdekkingsmechanisme dat GMCP niet heeft. +protocol: MSDP +home: https://www.mudhalla.net/tintin/protocols/msdp/ +see-also: protocols/gmcp +see-also: clients/tintin +see-also: clients/blightmud +--- + +MSDP is telnet-optie 69, en het lost hetzelfde probleem op als [GMCP](/reference/protocols/gmcp): +gestructureerde gegevens naast de tekst sturen, zodat een client geen proza hoeft af te struinen op +getallen. + +De verschillen zijn er twee. De codering van MSDP is **binair en compact** — variabelen en waarden +worden met losse stuurbytes gemarkeerd in plaats van in JSON verpakt — en MSDP definieert een gesprek +voor **ontdekking**: een client kan met `LIST` naar `COMMANDS`, `REPORTABLE_VARIABLES` enzovoort +vragen, en te horen krijgen wat een bepaald spel ondersteunt. GMCP heeft daar geen equivalent voor, +en daarom moet een GMCP-client meestal per spel ingesteld worden. + +In de praktijk heeft GMCP het gewonnen op adoptie en houdt MSDP stand in de servers en clients die +het geïmplementeerd hebben, vaak naast GMCP. + +## Wat we meten + +Een spel telt hier mee wanneer zijn server MSDP aanbood in een handshake die we waargenomen hebben. +Zoals bij elk cijfer in dit onderdeel is dat een positieve waarneming, en de rest is niet het +tegendeel ervan — een spel dat niet meetelt, implementeert MSDP misschien niet, of we hebben zijn +handshake simpelweg nog niet gelezen. diff --git a/content/reference/nl/protocol-msp.md b/content/reference/nl/protocol-msp.md new file mode 100644 index 0000000..9e2e338 --- /dev/null +++ b/content/reference/nl/protocol-msp.md @@ -0,0 +1,41 @@ +--- +kind: protocol +slug: msp +title: MSP +summary: Het MUD Sound Protocol — de server noemt een geluidsbestand en de client speelt het af. Oud, eenvoudig, en makkelijk te verwarren met twee andere dingen. +protocol: MSP +home: https://www.zuggsoft.com/zmud/msp.htm +see-also: protocols/mxp +see-also: clients/vipmud +--- + +Met MSP kan een server een client vragen een geluid af te spelen: een instructie tussen haakjes die +een bestand noemt, een volume, een aantal herhalingen en een URL om het vandaan te halen als de +client het niet heeft. Het onderhandelt op telnet-optie 90, en het kan ook in band in de tekststroom +gestuurd worden door servers die nergens over onderhandelen. + +Het is werkelijk oud en werkelijk nog in gebruik — omgevingsgeluid in een tekstspel doet meer dan het +klinkt, en voor spelers die de geluidssignalen van een client gebruiken in plaats van het beeld is +het meer dan versiering. + +## Drie dingen die het niet is + +De clienttabellen in dit onderdeel moesten hier voorzichtig zijn, en het is de moeite waard op te +schrijven waarom: + +- **MCMP** — het Mud Client Media Protocol — is een ander protocol dat soortgelijk werk doet. + Minstens één client implementeert MCMP en geen MSP, en het ene voor het andere lezen zou een + bewering in een tabel zetten die niemand gedaan heeft. +- **De eigen scriptaanroep ‘speel een geluid af’ van een client** is geen MSP. Die speelt een lokaal + bestand af wanneer een script dat zegt; bij MSP vertelt een server een client wat hij moet + afspelen. +- **Ondersteuning via een meegeleverde plug-in is het waard om als zodanig te vermelden.** Bij één + client komt de MSP-ondersteuning als een plug-in die uitdrukkelijk geen telnet-onderhandeling doet, + wat werkt bij servers die MSP in band sturen en niet bij servers die verwachten dat erover + onderhandeld wordt. + +## Wat we meten + +Servers die telnet-optie 90 aanbieden. Doordat MSP vaak in band gestuurd wordt zonder onderhandeling, +stelt dit cijfer de uitrol te laag voor met een hoeveelheid die we niet kunnen schatten — en dat is +een beperking van wat een handshake kan zien, geen bevinding over het protocol. diff --git a/content/reference/nl/protocol-mssp.md b/content/reference/nl/protocol-mssp.md new file mode 100644 index 0000000..014aa77 --- /dev/null +++ b/content/reference/nl/protocol-mssp.md @@ -0,0 +1,60 @@ +--- +kind: protocol +slug: mssp +title: MSSP +summary: Het Mud Server Status Protocol — hoe een spel een crawler over zichzelf vertelt. Alles wat het meldt is opgegeven, niet gemeten, en deze site houdt die twee uit elkaar. +protocol: MSSP +home: https://www.mudhalla.net/tintin/protocols/mssp/ +see-also: protocols/gmcp +see-also: codebases/dikumud +see-also: codebases/pennmush +--- + +MSSP is telnet-optie 70. Een crawler stuurt `IAC DO MSSP`; een server die het ondersteunt antwoordt +met een tabel van naam/waarde-paren die hem beschrijft — naam, spelerstelling, codebase, uptime, +hostnaam, poort, genre, en wat hij verder ook wil publiceren. + +Het komt het dichtst in de buurt van wat deze hobby aan een machineleesbare gidsvermelding heeft, en +het is de reden dat verschillende gidsen überhaupt bestaan. + +## Alles in een MSSP-rapport is een bewering + +Dit is het punt waarop deze site van elke gevestigde gids verschilt. Een MSSP-rapport is het spel dat +je over zichzelf *vertelt*. `GMCP 1` in een MSSP-tabel betekent dat iemand `1` in een +configuratiebestand getypt heeft, misschien wel in 2011. Het is geen bewijs dat de server GMCP +aanbiedt, en de twee wijken vaak genoeg van elkaar af om interessant te zijn. + +Feiten die uit MSSP komen krijgen hier dus het label **opgegeven**, en waar we hetzelfde feit kunnen +meten — een mogelijkheid, door te kijken of er werkelijk over de optie onderhandeld wordt — worden +beide getoond, naast elkaar, elk met een ouderdom erbij. Een spel waarvan de MSSP al zes jaar GMCP +opgeeft en het nog nooit één keer in een handshake aangeboden heeft, is een feit dat het weten waard +is, en nergens anders is het te vinden. + +Het ene veld dat we bewust in het geheel niet laten meetellen is `CREATED`. Het is één met de hand +ingetypte regel, en het ergens voor laten meetellen zou dat iets triviaal manipuleerbaar maken. + +## Wie het beantwoordt + +MSSP is het antwoord van **Diku en LP**. In ons eigen onderzoek onder 38 codebases publiceerden er 28 +een spelerstelling via MSSP en zeven via een `WHO` op het inlogscherm, en maar twee deden allebei — +de twee families zijn vrijwel disjunct. AresMUSH, TinyMUX, MUCK, RhostMUSH, CobraMUSH en TinyMUSH +bieden helemaal geen MSSP aan. + +Dat is het empirische argument om vier lagen te peilen in plaats van één: **een crawler die alleen op +MSSP gebouwd is, kan het grootste deel van de MUSH-familie niet zien**, en dat is een groot deel van +de hobby en het merendeel van het beoogde publiek van deze site. + +## Vragen, niet afwachten + +Heel veel servers die MSSP volledig ondersteunen zullen het nooit uit zichzelf aanbieden — ze +beantwoorden `IAC DO MSSP` en zeggen verder niets. Een crawler die opent met `IAC WILL NAWS` en dan +wacht, meldt die spellen dus als spellen die niets publiceren, en dat is een bewering over de server +die voortkomt uit het zwijgen van de crawler zelf. Wij sturen `IAC DO MSSP` bij het verbinden. + +## De vorm in platte tekst + +Er bestaat een oudere variant waarin een client bij het inlogscherm letterlijk de regel +`MSSP-REQUEST` stuurt. We hebben het gemeten: van de twintig geprobeerde spellen antwoordden er drie +— en alle drie beantwoordden ook telnet-optie 70, dus het bereikte niets wat de optie niet al +bereikte. Acht servers lazen het verzoek als een **personagenaam** en zeiden dat ook, waarmee een van +de inlogpogingen opging die een vreemde krijgt. Wij sturen het niet. diff --git a/content/reference/nl/protocol-mxp.md b/content/reference/nl/protocol-mxp.md new file mode 100644 index 0000000..b3a94a3 --- /dev/null +++ b/content/reference/nl/protocol-mxp.md @@ -0,0 +1,41 @@ +--- +kind: protocol +slug: mxp +title: MXP +summary: Het MUD eXtension Protocol — HTML-achtige opmaak in de tekststroom, goed voor klikbare links, afbeeldingen en formulieren. Breed gespecificeerd, ongelijkmatig geïmplementeerd. +protocol: MXP +home: https://www.zuggsoft.com/zmud/mxp.htm +see-also: protocols/pueblo +see-also: clients/mushclient +see-also: clients/mudlet +--- + +MXP bouwt een kleine, HTML-achtige opmaaktaal in de tekst die een server stuurt: `` voor een +klikbaar commando, `` voor een link, elementen voor kleur en lettertype, en een mechanisme +waarmee een server eigen tags kan definiëren. Het onderhandelt op telnet-optie 91. + +Het ontwerpprobleem zit er inherent in en is interessant: de opmaak reist in dezelfde stroom als de +tekst, dus een server moet oppassen met tekst die er *uitziet* als opmaak, en een client moet +oppassen met wat hij weergeeft. Precies daarom definieert MXP beveiligingsniveaus — een tag die +binnenkomt in een regel chat van een andere speler is niet hetzelfde als een tag die de server zelf +uitgestuurd heeft. + +## Klikbaarheid is waarom mensen het willen + +Waar MXP in de praktijk vooral voor gebruikt wordt, is `north` en namen van voorwerpen veranderen in +dingen waarop je kunt klikken. Voor een nieuwe speler scheelt dat aanzienlijk, en daarom blijft het +protocol geïmplementeerd worden ondanks zijn complexiteit. + +## Pueblo is die andere + +[Pueblo](/reference/protocols/pueblo) is ouder dan MXP en doet soortgelijk werk met een andere +aanpak, die letterlijker de vorm van HTML heeft. Een client die het ene ondersteunt, ondersteunt het +andere vaak niet, en de twee zijn makkelijk te verwarren bij het lezen van een functielijst — een +fout waarmee we in de clienttabellen in dit onderdeel hebben moeten oppassen. + +## Wat we meten + +Servers die telnet-optie 91 aanboden in een handshake die we waargenomen hebben. Er wordt minder vaak +over MXP onderhandeld dan over de out-of-bandprotocollen, deels doordat veel van zijn waarde +gerealiseerd wordt door servers die de opmaak gewoon uitsturen en hopen, zonder ergens over te +onderhandelen — en dat kunnen wij niet zien. diff --git a/content/reference/nl/protocol-pueblo.md b/content/reference/nl/protocol-pueblo.md new file mode 100644 index 0000000..6f363f6 --- /dev/null +++ b/content/reference/nl/protocol-pueblo.md @@ -0,0 +1,32 @@ +--- +kind: protocol +slug: pueblo +title: Pueblo +summary: Het oudere schema voor HTML in een MUD, afkomstig uit de gelijknamige client. Nog altijd ondersteund door clients aan de MUSH-kant, en stelselmatig verward met MXP. +protocol: PUEBLO +home: https://pueblo.sourceforge.net/ +see-also: protocols/mxp +see-also: clients/beipmu +--- + +Pueblo kwam halverwege de jaren negentig voort uit de gelijknamige client en koos een directe aanpak +om MUD-tekst rijker te maken: laat de server **HTML** sturen, en laat de client die weergeven. Een +server kondigt Pueblo-ondersteuning aan in een regel bij het verbinden; de client antwoordt, en vanaf +dat moment mag de stroom opmaak bevatten. + +Het bereikte de MUSH-kant van de hobby meer dan de MUD-kant, en MUSH-servers die het ondersteunen +doen dat over het algemeen nog steeds. + +## Niet MXP + +[MXP](/reference/protocols/mxp) is het latere schema en het breder geïmplementeerde. Ze doen +soortgelijk werk en zijn niet uitwisselbaar, en de Pueblo-ondersteuning van een client lezen als +MXP-ondersteuning — of andersom — is de allermakkelijkste fout bij het samenstellen van een +clientvergelijking. Daarom houden de clientpagina's in dit onderdeel ze uit elkaar, en waar een +project het ene documenteert en het andere niet, zegt het andere *onbekend*. + +## Wat we meten + +De handshake van Pueblo is geen telnet-optie in de gebruikelijke zin, dus wat we waarnemen is smaller +dan bij de onderhandelde protocollen, en een laag cijfer hier moet gelezen worden als een uitspraak +over ons zicht erop en niet over de uitrol. diff --git a/content/reference/nl/protocol-tls.md b/content/reference/nl/protocol-tls.md new file mode 100644 index 0000000..aa5ea18 --- /dev/null +++ b/content/reference/nl/protocol-tls.md @@ -0,0 +1,37 @@ +--- +kind: protocol +slug: tls +title: TLS +summary: Versleutelde verbindingen. Meestal een aparte poort in plaats van een onderhandelde opwaardering, en de enige mogelijkheid op deze site die we vaststellen door verbinding te maken in plaats van door te vragen. +protocol: TLS +see-also: connecting +see-also: protocols/charset +see-also: clients/potato +--- + +Telnet is platte tekst. Alles wat je naar een MU\* stuurt — je wachtwoord inbegrepen — gaat over het +netwerk, leesbaar voor alles wat onderweg meekijkt, tenzij het spel TLS aanbiedt. + +In deze hobby betekent TLS bijna altijd **een tweede poort die vanaf de eerste byte TLS spreekt**, en +niet een opwaardering in band. Een spel met een gewone poort op 4201 en een TLS-poort op 4202 is de +gebruikelijke vorm. Er bestaat een onderhandelde variant, en die is zeldzaam genoeg dat de +documentatie van minstens één client uitdrukkelijk zegt dat hij niet ondersteund wordt. + +## Waarom de spelpagina's dit apart markeren + +TLS is de enige mogelijkheid op deze site die vastgesteld wordt door het te *doen*: een adres wordt +als TLS gemarkeerd omdat we er een TLS-handshake mee voltooid hebben. Er komt geen vragen aan te pas +en er is geen veld om iets in op te geven, wat het de zuiverste meting in de catalogus maakt. + +Daarom worden de TLS-poort en de gewone poort van een spel ook als aparte adressen vermeld en niet +samengevoegd. Het zijn verschillende metingen van verschillende dingen. + +## Praktisch advies + +Biedt een spel dat je speelt een TLS-poort aan, gebruik hem dan. Doet het dat niet en vind je het +belangrijk, vraag er dan om — het is weinig werk voor een beheerder, en dat het niet overal is, komt +vooral doordat niemand erom gevraagd heeft en niet doordat iemand bezwaar maakt. + +Controleer of je client het ondersteunt voordat je erop vertrouwt. Verschillende in het onderdeel +[clients](/reference) doen dat; minstens één documenteert in plaats daarvan een omweg met een extern +`stunnel`-proces, wat werkt en meer opzetwerk is dan de meeste mensen zullen doen. diff --git a/content/reference/nl/protocol-ttype.md b/content/reference/nl/protocol-ttype.md new file mode 100644 index 0000000..10787c9 --- /dev/null +++ b/content/reference/nl/protocol-ttype.md @@ -0,0 +1,46 @@ +--- +kind: protocol +slug: ttype +title: TTYPE en MTTS +summary: Hoe een client een server vertelt wat hij is en wat hij kan — inclusief, als de client dat wil zeggen, dat er een schermlezer in gebruik is. +protocol: TTYPE +home: https://www.mudhalla.net/tintin/protocols/mtts/ +see-also: protocols/charset +see-also: clients/tintin +see-also: clients/blightmud +--- + +TTYPE is telnet-optie 24, uit RFC 1091: de server vraagt de client welke terminal hij is, en de +client antwoordt. Historisch was het antwoord `VT100` of `ANSI`. + +**MTTS** — de Mud Terminal Type Standard — legt daar een afspraak overheen. Een client antwoordt drie +keer: zijn naam, zijn terminaltype, en dan `MTTS `, waarbij de bits mogelijkheden opgeven. +256 kleuren, true colour, UTF-8, MNES, MSP over out-of-band — en, opvallend genoeg, +**`MTTS_SCREEN_READER`**. + +## Het schermlezerbit + +Bij die laatste is het de moeite waard even stil te staan, want het is de enige plek in de +protocolstapel van deze hobby waar toegankelijkheid een eersterangsbegrip is. + +Een client die het zet, vertelt de server dat er een schermlezer in gebruik is, en een server die dat +opmerkt kan zich aanpassen: ASCII-kunst onderdrukken, de decoratieve kaderlijnen rond een +kamerbeschrijving weglaten, een tabel anders opmaken. Zowel [TinTin++](/reference/clients/tintin) als +[Blightmud](/reference/clients/blightmud) adverteert het, en [Mudlet](/reference/clients/mudlet) +heeft er een instelling voor. + +Of een bepaald spel er iets mee doet is een andere vraag, en niet een die deze site kan meten — we +kunnen een server niet vragen wat hij anders zou doen. + +## Wat een crawler hier verplicht is + +Een crawler maakt zichzelf via TTYPE bekend, en dat hoort ook. De onze doet dat, met een URL met +informatie, zodat een beheerder die zijn logboeken leest kan achterhalen wie er verbinding met zijn +spel gemaakt heeft en hoe hij ons kan vragen te stoppen. Een crawler die `ANSI` antwoordt en verder +niets, is anoniem van opzet, en daar is geen goede reden voor. + +## Wat we meten + +Servers die met ons over TTYPE onderhandeld hebben. Let op: dit is een van de weinige opties waarbij +*wij* de partij zijn die gevraagd wordt, dus een cijfer hier is een telling van servers die de moeite +namen te vragen. diff --git a/content/reference/zh-Hans/client-atlantis.md b/content/reference/zh-Hans/client-atlantis.md new file mode 100644 index 0000000..e77344c --- /dev/null +++ b/content/reference/zh-Hans/client-atlantis.md @@ -0,0 +1,40 @@ +--- +kind: client +slug: atlantis +title: Atlantis +summary: 一个只跑在 macOS 上的客户端,活得久,在 beta 里也待得久。它的脚本功能有文档说明已经不能用了,这是本节里唯一一个诚实的“否”。 +home: https://www.riverdark.net/atlantis/ +platform: macOS +capability: screen reader | unknown | +capability: TLS | yes | https://www.riverdark.net/atlantis/history.php +capability: UTF-8 | yes | https://www.riverdark.net/atlantis/history.php +capability: MCCP | yes | https://www.riverdark.net/atlantis/history.php +capability: GMCP | unknown | +capability: MSDP | unknown | +capability: ATCP | unknown | +capability: MXP | unknown | +capability: MSP | unknown | +capability: scripting | no | https://www.riverdark.net/atlantis/ +see-also: clients/mudlet +see-also: protocols/charset +--- + +Atlantis 是一个原生 macOS 客户端,从 Mac OS X 10.3 起就在了,并在 Catalina 时期更新到了 64 位。它 +能处理 RFC 2066 字符集协商和 Unicode,这比它的年纪所暗示的要好,而且它支持 MCCP 和 SSL。 + +## 本节里唯一的那个“否” + +它的脚本功能是 Perl,经由 CamelBones 桥接实现,而项目自己的主页说它已经不能用了——Apple 对 Perl 的 +处理方式变了,而那个库的作者几年前去世了。这是一处*有出处的缺失*,与未知是不同的东西,也是整个客 +户端部分里唯一带着这种答案的格子。别的每一处,诚实的答案都是我们无法确立。 + +## 我们无法确立的一切 + +它的版本历史完整而公开,其中提到了 **MCCP**、**SSL** 和**字符集协商**——而从未提到 GMCP、MSDP、 +ATCP 或 MSP。MXP 出现过一次,作为打算放进 1.0.0 之后某个版本的东西,而那个版本还没有到来。 + +脚本 API 里有一个 Perl 的 `Atlantis::Speak()` 调用,很容易把它读成屏幕阅读器支持。它不是:它是一个 +脚本化的文本转语音调用,而它所在的那套脚本系统,项目自己说是不能用的。VoiceOver、“accessible”和 +“screen reader”在主页、下载页、完整版本历史和已归档的用户指南上都不曾出现。 + +当前可下载的是 0.9.9.8,名义上仍是 beta,而站上任何地方都没有公布发布日期。 diff --git a/content/reference/zh-Hans/client-beipmu.md b/content/reference/zh-Hans/client-beipmu.md new file mode 100644 index 0000000..da43ba8 --- /dev/null +++ b/content/reference/zh-Hans/client-beipmu.md @@ -0,0 +1,42 @@ +--- +kind: client +slug: beipmu +title: BeipMU +summary: 一个瞄准这个爱好中 MUSH 那一边的 Windows 客户端,输出窗口有屏幕阅读器支持,走的是 Pueblo 而不是 MXP。 +home: https://beipdev.github.io/BeipMU/ +platform: Windows +capability: screen reader | yes | https://github.com/BeipDev/BeipMU/blob/master/Assets/Changes.txt +capability: TLS | yes | https://beipdev.github.io/BeipMU/ +capability: UTF-8 | yes | https://beipdev.github.io/BeipMU/ +capability: MCCP | unknown | +capability: GMCP | yes | https://github.com/BeipDev/BeipMU/blob/master/Documentation/GMCP.md +capability: MSDP | unknown | +capability: ATCP | unknown | +capability: MXP | unknown | +capability: MSP | unknown | +capability: scripting | yes | https://beipdev.github.io/BeipMU/ +see-also: clients/mushclient +see-also: clients/potato +see-also: collaborative-roleplay +--- + +BeipMU 是一个 MIT 许可的 Windows 客户端,发布活跃,也是少数几个在设计时想的是 MUSH 式玩法而不是战 +斗 MUD 的客户端之一——多个输入窗口、spawn 窗口,以及一个预期会遇到长段落的文本引擎。脚本默认是 +JavaScript,也可以用其他 ActiveScript 引擎。 + +## 无障碍 + +输出窗口实现了 Windows 的 `IAccessible` 接口,这是有意加进去的,作为迈向视障玩家可用性的一步,另外 +还有一个用于文本转语音的 **Speak** 触发动作。任何地方都没有点名某个具体的屏幕阅读器,文档里也没有 +无障碍相关的章节。 + +如果你要去翻,有一点需要留神:项目自己的文档里有一页至今仍写着 BeipMU 无法使用语音合成。那一页过时 +了——变更日志和维护者本人在 issue 里的留言都晚于它。 + +## 关于这个客户端的两个容易犯的错 + +**BeipMU 实现的是 MCMP,不是 MSP。**它们是两个名字相似、用途也相似的不同协议,把其中一个读成另一 +个,会在这张表里写下一个谁也没有做过的主张。所以 MSP 那一行写的是未知。 + +**它支持的是 Pueblo,不是 MXP。**Pueblo 是较早的那套“在 MUD 里用 HTML”的方案,MXP 是较晚的那套; +BeipMU 有文档说明基本的 Pueblo 样式和可点击链接。MXP 则无论有无都未能确立。 diff --git a/content/reference/zh-Hans/client-blightmud.md b/content/reference/zh-Hans/client-blightmud.md new file mode 100644 index 0000000..ae87acf --- /dev/null +++ b/content/reference/zh-Hans/client-blightmud.md @@ -0,0 +1,46 @@ +--- +kind: client +slug: blightmud +title: Blightmud +summary: 一个用 Rust 写的现代终端客户端,有 Lua 脚本、内置的文本转语音,以及一个会向服务器自报的屏幕阅读器模式。 +home: https://github.com/Blightmud/Blightmud +platform: Linux +platform: macOS +platform: Windows (WSL only) +capability: screen reader | yes | https://github.com/Blightmud/Blightmud +capability: TLS | yes | https://github.com/Blightmud/Blightmud +capability: UTF-8 | yes | https://github.com/Blightmud/Blightmud +capability: MCCP | yes | https://github.com/Blightmud/Blightmud +capability: GMCP | yes | https://github.com/Blightmud/Blightmud +capability: MSDP | yes | https://github.com/Blightmud/Blightmud +capability: ATCP | unknown | +capability: MXP | unknown | +capability: MSP | unknown | +capability: scripting | yes | https://github.com/Blightmud/Blightmud +see-also: clients/tintin +see-also: clients/mudlet +see-also: protocols/ttype +--- + +Blightmud 是一个用 Rust 写的终端客户端,GPL 3,也是本节中发布最活跃的客户端之一。脚本是 Lua。它只 +在终端里跑:没有原生的 Windows 构建,Windows 用户是在 WSL 下运行它。 + +## 无障碍 + +Blightmud 在这方面有三块彼此不同的东西,比一行所能承载的要多: + +- 一个**对屏幕阅读器友好的模式**(`--reader-mode`,或 `reader_mode` 设置),它把终端界面改成阅读器 + 跟得上的样子。它不支持状态区。 +- **内置的文本转语音**,作为一个可选的编译项,并带有脚本可以调用的 Lua API——其中包括一个 + `tts.gag()`,用来让匹配到的行不被读出来。文档很坦白地说明,把它的 TTS 和屏幕阅读器一起用,未必总 + 是一个愉快的组合。 +- **自动的 MTTS 通告**:在阅读器模式下,或者启用了 TTS 时,它会把 `MTTS_SCREEN_READER` 加进它向服 + 务器自述的内容里,这样在意这一点的游戏就可以做出调整。 + +和 TinTin++ 一样,这里没有点名任何具体的屏幕阅读器,所以这是一个有文档的模式,而不是与某个产品经过 +测试的兼容性。 + +## 表里写着未知的地方 + +**MXP**、**MSP** 和 **ATCP** 在项目的 README 和随附的帮助里都不曾出现。**MCCP** 有文档说明是 v2; +v1 是否也一并处理,我们没有确立。 diff --git a/content/reference/zh-Hans/client-mudlet.md b/content/reference/zh-Hans/client-mudlet.md new file mode 100644 index 0000000..732e6f1 --- /dev/null +++ b/content/reference/zh-Hans/client-mudlet.md @@ -0,0 +1,50 @@ +--- +kind: client +slug: mudlet +title: Mudlet +summary: 跨平台,用 Lua 编写脚本,也是本节中屏幕阅读器支持的文档写得最详尽的客户端。 +home: https://www.mudlet.org/ +platform: Windows +platform: macOS +platform: Linux +capability: screen reader | yes | https://wiki.mudlet.org/w/Manual:Screen_Readers +capability: TLS | yes | https://wiki.mudlet.org/w/Manual:Supported_Protocols +capability: UTF-8 | yes | https://wiki.mudlet.org/w/Manual:Supported_Protocols +capability: MCCP | unknown | +capability: GMCP | yes | https://wiki.mudlet.org/w/Manual:Supported_Protocols +capability: MSDP | yes | https://wiki.mudlet.org/w/Manual:Supported_Protocols +capability: ATCP | yes | https://wiki.mudlet.org/w/Manual:Supported_Protocols +capability: MXP | yes | https://wiki.mudlet.org/w/Manual:Supported_Protocols +capability: MSP | yes | https://wiki.mudlet.org/w/Manual:Supported_Protocols +capability: scripting | yes | https://github.com/Mudlet/Mudlet +see-also: clients/blightmud +see-also: clients/tintin +see-also: protocols/gmcp +see-also: connecting +--- + +Mudlet 是一个图形客户端,带地图器、一套包系统,以及一个 Lua API——它自己的大部分功能都是针对这个 +API 写出来的。它是 GPL,发布活跃,也是给一个刚要开始玩现代战斗 MUD 的人的常规推荐。 + +## 无障碍 + +这是本节中有文档支撑的理由最强的客户端,而这里的“有文档”是什么意思值得说清楚,因为它并不常见。 + +Mudlet 有**一章讲屏幕阅读器的手册内容**,有按操作系统分开的页面,点名了 Windows 上的 Narrator、 +NVDA 和 JAWS,Linux 上的 Orca,以及 macOS 上的 VoiceOver;有一条客户端内的 `mudlet access on` 命 +令,还有一个通过阅读器播报游戏来文的选项。它另有一个设置,会通过 MTTS 向服务器通告正在使用屏幕阅 +读器,这样游戏若愿意就可以做出调整。 + +它对自己哪里做得不好也很坦白:它自己的 Windows 页面说 JAWS 读输出窗口的方式和别的阅读器不一样,并 +建议改用 Narrator 或 NVDA。一个把自己无障碍支持行不通的情形也公布出来的项目,给你的信息比一个只公 +布一个对勾的项目要好。 + +## 表里写着未知的地方 + +**MCCP。**Mudlet 的源码实现了 MCCP v1 和 v2,但手册的支持协议页面没有把它列出来,而本节的规则是: +一项能力主张要引用项目自己的文档。从头文件里读出一个常量不是同一回事,所以这个格子写的是未知。 + +## 关于编码的说明 + +Mudlet 默认的服务器数据编码是 ASCII 而不是 UTF-8,CHARSET 协商是 4.10 才有的。如果在一个全新的配置 +档上某个游戏的文本显示不对,那个设置是第一个该看的地方。 diff --git a/content/reference/zh-Hans/client-mushclient.md b/content/reference/zh-Hans/client-mushclient.md new file mode 100644 index 0000000..a3ba360 --- /dev/null +++ b/content/reference/zh-Hans/client-mushclient.md @@ -0,0 +1,46 @@ +--- +kind: client +slug: mushclient +title: MUSHclient +summary: 老牌的 Windows 客户端。五种脚本语言,一套承载了它大部分协议支持的插件架构,以及一段已经放缓的发布史。 +home: https://www.mushclient.com/ +platform: Windows +platform: Linux (Wine) +capability: screen reader | unknown | +capability: TLS | unknown | +capability: UTF-8 | unknown | +capability: MCCP | yes | https://www.mushclient.com/mushclient/mccp.htm +capability: GMCP | unknown | +capability: MSDP | unknown | +capability: ATCP | yes | https://www.mushclient.com/gmcp +capability: MXP | yes | https://www.mushclient.com/mushclient/doc/general/features.html +capability: MSP | yes | https://github.com/nickgammon/mushclient/blob/master/plugins/msp.xml +capability: scripting | yes | https://www.mushclient.com/mushclient/doc/general/features.html +see-also: clients/mudlet +see-also: clients/potato +see-also: protocols/mccp +--- + +MUSHclient 是 Nick Gammon 的 Windows 客户端,MIT 许可,很长一段时间里是所有 Windows 用户的默认答 +案。它可以用 Lua、VBScript、JScript、PerlScript 和 Python 写脚本,而它所做的很多事是由插件、而不是 +由内核来承担的——这是一个货真价实的架构选择,也是上面好几行比看上去更难回答的原因。 + +最后一个打了标签的发布是 **5.06,2019 年 3 月**。仓库仍在被提交,也已经有了一个尚未发布的 5.07 的 +发布说明。 + +## 为什么这么多行写着未知 + +它们每一个都是诚实答案为“我们无法确立”的情形,而理由各不相同: + +- **GMCP**——项目自己关于它的页面给出的是一个你可以自己写的*示例*插件,而不是这个客户端具备的功 + 能。那和随产品提供支持是两回事,所以那个格子写的是未知,而不是有。 +- **TLS**——有文档的办法是外挂一个 `stunnel` 进程。一个加入 OpenSSL 支撑的 TLS 的提交在 2026 年落进 + 了 master 分支,但不在任何一个发布版里,所以今天用户装得到的东西里,没有我们指得出来的。 +- **UTF-8**——CHARSET 协商出现在尚未发布的 5.07 说明里,而在任何一个已发布版本的文档里,我们都没能 + 找到它。 +- **MSDP**——无论有无都没有说法。 +- **屏幕阅读器**——客户端随附一个用 Windows SAPI 的文本转语音插件,而那和屏幕阅读器支持不是一回 + 事。手册里没有无障碍章节,而作者本人在自己的论坛上说明过输出窗口为什么对阅读器不好用:它没有“当 + 前行”这个概念。我们无法确立一个答案,所以这张表也不给出一个。 + +这些没有一个是*否*。其中好几项很可能是有,只是我们没能证明。 diff --git a/content/reference/zh-Hans/client-potato.md b/content/reference/zh-Hans/client-potato.md new file mode 100644 index 0000000..16349c9 --- /dev/null +++ b/content/reference/zh-Hans/client-potato.md @@ -0,0 +1,42 @@ +--- +kind: client +slug: potato +title: Potato MUSHclient +summary: 一个为 MUSH 玩家写的跨平台 Tcl/Tk 客户端。编码支持不错,而它的整套文档对大多数协议只字未提。 +home: https://www.potatomushclient.com/ +platform: Windows +platform: Linux +platform: macOS +capability: screen reader | unknown | +capability: TLS | yes | https://github.com/potatomushclient/potato/wiki/ConfigureWorldsBasics +capability: UTF-8 | yes | https://github.com/potatomushclient/potato/wiki/Features +capability: MCCP | unknown | +capability: GMCP | unknown | +capability: MSDP | unknown | +capability: ATCP | unknown | +capability: MXP | unknown | +capability: MSP | unknown | +capability: scripting | yes | https://github.com/potatomushclient/potato/wiki/FAQs +see-also: clients/beipmu +see-also: clients/mushclient +see-also: collaborative-roleplay +--- + +Potato 是一个为 MUSH 玩法打造的 Tcl/Tk 客户端——多个世界、spawn 窗口,以及一套假定你敲的是 pose(扮 +演描述)而不是战斗命令的默认设置。它用同一份源码在 Windows、Linux 和 macOS 上运行,其中 macOS 的构 +建通常落后一两个版本。 + +它会协商字符编码,并且完整支持 Unicode,对这个爱好中 MUSH 那一边来说,这是实践中最要紧的一项能力。 + +有一处有文档说明的限制值得注意:它支持连接到一个从一开始就是 SSL 的端口,而它自己的配置页面说, +STARTTLS 那种协商式 SSL **不**受支持。 + +## 为什么有六行写着未知 + +我们在项目的主页、下载页、全部 103 个维基帮助文件以及整棵源码树里都搜过 GMCP、MSDP、MCCP、MXP、 +MSP 和 ATCP。关于它们中的任何一个,都没有成文的说法。确实有*代码*碰到了其中一些,而本节不会把代码 +变成一项能力主张——一张仅凭头文件里的一个常量就写下“有”的表,是在做一个项目从未做过的承诺。 + +屏幕阅读器那一行是用同样的办法得到的同样的答案:对项目发布的一切做一次不区分大小写的搜查,找 +“screen reader”、“text-to-speech”、NVDA、JAWS、VoiceOver、“accessibility”、“visually impaired”和 +“blind”,结果什么也没有。这不是关于这个软件的发现。 diff --git a/content/reference/zh-Hans/client-tintin.md b/content/reference/zh-Hans/client-tintin.md new file mode 100644 index 0000000..fa2e0b2 --- /dev/null +++ b/content/reference/zh-Hans/client-tintin.md @@ -0,0 +1,47 @@ +--- +kind: client +slug: tintin +title: TinTin++ +summary: 一个有自己脚本语言的终端客户端,在每一个平台上都能跑,手机也不例外,并且有一个成文的屏幕阅读器模式。 +home: https://tintin.mudhalla.net/ +platform: Linux +platform: macOS +platform: Windows +platform: Android +platform: iOS +capability: screen reader | yes | https://tintin.mudhalla.net/manual/screen_reader.php +capability: TLS | yes | https://github.com/scandum/tintin +capability: UTF-8 | yes | https://github.com/scandum/tintin +capability: MCCP | yes | https://tintin.mudhalla.net/ +capability: GMCP | yes | https://tintin.mudhalla.net/manual/event.php +capability: MSDP | yes | https://tintin.mudhalla.net/manual/msdp.php +capability: ATCP | unknown | +capability: MXP | unknown | +capability: MSP | unknown | +capability: scripting | yes | https://github.com/scandum/tintin +see-also: clients/blightmud +see-also: clients/mudlet +see-also: protocols/msdp +see-also: protocols/ttype +--- + +TinTin++ 是一个命令行客户端,GPL 3,发布活跃,而且它能跑的地方比这里的任何其他客户端都多——包括 +Android 和 iOS。它的脚本语言是它自有的,很简练,能做的事情却很多;别的客户端在图形界面里做的相当 +一部分事情,在这里是一行 `#config`。 + +**MSSP** 和 **MSDP** 的协议规范由同一位作者维护,这也是本节里那么多协议页面都引用同一个站点的原因。 + +## 无障碍 + +TinTin++ 有一个专门讲**屏幕阅读器模式**的手册页面(`#config screen reader on`,或者启动时加 +`-s`)。启用它会做两件事:把念出来没有意义的视觉元素去掉或改掉,以及通过 +[MTTS](/reference/protocols/ttype) 向服务器报告正在使用屏幕阅读器,这样游戏就可以调整自己的输出。 + +那是一个有文档的模式,不是与某个具体阅读器测试过的主张——那一页上没有点名任何产品。作为证据,它明 +显弱于一个点名了自己配合哪些阅读器的客户端,也明显强于什么都没有。 + +## 表里写着未知的地方 + +**MXP** 和 **MSP** 在项目站点上都有社区脚本,而一个脚本不等于客户端支持某个协议——MXP 那个脚本直接 +说了它未必能在每个 MUD 上工作。两者的原生支持都未能确立。**ATCP** 我们无论有无都没找到任何说法;顺 +带一提,ATCP 大体上已被 GMCP 取代,而 GMCP 是 TinTin++ 支持的。 diff --git a/content/reference/zh-Hans/client-tinyfugue.md b/content/reference/zh-Hans/client-tinyfugue.md new file mode 100644 index 0000000..e27d31e --- /dev/null +++ b/content/reference/zh-Hans/client-tinyfugue.md @@ -0,0 +1,40 @@ +--- +kind: client +slug: tinyfugue +title: TinyFugue +summary: 经典的 UNIX 终端客户端。上游自 2007 年起就没有再发布过;一个仍在维护的分支把它带了下去。 +home: https://tinyfugue.sourceforge.net/ +platform: Linux +platform: macOS +platform: BSD +capability: screen reader | unknown | +capability: TLS | yes | https://tinyfugue.sourceforge.net/ +capability: UTF-8 | unknown | +capability: MCCP | yes | https://tinyfugue.sourceforge.net/ +capability: GMCP | unknown | +capability: MSDP | unknown | +capability: ATCP | unknown | +capability: MXP | unknown | +capability: MSP | unknown | +capability: scripting | yes | https://tinyfugue.sourceforge.net/ +see-also: clients/tintin +see-also: clients/blightmud +--- + +TinyFugue——“tf”——是 MUSH 世界很大一部分人用了二十年的终端客户端,输入和输出分成两个窗格,有一门自 +己的宏语言,还有一套比它好几个竞争者活得更久的使用习惯。 + +**上游处于休眠状态**:最后一个发布是 5.0 beta 8,2007 年 1 月。它仍然能编译,也仍然能用。 + +一个仍在维护的分支 *TinyFugue Rebirth* 发布活跃,在原生宏语言之外加入了 GMCP、ATCP、经由 ICU 的宽 +字符支持,以及 Python 和 Lua 脚本。上面那张表描述的是**上游**,因为“TinyFugue”指向的就是上游;如果 +你今天要装,那个分支值得先看一眼。 + +## 这个客户端的文档里的陷阱 + +上游有一个叫**“non-visual mode”**的文档条目。它跟辅助技术无关——它讲的是把输入限制在最底下那一 +行——而且从头到尾没有提到屏幕阅读器、语音,也没有提到盲人用户。一张靠关键词搜索拼出来的能力表,会 +把那个文件名变成一个“有”。这一张写的是未知,因为文档所能支撑的只有这个。 + +UTF-8 是同样形状的答案:有文档的编码支持是针对 8 位的 ISO 8859 字符集,而关于 UTF-8,我们无论有无 +都没有找到上游的任何说法。 diff --git a/content/reference/zh-Hans/client-vipmud.md b/content/reference/zh-Hans/client-vipmud.md new file mode 100644 index 0000000..4a29ea7 --- /dev/null +++ b/content/reference/zh-Hans/client-vipmud.md @@ -0,0 +1,38 @@ +--- +kind: client +slug: vipmud +title: VIP Mud +summary: 一个从头为盲人玩家打造的商业 Windows 客户端。它点名了七款屏幕阅读器——却几乎没有公布任何关于协议支持的内容。 +home: https://www.gmagames.com/vipmud.shtml +platform: Windows +capability: screen reader | yes | https://www.gmagames.com/vipmud.shtml +capability: TLS | unknown | +capability: UTF-8 | unknown | +capability: MCCP | unknown | +capability: GMCP | unknown | +capability: MSDP | unknown | +capability: ATCP | unknown | +capability: MXP | unknown | +capability: MSP | yes | https://www.gmagames.com/vipmud.shtml +capability: scripting | yes | https://www.gmagames.com/vipmud.shtml +see-also: clients/mudlet +see-also: clients/blightmud +--- + +VIP Mud 是本节中*整个*设计前提就是无障碍的那一个客户端。它是商业软件——三十美元,有三十天的完整试 +用,试用期过后它仍然能用,只是功能有所削减——而且它是一个 Windows 程序。 + +它是这里遥遥领先的最强无障碍主张,而且难得地具体。产品页面点名 **JAWS、Window-Eyes、System +Access、NVDA、Cobra、SuperNova/Hal 和 Microsoft SAPI** 开箱即用,并描述了一些只有认真想过这个问题 +才会做出来的功能:按窗口、按输出类型使用不同的语音;把刷屏内容从语音里屏蔽掉、却依然显示出来;以 +及好几种抑制 ASCII 图画的办法——那是一个 MUD 发给屏幕阅读器的东西里最不友善的一样。 + +## 为什么这张表的其余部分是空的 + +因为厂商发布的是一个营销页面,而不是一份手册。上面没有任何地方提到 GMCP、MSDP、MCCP、MXP、ATCP、 +TLS 或字符编码;它把产品描述为“a Telnet-based client”,然后就没有下文了。**连着九个未知不是对这个 +软件的判决。**当唯一可得的来源只有一页时,一张矩阵就长这个样子,而把它发布成九个“否”,会是对一个 +很可能全都做得到的产品撒谎。 + +还有两件我们无法确立的事:当前版本的任何发布日期,以及它是否仍在积极开发中——这家厂商于 2025 年 2 +月被收购,而产品页面上标的版权年份是 2016。 diff --git a/content/reference/zh-Hans/codebase-aresmush.md b/content/reference/zh-Hans/codebase-aresmush.md new file mode 100644 index 0000000..fc8a951 --- /dev/null +++ b/content/reference/zh-Hans/codebase-aresmush.md @@ -0,0 +1,29 @@ +--- +kind: codebase +slug: aresmush +title: AresMUSH +summary: 一个用 Ruby 写成的现代扮演服务器,自带 Web 前端和场景工具,而不是靠 softcode 写出来的。 +codebase: AresMUSH +home: https://aresmush.com/ +see-also: collaborative-roleplay +see-also: codebases/pennmush +see-also: codebases/evennia +--- + +AresMUSH 是目前广泛使用的服务器中最新的一个,明确瞄准**协作扮演**,它所取的立场与它接续的 +TinyMUSH 一脉不同。PennMUSH 游戏的场景系统、角色卡和工单队列,是当时在场的人用 softcode 一点点搭 +出来的;Ares 则把这些直接作为功能提供,并且期待游戏的管理人员去配置它们,而不是去编写它们。 + +它自带一个 **Web 门户**——角色维基、场景日志、论坛以及游戏本身,全都可以从浏览器访问——对于一个人 +们事后要读日志的题材来说,这是种类上的差别,而不只是程度上的差别。 + +配置写在 YAML 里;扩展是 Ruby 插件。玩家没有游戏内的编程语言可用,这就是它的取舍:绳子少了,被绳 +子勒伤的机会也少了,而 MUSH 一脉赖以得名的那种即兴营造文化,同样少了。 + +## 从外面看是什么样 + +没有 MSSP。它会回应登录前的 `WHO`,而回应是一份**逐个玩家的列表**,不是一个光秃秃的数字,我们的解 +析器按结构来数它。在我们实测的那个游戏上,没有协商任何 telnet 选项。 + +如果你要为一个新的扮演游戏在它和 PennMUSH 之间做选择,问题大致是:你想要一个由你配置的系统,还是 +一个由你编写的系统。 diff --git a/content/reference/zh-Hans/codebase-circlemud.md b/content/reference/zh-Hans/codebase-circlemud.md new file mode 100644 index 0000000..dfea900 --- /dev/null +++ b/content/reference/zh-Hans/codebase-circlemud.md @@ -0,0 +1,26 @@ +--- +kind: codebase +slug: circlemud +title: CircleMUD +summary: 文档写得完善到足以拿来教学的 DikuMUD 衍生代码库。被派生的次数多得惊人;至今仍在运行。 +codebase: CircleMUD +home: https://www.circlemud.org/ +see-also: codebases/dikumud +see-also: codebases/tbamud +see-also: codebases/rom +--- + +CircleMUD 是 DikuMUD 的一个衍生版本,而它的与众不同之处从来不是某个游戏机制,而是**文档**。Jeremy +Elson 发布的代码干净、有注释,还附带一份编码指南,结果它成了人们用来学 C、用来跑一个 MUD、并且不 +必先做逆向工程就能派生的那个代码库。 + +由此带来的结果是,大量仍在运行的游戏都是隔了好几代的 Circle 衍生物,而这个词往往不会出现在任何玩 +家看得到的地方。 + +Circle 本身的开发早已结束;**tbaMUD** 是它的延续,今天还在维护的 Circle 游戏,通常是作为 tbaMUD 在 +维护。 + +## 从外面看是什么样 + +有 MSSP,索取即答。登录画面上没有 `WHO`——Diku 家族一般不提供,所以一个只读登录画面的目录,在这里 +什么也看不到。 diff --git a/content/reference/zh-Hans/codebase-cobramush.md b/content/reference/zh-Hans/codebase-cobramush.md new file mode 100644 index 0000000..604e4b4 --- /dev/null +++ b/content/reference/zh-Hans/codebase-cobramush.md @@ -0,0 +1,22 @@ +--- +kind: codebase +slug: cobramush +title: CobraMUSH +summary: 一个 PennMUSH 分支,有自己的分部(division)与权限模型。部署量不大,仍在应答。 +codebase: CobraMUSH +home: https://cobramush.org/ +see-also: codebases/pennmush +see-also: codebases/rhostmush +--- + +CobraMUSH 从 PennMUSH 分支而来,加入了一套*分部*(division)模型——一个可以逐级下放权限的管理权层 +级,取代了它上游所用的 wizard/royalty 那种扁平区分。想把管理权切成小块分出去、又不愿把全部权限一 +并交出的游戏,就是它的用户群。 + +为 PennMUSH 写的 softcode 大体上能跑,差异恰好集中在这次分支所针对的那个领域。 + +## 从外面看是什么样 + +没有 MSSP,登录前的 `WHO` 可用,而在我们实测的那个游戏上完全没有协商任何 telnet 选项。最后这一点不 +是批评:什么都不协商的服务器,也就不可能把协商弄错,而纯文本走一个普通套接字,是这个爱好里每一个 +客户端都应付得来的东西。 diff --git a/content/reference/zh-Hans/codebase-coffeemud.md b/content/reference/zh-Hans/codebase-coffeemud.md new file mode 100644 index 0000000..07fe509 --- /dev/null +++ b/content/reference/zh-Hans/codebase-coffeemud.md @@ -0,0 +1,25 @@ +--- +kind: codebase +slug: coffeemud +title: CoffeeMUD +summary: 一个用 Java 写的 MUD 服务器,拥有我们探测过的一切当中最大的一份 MSSP 报告,协议面也宽得反常。 +codebase: CoffeeMUD +home: https://www.coffeemud.net/ +see-also: codebases/dikumud +see-also: protocols/mssp +--- + +CoffeeMUD 是一个 Java 编写的 MUD 服务器,功能面宽得反常——它自带 Web 服务器、邮件、论坛和一套庞大 +的职业与技能系统,而且它是这个爱好里少数几个不是用 C 写的服务器之一。 + +它在积极维护中,按本目录这一部分的标准,这是值得大声说出来的一件事。 + +## 从外面看是什么样 + +有 MSSP 和 **MCCP2**,而且在我们试过的二十台服务器里,CoffeeMUD 是仅有的三台会同时回应*明文* +`MSSP-REQUEST` 形式的服务器之一——这种形式比那个 telnet 选项还早,如今偶尔还能见到。 + +它的 MSSP 报告是我们实测到的最大的一份:**47 个字段**,其中 `PORT` 为九个不同的端口分九次报出。这 +不是格式错误。MSSP 变量本来就是列表,而一个把多值 `PORT` 压平成单个字符串的爬虫,会从 +`"80" "23" "4201"` 造出整数 `80234201`——这个 bug 本项目发布过,也修复了,也正是这里的解析器自始至 +终把取值保留为列表的原因。 diff --git a/content/reference/zh-Hans/codebase-dikumud.md b/content/reference/zh-Hans/codebase-dikumud.md new file mode 100644 index 0000000..687c517 --- /dev/null +++ b/content/reference/zh-Hans/codebase-dikumud.md @@ -0,0 +1,31 @@ +--- +kind: codebase +slug: dikumud +title: DikuMUD +summary: 战斗类 MUD 家族的根。等级、职业、装备和区域文件——以及一份塑造了整整一代衍生代码库的许可协议。 +codebase: DikuMUD +home: https://dikumud.com/ +see-also: codebases/circlemud +see-also: codebases/rom +see-also: codebases/smaug +see-also: mush-mud-muck-moo +--- + +DikuMUD 写于哥本哈根大学的 Datalogisk Institut,1991 年发布,人们不加限定地说“MUD”时所指的东西, +大多以它为祖先。等级、角色职业、生命值、mob、装备栏位、一种由建造者离线编写的区域文件格式——整套 +词汇都出自这里,而那些从未见过 Diku 源码的游戏,依然继承了它的形状。 + +它的许可协议也是故事的一部分。Diku 可以免费使用,但禁止对访问收费,并要求显示原始致谢,正是这一条 +款,使得“Diku 致谢”会出现在与它隔了好几次分支的游戏的登录画面上。 + +它的直系后代——**Merc**,然后是 **ROM**、**CircleMUD**、**SMAUG**、**tbaMUD** 以及其他几十个——在有 +史以来的每一份 MUD 名录里都占了很大一部分。 + +## 从外面看是什么样 + +Diku 家族就是 **MSSP** 家族。MUSH 那一边通过登录画面上的 `WHO` 发布人数,完全不提供 MSSP;而 Diku +一脉的服务器绝大多数会用一份结构化报告回应 telnet 选项 70,本站上它们的数字正是从那里来的。 + +**MCCP2**——流压缩——在这个家族里也很常见,而且值得知道的是:一个协商了它、却无法解压这条流的客户 +端,收到的整个连接画面会是一堆二进制噪声。这曾是本项目所依赖的 telnet 库里一个货真价实的缺陷,现 +已修复;见 [MCCP](/reference/protocols/mccp)。 diff --git a/content/reference/zh-Hans/codebase-evennia.md b/content/reference/zh-Hans/codebase-evennia.md new file mode 100644 index 0000000..7ebfdaf --- /dev/null +++ b/content/reference/zh-Hans/codebase-evennia.md @@ -0,0 +1,30 @@ +--- +kind: codebase +slug: evennia +title: Evennia +summary: 一个 Python 框架,而不是一个做好的游戏。两个 Evennia 游戏之间,可能除了管道什么都不共享。 +codebase: Evennia +home: https://www.evennia.com/ +see-also: codebases/aresmush +see-also: collaborative-roleplay +see-also: protocols/gmcp +--- + +Evennia 是一个 **MU\* 框架**,不是一个游戏——这是关于它首先要知道的事,也正是这一点让互相比较各个 +Evennia 游戏变得没什么意义。它是一个建立在 Django 和 Twisted 之上的 Python 库,为你提供账户、对 +象、房间、命令、一层持久化和整套网络栈,然后指望你把游戏写出来。 + +由此带来的结果是,“运行 Evennia”所告诉你的东西,远少于“运行 PennMUSH”。Evennia 上有战斗 MUD,也有 +扮演游戏,它们不共享任何词汇。两个 Evennia 游戏可能连一条相同的命令都没有。 + +对一个已经会 Python 的开发者来说,这是从零到一个能跑的世界的最短路径,二〇一〇年代中期以来相当一 +部分新游戏也正是从这里起步的。 + +## 从外面看是什么样 + +Evennia 提供 **MSSP**,并通过它发布玩家人数。在我们实测的那个游戏上,它还协商了 **MCCP2**——压 +缩——这是一个认真对待自己 telnet 实现的技术栈的特征。 + +因为 Evennia 是一个框架,某个具体游戏协商什么,有一部分是那个游戏自己的决定。协议页面上的采用数字 +数的是服务器实际提供给我们的东西,而不是这个框架能做到的东西,对 Evennia 来说,这两者之间的距离比 +大多数情况都要远。 diff --git a/content/reference/zh-Hans/codebase-fluffos.md b/content/reference/zh-Hans/codebase-fluffos.md new file mode 100644 index 0000000..2729d7b --- /dev/null +++ b/content/reference/zh-Hans/codebase-fluffos.md @@ -0,0 +1,29 @@ +--- +kind: codebase +slug: fluffos +title: FluffOS +summary: 仍在维护的 MudOS 后继者,也是幸存的 LPMud 游戏大多所用的驱动。游戏是用 LPC 写的,不是用 C。 +codebase: FluffOS +home: https://www.fluffos.info/ +see-also: codebases/dikumud +see-also: mush-mud-muck-moo +--- + +LPMud 传统对世界的切分方式与 Diku 不同。这里有一个**驱动**——一个运行面向对象解释器的 C 程序——还 +有一个 **mudlib**,那才是整个游戏,用 **LPC** 写成,由驱动加载。房间、战斗、命令和登录流程全都是 +mudlib 对象;驱动对它们一无所知。 + +这使得一个 LPMud 在气质上比它的战斗系统所暗示的更接近 MUSH:游戏是用一种活在游戏内部的语言写的, +而两个共用同一个驱动的 LPMud,可能别的什么都不共享。 + +**MudOS** 多年来一直是主流驱动;**FluffOS** 是它仍在维护的延续,也是今天一个还在运行的 LP 游戏最可 +能跑在上面的东西。有名的 mudlib——Nightmare、Lima、Discworld 自己的那套——则又是各自独立的项目。 + +## 从外面看是什么样 + +在我们实测的那个 FluffOS 游戏上有 MSSP 和 **MCCP2**。在我们的调查中,MudOS 是仅有的两个既回应 +MSSP、又回应登录画面 `WHO` 的代码库之一,不过它给出的 `WHO` 是一份逐个玩家的清单,而不是一个计数。 + +因为 mudlib 就是游戏,任何一个具体的 LP 游戏协商什么,是 mudlib 的决定不亚于是驱动的决定——协议页 +面上的采用数字数的是服务器实际提供给我们的东西,对这个家族而言,那作为关于代码库的信号,要比在别 +处更弱。 diff --git a/content/reference/zh-Hans/codebase-moo.md b/content/reference/zh-Hans/codebase-moo.md new file mode 100644 index 0000000..8b1bd8c --- /dev/null +++ b/content/reference/zh-Hans/codebase-moo.md @@ -0,0 +1,26 @@ +--- +kind: codebase +slug: moo +title: MOO +summary: 面向对象,完全从内部编辑,与其说是游戏引擎,不如说同样是一个研究与教学平台。 +codebase: MOO +home: https://www.ipomoea.org/moo/ +see-also: mush-mud-muck-moo +see-also: codebases/muck +--- + +MOO——*MUD, Object-Oriented*——把“世界自己编辑自己”这个想法推得比这个爱好里的任何东西都远。最初的 +服务器 LambdaMOO 只带一个很小的 C 内核和一个数据库;用户体验到的几乎一切,都是**用 MOO 语言、在运 +行中的数据库里、由使用它的人写出来的**。一个房间没有对应的源文件。 + +这个性质让 MOO 拥有了游戏之外的生命。整个九十年代里,它们被用于教学、会议和研究——Diversity +University、BioMOO、Jay's House——而关于 MOO 的技术文献,对这个领域的一个代码库来说,学术味重得不 +成比例。 + +今天的部署量很小,但确确实实不是零,而留下来的那些服务器往往已经连续运行了几十年。 + +## 从外面看是什么样 + +没有 MSSP,在我们实测的那个游戏上也没有我们能解析的 `WHO`。它有的是连接画面里的一句话,写着*“one +of three players are active”*——这个爬虫里读拼写出来的数词的那一块,正是由此而来。一个只认数字的解 +析器在那里根本看不到任何计数,并且会把那个游戏永远报成未知。 diff --git a/content/reference/zh-Hans/codebase-muck.md b/content/reference/zh-Hans/codebase-muck.md new file mode 100644 index 0000000..04cbf40 --- /dev/null +++ b/content/reference/zh-Hans/codebase-muck.md @@ -0,0 +1,27 @@ +--- +kind: codebase +slug: muck +title: MUCK +summary: TinyMUD 的一支后裔,有自己的类 Forth 游戏内语言,社群文化也与 MUSH 那一边不同。 +codebase: MUCK +home: https://www.fuzzball.org/ +see-also: mush-mud-muck-moo +see-also: codebases/tinymush +see-also: codebases/moo +--- + +MUCK——实际上几乎总是指 **Fuzzball MUCK**——是 MUSH 一脉的兄弟,而不是它的后代:两者都源自 TinyMUD, +也都在游戏里放了一门编程语言。 + +语言就是那处看得见的差别。MUF(*Multi-User Forth*)是基于栈的,读起来和 MUSH softcode 毫无相似之 +处;精通其中一门的建造者,在另一门面前是新手。在它之上还有 MPI,一门更小的内联表达式语言,用来做 +在 MUSH 上会交给 softcode 去做的那些事。 + +从文化上说,MUCK 是这个爱好中社交与同人世界很大一部分的家园。那些游戏往往围绕在场与交谈来构建,而 +不是围绕有开始有结束的场景,这是与扮演 MUSH 传统之间一处实实在在的差别,而不是题材问题。 + +## 从外面看是什么样 + +没有 MSSP。登录前的 `WHO` 会给出一个计数。在我们实测的那个游戏上没有协商任何 telnet 选项——调查中 +还有一个细节值得记住:它的 `WHO` 回应以一个尾随空格结束,没有换行,而这正是那种会让一个天真的解析 +器什么也报不出来的东西。 diff --git a/content/reference/zh-Hans/codebase-pennmush.md b/content/reference/zh-Hans/codebase-pennmush.md new file mode 100644 index 0000000..dc3ccd2 --- /dev/null +++ b/content/reference/zh-Hans/codebase-pennmush.md @@ -0,0 +1,44 @@ +--- +kind: codebase +slug: pennmush +title: PennMUSH +summary: 部署最广的 MUSH 服务器。softcode、一段很长的发布史,以及在我们的调查中仅有的两个既回应 MSSP 又回应登录前 WHO 的代码库之一。 +codebase: PennMUSH +home: https://www.pennmush.org/ +see-also: codebases/tinymux +see-also: codebases/rhostmush +see-also: codebases/cobramush +see-also: mush-mud-muck-moo +see-also: protocols/mssp +--- + +PennMUSH 经由 1991 年的一次分支从 TinyMUSH 而来,也是长期运行的扮演 MUSH 最常用的服务器。它的决定 +性特征是 **softcode**:一门函数式表达式语言,由任何设置了相应标记位的人从游戏内部编辑,任何一个 +MUSH 的行为都有很大一部分是用它写的。与其说一个 PennMUSH 游戏是被配置出来的,不如说是被它的玩家编 +程出来的。 + +版本号形如 `1.8.8p0`——一个主版本、一个次版本和一个补丁级别——而补丁级别经常变动。游戏常常跑在落后 +好几个补丁级别的版本上,这并不稀奇。 + +## 从外面看是什么样 + +在我们自己那次涵盖 38 台服务器的调查中,PennMUSH 是仅有的两个把我们探测的*两条*路径都回应了的代码 +库之一。它在被问及时提供 MSSP,也会回应在登录画面上敲入的 `WHO`,而在我们实测的那个游戏上,两者是 +一致的——这比听上去要罕见,也让 PennMUSH 成了我们用来对照其他服务器的基准。 + +登录前的 `WHO` 的意义不止于方便:MUSH 家族能发布玩家人数,靠的就是它,因为这个家族的其余大部分根 +本不提供 MSSP。这一分裂正是本站要探测四层而不是一层的原因,见 +[MSSP](/reference/protocols/mssp)。 + +在现代 PennMUSH 上,CHARSET 协商是常态,这也是带重音符号的名字能平安走完全程的原因。 + +## 相关服务器 + +PennMUSH、**TinyMUX**、**RhostMUSH** 和 **CobraMUSH** 是四个有共同祖先、共享一套词汇的服务器——懂其 +中一个的建造者,费点力气也能读懂另一个的 softcode。它们并不兼容:数据库不做一次转换是搬不过去的, +函数库的差异也大到会带来实际影响。 + +## SharpMUSH + +有一个以 PennMUSH 兼容为目标的 .NET 重新实现正在开发中,作者与本站相同。本页没有任何内容是从它实测 +出来的,目录里也没有它的游戏。 diff --git a/content/reference/zh-Hans/codebase-rhostmush.md b/content/reference/zh-Hans/codebase-rhostmush.md new file mode 100644 index 0000000..225d2d8 --- /dev/null +++ b/content/reference/zh-Hans/codebase-rhostmush.md @@ -0,0 +1,25 @@ +--- +kind: codebase +slug: rhostmush +title: RhostMUSH +summary: 一个以精细的权限模型和庞大的内置函数集著称的 MUSH 服务器。没有 MSSP;会回应登录前的 WHO。 +codebase: RhostMUSH +home: https://github.com/RhostMUSH/trunk +see-also: codebases/pennmush +see-also: codebases/tinymux +see-also: codebases/cobramush +--- + +RhostMUSH 是四个广泛使用的 TinyMUSH 系服务器中的第四个,也是管理模型最为繁复的一个:它的权限与标记 +系统比它的亲戚们细致得多,游戏选择它通常就是为了这个。 + +它的内置函数库很大,为 Rhost 写的 softcode 往往无法干净地移植到 PennMUSH 或 TinyMUX,除非把用到了 +其他服务器所没有的函数的那些部分重写掉。 + +## 从外面看是什么样 + +没有 MSSP。登录前的 `WHO` 会给出一个计数。CHARSET 有协商。 + +这个组合——没有 MSSP,`WHO` 可用——正是 MUSH 家族的标志,也正是本站要去探测登录画面的原因。以我们自 +己那次调查的证据来看,MSSP 家族和 `WHO` 家族几乎互不相交:28 个代码库通过 MSSP 发布人数,7 个通过 +`WHO`,两者都发布的只有 2 个。 diff --git a/content/reference/zh-Hans/codebase-rom.md b/content/reference/zh-Hans/codebase-rom.md new file mode 100644 index 0000000..e78898a --- /dev/null +++ b/content/reference/zh-Hans/codebase-rom.md @@ -0,0 +1,27 @@ +--- +kind: codebase +slug: rom +title: ROM +summary: Merc 最有名的后代,也是九十年代很大一部分 MUD 所建立在其上的战斗引擎。 +codebase: ROM +see-also: codebases/dikumud +see-also: codebases/smaug +see-also: protocols/mccp +--- + +ROM——*Rivers of MUD*——是 **Merc** 的一个衍生版本,而 Merc 本身又是 DikuMUD 的衍生版本,ROM 则是站 +住了脚的那一个。它的战斗模型、它的技能与法术系统以及它的区域格式,是九十年代及其后极大量游戏的起 +点,尤其是 ROM 2.4,是这个爱好里被派生得最多的源码之一。 + +和 Diku 一脉的其余部分一样,它带着原始致谢的要求,所以一个你无法从别处确定其血统的游戏,往往会在 +它的登录画面上同时提到 Diku、Merc 和 ROM。 + +## 从外面看是什么样 + +在我们实测的那个游戏上有 MSSP、CHARSET 和 **MCCP2**。 + +ROM 是本项目用来坐实自己那个压缩 bug 的服务器。我们的探测协商了 MCCP2,服务器正确地开始压缩,而我 +们所依赖的 telnet 库始终没有解压这条流——于是连接画面到达时是一整片替换字符,我们一度把它记成了那 +个游戏的问题。用一个现成的 zlib 调用,那段负载能干净地解压出来,正是这一点让事情不再含糊。它已在 +上游修复;故事写在 [MCCP](/reference/protocols/mccp) 页上,因为它是一个从外面看起来和坏掉的游戏一 +模一样的缺陷的好例子。 diff --git a/content/reference/zh-Hans/codebase-smaug.md b/content/reference/zh-Hans/codebase-smaug.md new file mode 100644 index 0000000..b2462a6 --- /dev/null +++ b/content/reference/zh-Hans/codebase-smaug.md @@ -0,0 +1,24 @@ +--- +kind: codebase +slug: smaug +title: SMAUG +summary: 一个为大型主题世界打造的 Merc 衍生版本,有神祇、位面,以及规模数倍于其上游的区域集。 +codebase: SMAUG +home: https://www.smaug.org/ +see-also: codebases/dikumud +see-also: codebases/rom +--- + +SMAUG——*Simulated Medieval Adventure multi-User Game*——出自 Realms of Despair,是一个只做加法、不 +做简化的 Merc 衍生版本。ROM 把 Merc 的战斗收紧了,SMAUG 则往里加:神祇、位面、更繁复的区域程序和 +mob 程序、大得多的标准世界,以及相应地大得多的源码树。 + +运行它的游戏往往是那些大型主题游戏,其中有几个已经连续运行了二十五年。 + +## 从外面看是什么样 + +有 MSSP,索取即答,在我们实测的那个游戏上没有协商别的任何东西。 + +给要比较人数的人提个醒:我们调查中的 SMAUG 服务器是通过 MSSP 报数的,并不回应登录画面上的 `WHO`, +所以这里的数字是游戏自己的 MSSP `PLAYERS`——那是服务器做出的一个主张,我们采信它,仅仅是因为我们无 +法独立地实测它。游戏页面会标明任何一个数字究竟来自两者中的哪一个。 diff --git a/content/reference/zh-Hans/codebase-tbamud.md b/content/reference/zh-Hans/codebase-tbamud.md new file mode 100644 index 0000000..8c58ab6 --- /dev/null +++ b/content/reference/zh-Hans/codebase-tbamud.md @@ -0,0 +1,23 @@ +--- +kind: codebase +slug: tbamud +title: tbaMUD +summary: CircleMUD 仍在维护的延续,也是“我今天想开一个 Diku 风格的 MUD”这个问题的常规答案。 +codebase: tbaMUD +home: https://tbamud.com/ +see-also: codebases/circlemud +see-also: codebases/dikumud +--- + +tbaMUD——*The Builder Academy MUD*——在 CircleMUD 自身开发停下的地方把它接了过来,此后一直在建设、 +打补丁和写文档。如果有人要从一个已知可靠的基底、而不是从自己的一个分支出发去做一个练级加战斗的 +MUD,这就是那个基底。 + +它把 Circle 代码库的优点延续了下来:可读的 C、一份有文档的区域文件格式,以及一本假定你从没做过这 +件事的建造者手册。The Builder Academy 本身就作为一个教学游戏在运行,人们在上面学怎么建造。 + +## 从外面看是什么样 + +有 MSSP,索取即答。在我们探测的那个游戏上是一个实测到的零——在本站,这是一个填满的格子,而不是一个 +空格子:我们连上了,我们读到了数字,而那个数字是零。这是与一个我们无法计数的游戏不同的事实,两者 +在这里从不合并。 diff --git a/content/reference/zh-Hans/codebase-tinymush.md b/content/reference/zh-Hans/codebase-tinymush.md new file mode 100644 index 0000000..dbf10db --- /dev/null +++ b/content/reference/zh-Hans/codebase-tinymush.md @@ -0,0 +1,37 @@ +--- +kind: codebase +slug: tinymush +title: TinyMUSH +summary: MUSH 一脉的祖先,至今仍有游戏在跑。它让这个爬虫明白了:自己发出的协商字节,会毁掉它接下来发送的那条命令。 +codebase: TinyMUSH +home: https://github.com/TinyMUSH/TinyMUSH +see-also: codebases/pennmush +see-also: codebases/tinymux +see-also: mush-mud-muck-moo +--- + +PennMUSH、TinyMUX、RhostMUSH 和 CobraMUSH 这一脉全都出自 TinyMUSH,而它至今仍有部署。它的开发是安 +静,而不是不存在。 + +## 从外面看是什么样 + +没有 MSSP。登录前的 `WHO` 会用一句这种形式的话作答: +`0 Players logged in, 22 record, no maximum.` + +## 它在我们身上找出的那个 bug + +TinyMUSH 值得在这里占一段,因为正是这个游戏暴露了本站自己爬虫里的一个缺陷,而这次订正很好地说明了 +“实测”该是什么意思。 + +我们的探测有好几周把 TinyMUSH 读成了*计数未知*。当时归档的猜测是它的回应没有尾随换行。它有。从线 +路上抓下来看,真正的原因在我们这边:**TinyMUSH 在它的登录画面上不解析 telnet**,所以我们连接时发出 +的 `IAC DO MSSP` 那三个字节,落进它的输入缓冲区,就像有人把它们敲了进去一样。它读到的下一行不是 +`WHO`,而是三个控制字节后面跟着 `WHO`,那不是它认识的命令——于是它重新显示自己的连接画面,对玩家人 +数只字不提。 + +现在探测会在协商之后发一个光秃秃的换行,并把它引出的任何东西丢弃,因为那份输出是对*我们*选择发送 +的字节的反应,因此既不是游戏的连接画面,也不是它的回答。TinyMUSH 现在读得正确了,探测耗时也只有原 +来的三分之一。 + +一个没有去查的目录,会在这个游戏存在的全部时间里发布“此游戏不报告其玩家人数”,而那句话说的其实是 +我们。 diff --git a/content/reference/zh-Hans/codebase-tinymux.md b/content/reference/zh-Hans/codebase-tinymux.md new file mode 100644 index 0000000..a6945ef --- /dev/null +++ b/content/reference/zh-Hans/codebase-tinymux.md @@ -0,0 +1,33 @@ +--- +kind: codebase +slug: tinymux +title: TinyMUX +summary: 另一个大牌 MUSH 服务器。softcode 与 PennMUSH 的接近到足以让人争论不休,完全没有 MSSP,而登录前的 WHO 是好用的。 +codebase: TinyMUX +home: https://www.tinymux.org/ +see-also: codebases/pennmush +see-also: codebases/tinymush +see-also: codebases/rhostmush +see-also: mush-mud-muck-moo +--- + +TinyMUX 是老牌扮演 MUSH 最常用的两个服务器中的第二个,而对许多玩家来说,在它和 PennMUSH 之间怎么选 +只取决于自家游戏的管理人员先学会了哪一个。版本号形如 `2.12` 之类。 + +和 PennMUSH 一样,它也出自 TinyMUSH,它的 softcode 接近到让一个在两者之间迁移的建造者是在做翻译, +而不是重新学。差异是实实在在的——函数库、若干解析上的边角、`@` 命令集——而且恰恰是那种会让在两者之 +间搬数据库变成一个项目、而不是一次导出的东西。 + +## 从外面看是什么样 + +**没有 MSSP。**TinyMUX 根本不提供这个选项,这把它和 AresMUSH、MUCK、RhostMUSH、CobraMUSH 以及 +TinyMUSH 一起归到了这个爱好中一个只认 MSSP 的目录压根看不见的那一边。它的玩家人数来自登录画面上的 +`WHO`,它会用一个朴素的计数作答。 + +它确实会协商 CHARSET,这也是它在非 ASCII 文本上胜过大多数亲戚的原因。 + +## 这些人数从哪里来 + +如果你要拿本站给某个 TinyMUX 游戏的数字去和另一个目录的比,请注意我们读的是登录画面上的 `WHO`,而 +大多数爬虫不读。一个只建立在 MSSP 之上的目录,会把这些游戏报成完全没有人数,或者干脆不把它们列出 +来。 diff --git a/content/reference/zh-Hans/orientation-collaborative-roleplay.md b/content/reference/zh-Hans/orientation-collaborative-roleplay.md new file mode 100644 index 0000000..bfad61e --- /dev/null +++ b/content/reference/zh-Hans/orientation-collaborative-roleplay.md @@ -0,0 +1,56 @@ +--- +kind: orientation +slug: collaborative-roleplay +title: 你想找的是协作扮演 +summary: 如果你想要的是和别人一起写场景,这里是一条捷径——该找哪些代码库、哪种客户端不会跟你作对,以及第一晚会是什么光景。 +see-also: mush-mud-muck-moo +see-also: connecting +see-also: codebases/pennmush +see-also: codebases/aresmush +see-also: codebases/evennia +see-also: clients/mudlet +--- + +我们说的*协作扮演*是指:你和另外一些人一起写一场戏,以角色的身份,用散文,而且基本上是实时的。 +除非游戏要求,否则没有人掷先攻。这是被“MUD”这个词遮住的那一块,而且它活得好好的。 + +## 去找这些代码库 + +这种文化生活在 TinyMUD 这一脉里,因为它的服务器是为造东西的人、而不是为杀东西的人造的。 + +- **[PennMUSH](/reference/codebases/pennmush)** 和 **[TinyMUX](/reference/codebases/tinymux)**—— + 两大 MUSH 服务器。你听说过的那些长期运营的扮演游戏,大多跑的是其中之一。 +- **[AresMUSH](/reference/codebases/aresmush)**——现代的,专为扮演而造, + 网页前端和场景记录是内建的一等功能,而不是某个人写的 softcode。 +- **[Evennia](/reference/codebases/evennia)**——一个 Python 框架,而不是一个做好的游戏。 + 建在它上面的游戏差别极大;较新的扮演游戏里有好几个是 Evennia。 +- **[MUCK](/reference/codebases/muck)**——自成一套文化,社交色彩很重; + 如果你要找的是同人圈那类世界,它值得了解。 + +你可以按其中任何一项来筛选目录:每个代码库页面都链接到我们实测出运行着它的那些游戏。 + +## 一个不会跟你作对的客户端 + +你并不非有一个不可——这里的每个游戏都说普通的 telnet,而你的操作系统里多半已经带了一个客户端。 +但对写戏来说,有三件事比在别处更要紧: + +1. **一条撑得住长段落的命令行。** 你会敲下好几句话的 pose(动作描述)。 +2. **日志记录。** 事后你会想要那场戏的记录。 +3. **UTF-8。** 名字里会有重音符号。 + +请看[客户端](/reference)一节。如果你使用屏幕阅读器,那里的能力对照表专门有一行; +我们没能对每个客户端都确定出答案,而没能确定的地方,表里写的是*未知*,而不是去猜。 + +## 第一晚实际上是什么光景 + +大多数扮演游戏都有一套**申请流程**:你以访客身份连上去,读一些帮助文件, +然后写一份角色设定交给管理组看。这可能花掉一天,也可能花掉两周。这不是为设门槛而设门槛—— +游戏正是靠它来维持一个连贯的共享设定——但这确实意味着,“登录就玩”很少是它的样子。 + +你去看的那一刻一个人都没有,在这里很正常;每个游戏页面上的活跃度网格比它的实时人数更有用: +一个每天傍晚有十五个人、凌晨四点一个都没有的游戏,是一个在错的时刻被看到的健康游戏。 + +## 本站无法告诉你的事 + +那里的人好不好。管理组公不公道。那个设定是不是你会喜欢的。我们实测的是服务器。 +这里没有评分,将来也不会有——那是一个刻意的决定,不是一处空缺。 diff --git a/content/reference/zh-Hans/orientation-connecting.md b/content/reference/zh-Hans/orientation-connecting.md new file mode 100644 index 0000000..50148ae --- /dev/null +++ b/content/reference/zh-Hans/orientation-connecting.md @@ -0,0 +1,42 @@ +--- +kind: orientation +slug: connecting +title: 如何连接 +summary: 一个主机、一个端口,加上 telnet。游戏页面上那个地址是什么意思,以及拿它来做什么。 +see-also: mush-mud-muck-moo +see-also: protocols/tls +see-also: protocols/charset +--- + +这里列出的每个游戏都在一个**主机加一个端口**上应答,底下的协议是 telnet—— +实际上就是一条裸的 TCP 连接,外加其上少量可选的协商。 + + telnet mush.pennmush.org 4201 + +这管用,而且在很多系统上它已经装好了。它同时也是一种很差的玩法:系统自带的 `telnet` +没有像样的本地回显控制,没有日志,没有历史记录,而且会把 ASCII 以外的一切弄乱。 +它是用来确认一个游戏是否还应答的合适工具,却不是用来消磨一个晚上的那个。 + +## 游戏页面上的地址告诉你什么 + +每个游戏页面都列出我们实测到的端点,并标出其中观测到 **TLS** 的那些。有 TLS 端口的游戏, +你可以加密连上去;这个端口号通常和明文的那个不一样。 + +一个游戏有好几个端口时,它们往往是通往同一个世界的不同路径,而不是不同的游戏。 +我们列出我们实测到的内容,不去猜哪一个才是正式的那个。 + +## 挑一个客户端 + +[客户端](/reference)一节里每个客户端都有一页,带一张能力对照表。装任何东西之前值得先看的三件事: + +- **它支持 UTF-8 吗?** 如果这个游戏不是只用英文,第一个晚上你就会碰上这个问题。 +- **它支持 TLS 吗?** 只有在游戏提供 TLS 时才有意义,不过现在已经有好几个提供了。 +- **如果你使用屏幕阅读器,这个项目有没有写明它支持屏幕阅读器?** + 这是各家客户端对照里最常缺席的一行,所以在我们这里它是第一行—— + 而在没有人确定出答案的地方,它写的是*未知*。 + +## 如果什么都没应答 + +一个不应答的游戏未必就没了。游戏会换主机,DNS 会失效,防火墙也有自己的主张。 +本站保留它实测过的每一个游戏——包括那些好几年前就不再应答的——并且每周继续叩门, +所以在下任何结论之前,该去看的地方是[归档](/archive)。 diff --git a/content/reference/zh-Hans/orientation-families.md b/content/reference/zh-Hans/orientation-families.md new file mode 100644 index 0000000..bd1bd27 --- /dev/null +++ b/content/reference/zh-Hans/orientation-families.md @@ -0,0 +1,74 @@ +--- +kind: orientation +slug: mush-mud-muck-moo +title: MUSH、MUD、MUCK、MOO——这些词是什么意思 +summary: 四个词对应四种传统,没有一个是题材类型。它们真正告诉你的是什么。 +see-also: collaborative-roleplay +see-also: connecting +see-also: codebases/pennmush +see-also: codebases/aresmush +see-also: codebases/muck +see-also: codebases/moo +see-also: codebases/evennia +--- + +这几个词,每一个命名的都是**一族服务器软件**,而不是一类游戏。这是关于它们最有用的一条认识, +也正是“这是 MUSH 还是 MUD?”这个问题为什么老被答得很糟: +老实的答案通常是*两者都是,而你想问的其实是文化*。 + +## MUD + +最老的一个词,如今也是最宽的一个。它起初是 *Multi-User Dungeon*——Bartle 和 Trubshaw +1978 年的那个游戏——到九十年代中期,它已经成了所有文字多人世界的统称。 + +狭义地用,它指的是 **DikuMUD 和 LPMud 这两脉**:围绕等级、战斗、装备, +以及一个由建造者事先写好、描述各个房间的区域文件搭起来的服务器。 +如果有人说“我玩 MUD”并且是有所指的,通常指的就是这个。 + +列表可以把每一脉单独显示给你看:[DikuMUD 的游戏](/games?lineage=DikuMUD)和 +[LPMud 的游戏](/games?lineage=LPMud)。 + +## MUSH + +*Multi-User Shared Hallucination*,出自 TinyMUD 一脉。它的定义性特征不是题材, +而是 **softcode(软代码)**:MUSH 服务器自带一门玩家在游戏内部使用的编程语言, +于是有建造权限的玩家可以创建房间、物件和行为,既不用碰源文件,也不用重启任何东西。 + +正是这一个设计决定造就了那套文化。MUSH 往往自动化系统很稀薄,而人力系统很密集—— +管理组主持的剧情、写出来的场景、申请流程——因为玩的人同时也是造的人。 + +PennMUSH、TinyMUSH、TinyMUX、RhostMUSH、CobraMUSH 和 AresMUSH 都属于这一脉, +而它们没有一个这么说:MSSP 里没有 `MUSH` 这个取值可供公布, +而除 PennMUSH 之外的全都根本不发布 MSSP。所以把它们归到一起是我们做的事, +而不是我们读到的事,这就是[MUSH 的游戏](/games?lineage=MUSH)无论出现在哪里都标着*推算*的原因。 + +## MUCK + +和 MUSH 一样是 TinyMUD 的后裔,有自己的 softcode(MUF,一门类 Forth 的语言), +以及一条深厚的社交世界与福瑞同人世界的传统。技术上与 MUSH 很接近; +文化上则区别到,两边都玩的人不会把它们说成是一回事——[MUCK 的游戏](/games?lineage=MUCK)。 + +## MOO + +*MUD, Object-Oriented*。“游戏自己编辑自己”这个想法最纯粹的表达:MOO 里几乎所有东西, +都是使用它的人在里面用 MOO 编程语言写出来的。LambdaMOO 是它的祖先, +而 MOO 历来在教育和研究领域和在游戏领域一样受欢迎。[MOO 的游戏](/games?lineage=MOO)。 + +## 那么,你实际上该问什么? + +有三个问题,比那个四字母的词管用得多: + +1. **有没有战斗,战斗是不是自动化的?** 这一条把 Diku/LP 一脉和 TinyMUD 一脉分开, + 比任何名字都可靠。 +2. **谁来建造?** 只有管理组,还是任何拿到建造位的人? +3. **玩法是约时间的还是随时随地的?** 是约好时间的场景和 pose 出来的扮演,还是登录就走? + +本站的列表能替你回答第一个问题的一部分:我们为一个游戏实测出的**代码库**, +会告诉你它的服务器出自哪一种传统,而**谱系**这个筛选项就是把那个答案变得可筛选。 +它没法告诉你文化,本页也不会假装它能。 + +关于这个筛选项还有一句提醒,因为总有人是在本页上第一次遇到它。代码库是实测的,谱系不是: +它是*我们*对一个游戏告诉我们的内容所作的归类,挂在它自己的标签下——**推算**—— +与*实测*和*自述*并列。凡是代码库没有一个无争议的父系的, +我们宁可把它排除在所有谱系之外,也不把它归到最接近的那一个下面; +而这样的游戏里有好几个用它们自己的话表示了同意,公布的是 `FAMILY Custom`。 diff --git a/content/reference/zh-Hans/protocol-atcp.md b/content/reference/zh-Hans/protocol-atcp.md new file mode 100644 index 0000000..42a9ab5 --- /dev/null +++ b/content/reference/zh-Hans/protocol-atcp.md @@ -0,0 +1,27 @@ +--- +kind: protocol +slug: atcp +title: ATCP +summary: GMCP 的前身。带外数据,载荷格式更松散,如今大体已被取代,仍有一直没把它删掉的服务器在继续协商它。 +protocol: ATCP +see-also: protocols/gmcp +see-also: protocols/msdp +see-also: clients/mudlet +--- + +ATCP——Achaea Telnet Client Protocol——是 telnet 选项 200,在 MUD 文本之外另发一份结构化数据的想法, +最早就是在这里被大规模用起来的。服务器发出一个模块名和一份载荷,客户端把它分发出去。 + +它的载荷格式比 [GMCP](/reference/protocols/gmcp) 的 JSON 松散,这基本上就是 GMCP 取代它的原因。 +如今支持 ATCP 的客户端,一般都把它标注为已废弃,并让你改用 GMCP。 + +## 它为什么还在 + +因为把它开着并不会弄坏什么。一台 2008 年实现了 ATCP、2014 年又加上 GMCP 的服务器,通常两个都还在协商; +而两个都支持的客户端,对方给哪个就用哪个。 + +对一个新的实现来说,没有理由选它。 + +## 我们实测的是什么 + +在我们观测到的握手中提供了 telnet 选项 200 的服务器。这里的数字低是意料之中的,它说的是年代,别的什么也不说。 diff --git a/content/reference/zh-Hans/protocol-charset.md b/content/reference/zh-Hans/protocol-charset.md new file mode 100644 index 0000000..757a705 --- /dev/null +++ b/content/reference/zh-Hans/protocol-charset.md @@ -0,0 +1,35 @@ +--- +kind: protocol +slug: charset +title: CHARSET +summary: RFC 2066 里用来商定编码的 telnet 选项。一个游戏中带重音符号的名字能完好走完全程,靠的就是它;而它缺席时,一些细微的故障也由此而来。 +protocol: CHARSET +home: https://www.rfc-editor.org/rfc/rfc2066 +see-also: protocols/ttype +see-also: connecting +see-also: codebases/tinymux +--- + +CHARSET 是 telnet 选项 42,由 RFC 2066 规定。一方给出一份字符集清单,另一方从中挑一个, +双方随后就字节如何映射到字符达成一致。 + +实际当中,这场协商要么落在 **UTF-8** 上,要么根本就没有发生。MUSH 家族协商它的比例明显高于 MUD 家族 +——TinyMUX、RhostMUSH 和 PennMUSH 都协商——这反映的是一群写散文、而且文中带名字的人。 + +## 没有它会怎样 + +客户端只能猜,而通常猜的不是 ASCII 就是 Latin-1。猜 ASCII,0x7F 以上的每个字节都会变成问号; +在 UTF-8 服务器上猜 Latin-1,每个带重音的字符都会变成两个标点。这两种故障看上去都像是游戏的错,其实都不是。 + +对爬虫来说,这件事会在一个很具体的地方咬人。我们自己的 telnet 库把当前编码默认设成 ASCII, +而这个默认值并不是摆设——对每一台从不协商 CHARSET 的服务器(也就是它们中的绝大多数), +每一个字节都是用它来解码的。正因如此,我们才特意给它预置了一个值。 + +## CHARSET 唯一够不到的地方 + +不管 CHARSET 最后谈成了什么,MSSP 的字段名和字段值都按 ASCII 解码,因为子协商是命令而不是文本, +而规范把 CHARSET 的适用范围限定在文本上。这大概算是合规的,同时也是有损的:一个 MSSP `NAME` 为 +`Café Noir` 的游戏,报出来是 `Caf? Noir`,而原始字节在我们能控制的任何环节看到它之前就已经没了。 + +如果你在本站某个自述字段里看到一个乱掉的字符,而游戏自己的输出里没有,原因就在这里, +并且这在我们这一侧无法还原。 diff --git a/content/reference/zh-Hans/protocol-gmcp.md b/content/reference/zh-Hans/protocol-gmcp.md new file mode 100644 index 0000000..02409a4 --- /dev/null +++ b/content/reference/zh-Hans/protocol-gmcp.md @@ -0,0 +1,38 @@ +--- +kind: protocol +slug: gmcp +title: GMCP +summary: Generic Mud Communication Protocol——与文本并行的结构化 JSON 消息,也是当下多数客户端所面向的带外通道。 +protocol: GMCP +home: https://www.mudhalla.net/tintin/protocols/gmcp/ +see-also: protocols/msdp +see-also: protocols/atcp +see-also: clients/mudlet +--- + +GMCP 是 telnet 选项 201。一旦协商成功,服务器就可以**带外发送结构化数据**: +一个包名加一份 JSON 载荷,与文本走在同一条流里,却不是文本的一部分。 + +`Char.Vitals { "hp": 412, "maxhp": 500 }` 是最典型的例子。客户端可以据此驱动一条血条, +而不必从字里行间去抠数字,这正是它的全部意义所在——靠模式匹配文本搭起来的状态显示, +在游戏改动提示符的那一天就会坏掉,建在 GMCP 上的则不会。 + +包的命名空间是约定俗成的,而不是标准化的。`Char`、`Room`、`Comm` 和 `Client` 用得很广; +再往外,各游戏需要什么就自己发明什么,而客户端一般得有人告诉它某个游戏会发些什么。 + +## 它为什么取代了 ATCP + +GMCP 是 [ATCP](/reference/protocols/atcp) 的后继者,后者干的是同一件事,只是载荷格式更松散。 +JSON 就是那个改进,而这场迁移到 2010 年代中期基本已经完成。一个游戏两个都支持并不稀奇; +一个新游戏只支持 ATCP 才叫稀奇。 + +## 我们实测的是什么 + +一个游戏被算进这里,条件是**它的服务器在我们观测到的一次握手中提供了 GMCP**。这与某个游戏的 MSSP 里写着 +`GMCP 1` 是两种不同的说法——而这个圈子里大多数协议表恰恰是建立在后者之上的——两者经常对不上。 + +有一条来自我们自身历史的实测说明:有那么一段时间,凡是同时协商了 +[MCCP](/reference/protocols/mccp) 的服务器,我们都看不到它们的 GMCP, +因为我们的 telnet 库协商了压缩却从不解压,压缩标记之后的一切对我们来说都是噪声。 +我们调查过的服务器里,至少有一台其实一直都在说 GMCP。如果本页上的某个数字, +对一个你很熟悉的家族来说低得不对劲,第一个该怀疑的就是这类缺陷——怀疑我们,而不是怀疑他们。 diff --git a/content/reference/zh-Hans/protocol-mccp.md b/content/reference/zh-Hans/protocol-mccp.md new file mode 100644 index 0000000..8951cd0 --- /dev/null +++ b/content/reference/zh-Hans/protocol-mccp.md @@ -0,0 +1,36 @@ +--- +kind: protocol +slug: mccp +title: MCCP +summary: 流压缩。开销小、部署广,也是本项目历史上最有教益的那个 bug 的来源。 +protocol: MCCP +home: https://www.mudhalla.net/tintin/protocols/mccp/ +see-also: codebases/rom +see-also: codebases/dikumud +see-also: protocols/gmcp +--- + +MCCP 用 zlib 压缩服务器到客户端的数据流。版本 1 是 telnet 选项 85,实际上只剩历史意义; +**版本 2** 是选项 86,也是现代服务器会协商的那一个。服务器发出 `IAC SB MCCP2 IAC SE` 之后, +随后的每一个字节都属于同一条连续的 zlib 流。 + +在一个文本协议上,这是实打实的节省——MUD 的输出压缩率极高——而且它在 Diku 和 LP 家族里很常见: +我们调查过的代码库中,大约三分之一会协商它。 + +## 它的故障模式,以及这在本站为什么要紧 + +一个协商了 MCCP2 却不去解压数据流的客户端,会**从压缩标记开始收到一片二进制乱码**。不是报错,也不是断线: +连接画面变成一整墙替换字符,而在它之后的一切——`WHO` 的回复、后面的任何 MSSP、整场会话——统统丢失。 + +这不是假想。我们自己的 telnet 库干的就是这件事。它协商了这个选项,触发了自己的“压缩已启用”回调, +却一个字节都没解压过。那份载荷用一个现成的 zlib 调用就干干净净地解开了,正是这一点让事情毫无疑义: +对的是服务器,错的是我们。我们调查的三十八个代码库里有十三个受到影响, +而在这段时间内,我们无法观测这些服务器在压缩开始*之后*协商了什么—— +所以我们对它们能力的记录是偏低的。 + +这个问题已在上游修复。还有一个后续缺陷——解压器是每次读取都重建,而不是随连接一直保留, +于是在一张很大的连接画面读到一半时失败——已经提交且仍未关闭,它影响的是最大那些画面的尾部。 + +读者应该从这里带走两件事。**本页上的协议数字,既是对这个圈子的实测,同样也是对我们爬虫的实测**, +凡是我们知道它错过的地方,我们都会说出来。另外,如果你在写客户端:协商 MCCP 很容易, +而正确地解压才是工作量真正所在的地方。 diff --git a/content/reference/zh-Hans/protocol-msdp.md b/content/reference/zh-Hans/protocol-msdp.md new file mode 100644 index 0000000..6bd8a91 --- /dev/null +++ b/content/reference/zh-Hans/protocol-msdp.md @@ -0,0 +1,26 @@ +--- +kind: protocol +slug: msdp +title: MSDP +summary: Mud Server Data Protocol——和 GMCP 干同一件事,用的是一种紧凑的二进制编码,外加一套 GMCP 所没有的发现机制。 +protocol: MSDP +home: https://www.mudhalla.net/tintin/protocols/msdp/ +see-also: protocols/gmcp +see-also: clients/tintin +see-also: clients/blightmud +--- + +MSDP 是 telnet 选项 69,它解决的问题与 [GMCP](/reference/protocols/gmcp) 相同: +在文本之外发送结构化数据,好让客户端不必从字里行间去抠数字。 + +差别有两处。MSDP 的编码是**二进制且紧凑的**——变量和取值用单个控制字节来标记,而不是包进 JSON—— +并且 MSDP 定义了一套**发现**用的对话:客户端可以用 `LIST` 索取 `COMMANDS`、`REPORTABLE_VARIABLES` +等等,从而被告知某个游戏支持些什么。GMCP 没有对等的东西,这就是 GMCP 客户端一般得逐个游戏去配置的原因。 + +实际上,GMCP 在采用率上赢了,而 MSDP 在那些实现过它的服务器和客户端里留存下来,常常与 GMCP 并存。 + +## 我们实测的是什么 + +一个游戏被算进这里,条件是它的服务器在我们观测到的一次握手中提供了 MSDP。和本节里的每一个数字一样, +那是一次正面的观测,而剩下的部分并不是它的反面——一个没被算进来的游戏,可能没有实现 MSDP, +也可能只是它的握手还没被我们读到过。 diff --git a/content/reference/zh-Hans/protocol-msp.md b/content/reference/zh-Hans/protocol-msp.md new file mode 100644 index 0000000..7cd4398 --- /dev/null +++ b/content/reference/zh-Hans/protocol-msp.md @@ -0,0 +1,34 @@ +--- +kind: protocol +slug: msp +title: MSP +summary: MUD Sound Protocol——服务器点名一个声音文件,客户端把它放出来。古老、简单,也很容易与另外两样东西弄混。 +protocol: MSP +home: https://www.zuggsoft.com/zmud/msp.htm +see-also: protocols/mxp +see-also: clients/vipmud +--- + +MSP 让服务器可以请求客户端播放一段声音:一条用方括号括起来的指令,点明文件、音量、重复次数, +以及在客户端手上没有这个文件时用来取回它的 URL。它在 telnet 选项 90 上协商, +而那些从不协商任何东西的服务器,也可以把它带内塞在文本流里发出来。 + +它确实古老,也确实仍在使用——文字游戏里的环境音,效果比听起来要大; +而对于靠客户端的音频提示而不是靠画面来玩的玩家,它不只是装饰。 + +## 它不是的三样东西 + +本节里的客户端对照表在这里必须格外小心,值得把原因写下来: + +- **MCMP**——Mud Client Media Protocol——是另一个协议,干着类似的活。至少有一个客户端实现了 MCMP + 而没有实现 MSP,把其中一个读成另一个,就会在表里放进一句谁也没说过的话。 +- **客户端自己的“播放一段声音”脚本调用**不是 MSP。它是在脚本这么说的时候播放一个本地文件; + MSP 则是服务器告诉客户端该放什么。 +- **以捆绑插件形式提供的支持,值得照实写明。** 有一个客户端的 MSP 支持是以插件形式随附的, + 而该插件明确不做任何 telnet 协商,因此它在带内发送 MSP 的服务器上可用, + 在期待先协商的服务器上则不可用。 + +## 我们实测的是什么 + +提供了 telnet 选项 90 的服务器。由于 MSP 经常不经协商就带内发出,这个数字低估了实际的部署情况, +低估的幅度我们无法估计——这是一次握手所能看到的范围的局限,而不是关于这个协议的结论。 diff --git a/content/reference/zh-Hans/protocol-mssp.md b/content/reference/zh-Hans/protocol-mssp.md new file mode 100644 index 0000000..4d2f52e --- /dev/null +++ b/content/reference/zh-Hans/protocol-mssp.md @@ -0,0 +1,52 @@ +--- +kind: protocol +slug: mssp +title: MSSP +summary: Mud Server Status Protocol——一个游戏如何向爬虫讲述自己。它报告的一切都是自述而非实测,本站把这两者分得清清楚楚。 +protocol: MSSP +home: https://www.mudhalla.net/tintin/protocols/mssp/ +see-also: protocols/gmcp +see-also: codebases/dikumud +see-also: codebases/pennmush +--- + +MSSP 是 telnet 选项 70。爬虫发送 `IAC DO MSSP`,支持它的服务器就回一张描述自己的名值对表格—— +名称、在线人数、代码库、运行时长、主机名、端口、题材,以及它愿意公布的其他任何东西。 + +它是这个圈子里最接近“机器可读的目录条目”的东西,也是好几个目录站点得以存在的原因。 + +## MSSP 报告里的每一项都是一句断言 + +本站与所有既有站点的分歧就在这一点上。一份 MSSP 报告是游戏在*告诉你*它自己的情况。 +MSSP 表里的 `GMCP 1` 意味着有人在某个配置文件里敲了个 `1`,也许是在 2011 年。 +它不是服务器提供 GMCP 的证据,而两者对不上的次数,多到足以让人感兴趣。 + +所以来自 MSSP 的事实在这里标为**自述**;凡是同一项事实我们也能实测的——比如一项能力, +去看这个选项是否真的被协商了——两者都会并排显示,各自带着一个时间。一个游戏的 MSSP +自述支持 GMCP 已有六年,却从未在任何一次握手中提供过它,这是个值得知道的事实,而且别处找不到。 + +有一个字段我们刻意完全不予采信:`CREATED`。它只是一行手敲的文字, +把它计入任何东西,都会让那样东西轻易地被人做手脚。 + +## 谁会应答它 + +MSSP 是 **Diku 和 LP** 那边的答案。在我们自己那份涵盖 38 个代码库的调查中, +有 28 个通过 MSSP 公布在线人数,7 个通过登录画面上的 `WHO` 公布,两样都做的只有 2 个—— +这两个家族几乎完全不相交。AresMUSH、TinyMUX、MUCK、RhostMUSH、CobraMUSH 和 TinyMUSH +完全不提供 MSSP。 + +这就是要探测四个层次而不是一个层次的实证理由:**只建立在 MSSP 之上的爬虫, +看不见 MUSH 家族的大部分**,而那是这个圈子里很大的一块,也是本站预期读者中的大多数。 + +## 主动索取,不要干等 + +有相当多完全支持 MSSP 的服务器从不主动给出它——它们会应答 `IAC DO MSSP`,除此之外一言不发。 +于是,一个上来先发 `IAC WILL NAWS` 然后等着的爬虫,会把这些游戏报成什么都没公布, +而这是拿爬虫自己的沉默去下一句关于服务器的断言。我们在连接时就发送 `IAC DO MSSP`。 + +## 纯文本形式 + +还有一种更老的变体:客户端在登录画面上直接发送 `MSSP-REQUEST` 这一行字面文本。我们实测过: +在试过的二十个游戏里,有三个作了应答——而这三个同样也应答 telnet 选项 70, +所以它没能触及任何选项 70 触及不到的东西。有八台服务器把这个请求读成了一个**角色名**并如此回复, +白白花掉了一个陌生人被允许的登录尝试次数中的一次。我们不发送它。 diff --git a/content/reference/zh-Hans/protocol-mxp.md b/content/reference/zh-Hans/protocol-mxp.md new file mode 100644 index 0000000..b4203ee --- /dev/null +++ b/content/reference/zh-Hans/protocol-mxp.md @@ -0,0 +1,36 @@ +--- +kind: protocol +slug: mxp +title: MXP +summary: MUD eXtension Protocol——嵌在文本流里的类 HTML 标记,带来可点击的链接、图片和表单。规范写得详尽,实现却参差不齐。 +protocol: MXP +home: https://www.zuggsoft.com/zmud/mxp.htm +see-also: protocols/pueblo +see-also: clients/mushclient +see-also: clients/mudlet +--- + +MXP 在服务器发出的文本里嵌入了一门小巧的、类似 HTML 的标记语言:`` 表示一条可点击的命令, +`` 表示一个链接,另有颜色和字体元素,以及一套让服务器自定义标签的机制。 +它在 telnet 选项 91 上协商。 + +它的设计难题是固有的,也很有意思:标记与文本走在同一条流里,所以服务器必须小心那些*看起来*像标记的文本, +而客户端必须小心自己会渲染什么。MXP 定义安全级别正是为了这个原因—— +夹在另一个玩家的一行聊天里送来的标签,和服务器自己发出的标签不是一回事。 + +## 人们想要它,是为了可点击 + +MXP 实际用途的大部分,就是把 `north` 和物品名变成可以点的东西。对新玩家来说这个差别相当大, +也正因如此,这个协议尽管复杂,却还是不断有人实现。 + +## Pueblo 是另外那一个 + +[Pueblo](/reference/protocols/pueblo) 比 MXP 更早,用一种不同的、字面上更像 HTML 的做法干着类似的活。 +支持其中一个的客户端往往并不支持另一个,而在读功能清单时这两者很容易混淆—— +本节里的客户端对照表就得小心提防这个错误。 + +## 我们实测的是什么 + +在我们观测到的握手中提供了 telnet 选项 91 的服务器。MXP 被协商的频率低于那些带外协议, +一部分原因是它的价值有很大一块,是由那些干脆不协商、直接把标记发出去碰运气的服务器实现的—— +而那些我们看不见。 diff --git a/content/reference/zh-Hans/protocol-pueblo.md b/content/reference/zh-Hans/protocol-pueblo.md new file mode 100644 index 0000000..f4bbaff --- /dev/null +++ b/content/reference/zh-Hans/protocol-pueblo.md @@ -0,0 +1,28 @@ +--- +kind: protocol +slug: pueblo +title: Pueblo +summary: 更早的那套“在 MUD 里用 HTML”的方案,出自同名的客户端。MUSH 那一侧的客户端仍在支持它,它也经常被人与 MXP 弄混。 +protocol: PUEBLO +home: https://pueblo.sourceforge.net/ +see-also: protocols/mxp +see-also: clients/beipmu +--- + +Pueblo 出自九十年代中期同名的那个客户端,它增强 MUD 文本的路子很直接:让服务器发 **HTML**, +让客户端把它渲染出来。服务器在连接时用一行文字宣告自己支持 Pueblo,客户端作出回应, +从此这条流里就可以携带标记了。 + +它传到这个圈子里 MUSH 那一侧的程度大于 MUD 那一侧,而支持它的 MUSH 服务器一般至今仍在支持。 + +## 它不是 MXP + +[MXP](/reference/protocols/mxp) 是更晚的那套方案,也是实现得更广的那套。两者干的活类似, +彼此并不兼容,而把某个客户端的 Pueblo 支持读成 MXP 支持——或者反过来—— +是编制客户端对照时最容易犯的一个错。本节的客户端页面正因如此把它们分开列; +当一个项目只记载了其中一个而没有记载另一个时,另一个就写*未知*。 + +## 我们实测的是什么 + +Pueblo 的握手不是通常意义上的 telnet 选项,所以我们能观测到的范围比那些协商式协议要窄; +这里的数字低,应当读作一句关于我们可见范围的话,而不是关于部署情况的话。 diff --git a/content/reference/zh-Hans/protocol-tls.md b/content/reference/zh-Hans/protocol-tls.md new file mode 100644 index 0000000..8f56862 --- /dev/null +++ b/content/reference/zh-Hans/protocol-tls.md @@ -0,0 +1,35 @@ +--- +kind: protocol +slug: tls +title: TLS +summary: 加密的连接。通常是另开一个端口,而不是协商式的带内升级;它也是本站唯一一项靠亲自连上去、而不是靠开口询问来核实的能力。 +protocol: TLS +see-also: connecting +see-also: protocols/charset +see-also: clients/potato +--- + +telnet 是明文的。你发给一个 MU\* 的一切——包括你的密码——都以路径上任何东西都读得懂的形式穿过网络, +除非这个游戏提供 TLS。 + +在这个圈子里,TLS 几乎总是意味着**另有一个从第一个字节起就说 TLS 的端口**,而不是带内升级。 +明文端口在 4201、TLS 端口在 4202,是常见的形态。确实存在一种协商式的变体, +但它罕见到至少有一个客户端的文档明确写着不支持。 + +## 游戏页面为什么单独标注它 + +TLS 是本站唯一一项靠*亲自做一遍*来确立的能力:一个端点被标为 TLS, +是因为我们对它完成了一次 TLS 握手。这里没有询问,也没有可供自述的字段, +这让它成为整个目录里最干净的一项实测。 + +这也是为什么一个游戏的 TLS 端口和它的明文端口是作为两个独立端点分别列出的,而不是合并在一起。 +它们是对不同的东西所作的不同实测。 + +## 实用建议 + +如果你玩的游戏提供了 TLS 端口,就用它。如果没有而你又在意,那就去问——对管理员来说这工作量不大, +而它之所以还没普及,多半是因为没人开口,而不是因为有人反对。 + +在依赖它之前,先确认你的客户端支持它。[客户端](/reference)一节里有好几个是支持的; +至少有一个记载的是改用外部 `stunnel` 进程的变通办法,这办法可行, +但要配置的东西比大多数人愿意折腾的要多。 diff --git a/content/reference/zh-Hans/protocol-ttype.md b/content/reference/zh-Hans/protocol-ttype.md new file mode 100644 index 0000000..4f25839 --- /dev/null +++ b/content/reference/zh-Hans/protocol-ttype.md @@ -0,0 +1,41 @@ +--- +kind: protocol +slug: ttype +title: TTYPE and MTTS +summary: 客户端如何告诉服务器自己是什么、能做什么——包括在它愿意开口的时候,说出正有人在使用屏幕阅读器。 +protocol: TTYPE +home: https://www.mudhalla.net/tintin/protocols/mtts/ +see-also: protocols/charset +see-also: clients/tintin +see-also: clients/blightmud +--- + +TTYPE 是 telnet 选项 24,出自 RFC 1091:服务器问客户端它是什么终端,客户端作答。 +历史上这个答案是 `VT100` 或 `ANSI`。 + +**MTTS**——Mud Terminal Type Standard——在它之上叠了一层约定。客户端回答三次:它的名字、 +它的终端类型,然后是 `MTTS `,其中的各个比特位自述它具备哪些能力。256 色、真彩色、 +UTF-8、MNES、走带外的 MSP——以及很值得注意的 **`MTTS_SCREEN_READER`**。 + +## 屏幕阅读器那一位 + +最后这一项值得停下来说一说,因为在这个圈子的协议栈里,只有这一处把无障碍当作一等的概念。 + +设置了这一位的客户端,是在告诉服务器有人正在使用屏幕阅读器;注意到这一点的服务器可以随之调整: +不再输出 ASCII 图画,去掉房间描述外面那圈装饰性的制表边框,改变表格的排版方式。 +[TinTin++](/reference/clients/tintin) 和 [Blightmud](/reference/clients/blightmud) 都会声明它, +[Mudlet](/reference/clients/mudlet) 有一个对应的设置项。 + +至于某个具体的游戏是否真的据此做了什么,那是另一个问题,而且不是本站能实测的问题—— +我们没法去问一台服务器,它会做出什么不一样的处理。 + +## 爬虫在这里该尽的本分 + +爬虫要通过 TTYPE 表明自己的身份,它也应该这么做。我们的爬虫就这么做,并附上一个说明页地址, +好让翻看日志的管理员能弄清是谁一直在连他们的游戏,以及该怎么让我们停下。 +一个只回答 `ANSI`、别的什么都不说的爬虫,是设计上就匿名的,而这没有什么好理由。 + +## 我们实测的是什么 + +与我们协商了 TTYPE 的服务器。要注意,这是少数几个由*我们*作为被询问一方的选项之一, +所以这里的数字,数的是那些愿意开口来问的服务器。 diff --git a/docs/2026-08-17-find-page-qa.md b/docs/2026-08-17-find-page-qa.md new file mode 100644 index 0000000..0396726 --- /dev/null +++ b/docs/2026-08-17-find-page-qa.md @@ -0,0 +1,342 @@ +# Find a game — mockup parity QA + +An audit of `/find` on `design/measured-once` against the **third** handoff bundle +(`Mu-index accessibility audit (2).zip` → `design_handoff_find_page/`), which is scoped to this one +page and supersedes the second bundle where they overlap. Same shape as +[`2026-08-17-mockup-parity-qa.md`](2026-08-17-mockup-parity-qa.md): numbered deltas with what the +mock says, what ours does as measured, and a severity; then the conflicts. + +**Parts one to four were written before the work; part five records what shipped.** Everything in +part two is now built except where part five says otherwise. + +## Method + +- Bundle extracted to `/tmp/mui_handoff3/`; a copy of `MU Index Find Page.dc.html` with + `showAnnotations` defaulted to `false` served over `python3 -m http.server` and rendered in + Playwright's Chromium at 1440 / 1024 / 768 / 430. +- Ours at `http://127.0.0.1:5199/find`, both themes, same four widths, on the fixture — so every + page carries the demo banner, which CLAUDE.md requires and which is not a delta. Fixture counts + are small (6 games); the *shape* of each control is what is compared, never the numbers. +- Every value below is a `getComputedStyle` / `getBoundingClientRect` reading. +- `?plain=1` read as text; querystring behaviour probed with `curl`. + +--- + +# Part one — what the third bundle changed + +`_ds/` (tokens, `styles.css`, manifest, adherence config, fonts) and `support.js` are **byte-identical** +to the second bundle — `md5sum` matches on all ten files. The design system did not move. What +changed is the README and one new screen. + +| # | Change | Detail | +|---|---|---| +| Δ1 | **Find gets a screen and a review of its own** | Bundle 2 mocked home, games and game detail and gave Find four rows in a copy table. Bundle 3 is a single-page handoff with eight findings (F1–F8), a working prototype, a layout spec and a verification list. Everything below Δ7 is new material, not a restatement. | +| Δ2 | **Both intro paragraphs are now deleted, not rewritten** | Bundle 2 rewrote the lede to `Six optional questions. Every number is a count of games we measured, not an estimate.` and offered `Answers filter; they never rank.` "or cut". Bundle 3 deletes both outright — the live count is meant to make the point by demonstration. **We shipped bundle 2's version of both strings**, so both are now superseded. | +| Δ3 | **`uncounted` is reversed** | Bundle 2: `quiet is fine — we can reach it, and could not count` → **`uncounted` *(match the listing)***. Bundle 3: → **`reachable, but we can't count`**. A direct reversal of the string we shipped, and of a decision recorded in `FindAGame.razor`'s own comment. See §X6. | +| Δ4 | **The TLS gloss grew, and TLS folds into the protocol list** | Bundle 2: `TLS — handshake completed by us`. Bundle 3: `TLS — encrypted, handshake completed by us`, as row three of one uniform list, not a separate control. | +| Δ5 | **The acronym fix hardens** | Bundle 2 offered "add three words each **or** move the set behind an advanced disclosure". Bundle 3 removes the choice: every option reads *name · three-word gloss · count*, one shape per question. | +| Δ6 | **`?plain=1` is still described as absent from `/find`** | True of production in Aug 2026, stale for this branch — we shipped it. The requirement it carries is new though: plain must carry the `unknown` options and the count. | +| Δ7 | **New in bundle 3, with no antecedent in bundle 2** | The intersection count and the sticky panel (F1); the binding-answer "loosen" button (F2); `unknown` as a selectable option carrying its count (F3); grouping declared free text and badging the question `◆ derived` (F4); the long-tail collapse (F5); inverting the dark-games default (F7); the submit-hands-off-to-the-listing model and the shared vocabulary it needs (F8); single-select on every question including the client one; the layout spec — 24/14/24 rhythm, the `` padding trap, `overflow: clip` vs `hidden`, 310px panel, 40px option chips. | + +--- + +# Part two — the deltas + +Severity: **blocker** = the page makes a claim it cannot support, or an affordance is unreachable · +**major** = the redesign's premise or the site's own visual language is missing · +**minor** = a measurable gap that reads as polish. + +## L — Layout and chrome + +The Find page was not touched by either of the two earlier passes. It is the only page left on +**user-agent default form styling**. + +| # | Item | Mock | Ours (measured) | Sev | +|---|---|---|---|---| +| L1 | **Fieldsets are unstyled** | `border: 0`, questions separated by a 1px `--border-soft` hairline | `border: 2px groove rgb(107,107,107)` in dark, `rgb(239,239,239)` in light — the UA default, drawn full-bleed at 1394px | major | +| L2 | Fieldset padding | `0 24px 24px` | `4.9px 10.5px 8.75px`, plus a UA `margin: 0 2px` | major | +| L3 | **Vertical rhythm** | 24 rule→heading · 14 heading→options · 24 options→rule (measured on the mock as 24 / 14 / 25-incl-hairline) | **0 / 6.9 / 12.8** | major | +| L4 | Question separation | 1px hairline, full column width | fieldsets are flush: measured gap between consecutive fieldsets is **0** at every width; the groove border is the only separator | major | +| L5 | Legend typography | 16px / 600, `--text` | **14px / 400**, `padding: 0 2px` — a legend, never promoted to a heading | major | +| L6 | Legend padding | `24px 0 14px` on the legend itself (the README's warning: padding on the fieldset does not move it) | `0 2px` | major | +| L7 | No card, no two-column shell | one card, `grid-template-columns: minmax(0, 1fr) minmax(0, 310px)`, `gap: 0`, `align-items: stretch` | single flow column, `form.wizard` is `display: block`, `max-width: none`, spanning the full 1394px gutter-to-gutter | major | +| L8 | Footer row | `all games · random game · plain text` on `--surface-3` with a top hairline | a bare `plain text` link, no rule, no siblings | minor | +| L9 | `app.css` has no rule for this page | — | `fieldset.facets` and `.facet` are styled for the listing panel; **there is no selector for `form.wizard`, its `fieldset`, or its `legend`** anywhere in the 2394 lines | major | + +## N — The count panel (the centre of the redesign, entirely absent) + +| # | Item | Mock | Ours (measured) | Sev | +|---|---|---|---|---| +| N1 | **Intersection count** | mono 40px / 600, `tabular-nums`, accent, with the noun beside it at 13.5px | **does not exist**. Every number on the page is marginal, which is exactly F1's complaint | blocker | +| N2 | Sticky panel | outer cell carries `--surface-3` + left hairline at full row height (measured 310×1094 at 1440); inner wrapper `position: sticky; top: 20px`, `padding: 24px 22px`, `gap: 14px` | **0 elements with `position: sticky`** inside `main` | blocker | +| N3 | Kicker | `MATCHING ALL ANSWERS`, 9px, `letter-spacing: 1.44px`, uppercase, faint | absent | major | +| N4 | Sub-line | `of 515 known · 2 answers given`, 12px faint | absent | major | +| N5 | Answer chips | one per given answer, radius 99px, each with `aria-label="clear answer to: "` | absent | major | +| N6 | Loosen button | `× drop "Historical" → 4 games` when the count is small; the binding answer is the given answer with the smallest marginal count | absent. See §X2 — the number on it may not be estimated | major | +| N7 | `clear all answers` | plain link under the CTA | absent | minor | +| N8 | Submit | `Show these 19 games` — count in the label — 265×42, 14px/600, accent tint + `--accent-ring` + `--glow-sm`, radius 9 | `Show me the games`, **163×37**, `padding: 8px 14px`, **14px/400**, `rgb(29,33,37)` on dark, radius 9 | major | +| N9 | Live region | count in `aria-live="polite"`, debounced, announced as a sentence | **0 elements with `aria-live`** on the page. See §X3 — with no JS the round-trip replaces the live region rather than needing one | major | +| N10 | The panel's first line aligns with the first question's heading | `padding-top: 24px` on the sticky wrapper matching the legend | n/a — no panel | minor | + +## Q — Questions and options + +| # | Item | Mock | Ours (measured) | Sev | +|---|---|---|---|---| +| Q1 | **Option control** | `aria-pressed` buttons, `min-height: 40px`, `padding: 8px 14px`, radius 9px, laid out `flex-wrap: wrap; gap: 10px 8px` | native radios in `display: inline` labels; **19px tall**, `padding: 0`, `min-height: 0` — under half the 40px target and well under 44px | major | +| Q2 | **Three questions are ``** | genre, kind and language are option chips like every other question | `` × 3, measured **137×32 at every width including 430**, 13px, radius 6px. Two option shapes in one form, and a 32px target | major | +| Q3 | **`unknown` is not offered** | `unknown (395)` is a real selectable option in genre, kind and language, carrying its count | `FindAGame.razor:249` filters `.Where(v => !v.IsUnknown)`. **The query layer already supports it** — verified `/games?genre=~unknown&plain=1` returns a filtered listing with the row marked selected. The page declines to offer a filter the site can already apply | blocker | +| Q4 | Provenance badge per question | `● measured` / `◇ declared` / `◆ derived grouping` on every legend | none. `FacetWords.Evidence` and `FacetEvidence` already produce these three words for the listing panel | major | +| Q5 | Counts on every question | activity `199 / 229 / 280`, dark `493`; every option except "any" carries a number | activity and dark options carry **no counts at all**; genre/kind/language carry them inside the `` text | major | +| Q6 | **The client question is single-select** | one `aria-pressed` choice of eight, `doesn't matter` first | seven **checkboxes** — multi-select. Also see Q7/Q8 | major | +| Q7 | **TLS appears twice** | one row, `TLS — encrypted, handshake completed by us 74` | the dedicated `FacetKeys.Tls` checkbox *and* a `protocol=TLS` row measured as `TLS— measured in the handshake 1 games` — the second one falls through to the generic gloss. Two controls, two meanings, one acronym | blocker | +| Q8 | **A missing space and a broken plural** | `MSSP — server self-description 171` | measured accessible text: `MSSP— server self-description 5 games`, `MSDP— structured client data 1 games`. Razor eats the whitespace after the `@if/else` block, and the unit is hard-pluralised | major | +| Q9 | Option order | count-descending inside each question, `any` first | same for the selects; protocols are count-descending and capped at 6 (`Take(6)`), so the cap can hide a protocol the reader wants | minor | +| Q10 | Long tail | anything under 3 folds into `3 more genres`, expanded on click | no collapse. See §X5 before building it | minor | +| Q11 | Name field | label stacked **above** a 460px field on `--surface-3` with a `⌕` glyph; kicker `GAME NAME, IF YOU HAVE ONE` 9px; placeholder `name, or part of one` | label sits **beside** the input (label at x=23, input at x=201); field 226px, no glyph; kicker `A NAME, IF YOU HAVE ONE` at 10px; placeholder `part of a game's name`. The real `` the mock asks for is already there | minor | + +## C — Copy + +| # | Item | Mock | Ours (measured) | Sev | +|---|---|---|---|---| +| C1 | Intro paragraph | deleted | present: `Six optional questions. Every number is a count of games we measured, not an estimate. Skip to the whole listing…` | minor | +| C2 | Trailing paragraph | deleted | present: `Answers filter; they never rank.` | minor | +| C3 | Escape hatch | survives as `games` in the nav and `all games` in the footer, or beside submit — not in a paragraph | inside the intro paragraph, which C1 deletes. **Must be re-placed, not dropped** | major | +| C4 | Uncounted band | `reachable, but we can't count` | `uncounted`, read from `FacetWords.BandWord`. See §X6 — this reverses bundle 2 and a recorded decision | — | +| C5 | Dark question options | `include them` *(default)* / `live games only 493` | `no, only live games` *(checked)* / `yes, show me those too`, no counts | minor | +| C6 | Submit label | carries the count | does not | major | +| C7 | `unknown` wording | one word, `unknown`, in all three questions | `FacetWords` spells absence per facet — `not declared`, `not identified`, `nothing negotiated` — and its doc comment says so deliberately. See §X7 | — | + +## S — State, the querystring, and the plain surface + +| # | Item | Mock | Ours (measured) | Sev | +|---|---|---|---|---| +| S1 | **`/find` does not read its own querystring** | answers belong in the URL, linkable, surviving reload | `/find?genre=Fantasy` renders with **0 selected options**. The form is a GET at `/games`, so state exists only *after* submit. Any count on `/find` requires it to bind its own answers first | blocker | +| S2 | **`?plain=1` is a different page** | plain carries the same six questions, every option including `unknown`, and the count | plain renders **ten facet groups** (`band`, `seen`, `charset`, `lineage`, `codebase`, `version`, `family`, `genre`, `language`, `protocol`, `tls`) as querystring recipes — not the six questions. It *does* offer `~unknown`, which the rendered page hides, so the two surfaces disagree about what can be asked | blocker | +| S3 | Two vocabularies for one facet across surfaces | one | rendered says `somebody is on now` / `somebody was on this week`; plain says `connected now` / `active this week` for the same three band tokens | major | +| S4 | Intersection count query | a real indexed count per change, never the prototype's multiplied marginals | not built. `IGameQueries.SearchAsync` already returns a filtered `GameListing`, so the count exists — it is one call away once S1 lands | major | +| S5 | Submit hands off to the listing with the answers as facets (F8) | that is the design | **already true** — every control's `name` is a `FacetKeys` constant and the form GETs `/games`. The page's own header comment says why. No work | ✅ | +| S6 | Find and Games share one vocabulary (F8) | one taxonomy nesting inside the other | **partly already true**: `lineage` (derived: MUSH, LPMud) and `family`/`codebase` (declared: PennMUSH, Evennia) both exist and both are facets. Find asks `family`; the mock's *"What kind of game?"* is the `lineage` question. This is a one-key change, not a taxonomy project. See §X4 | major | + +## R — Responsive, focus, themes + +| # | Item | Mock | Ours (measured) | Sev | +|---|---|---|---|---| +| R1 | Document horizontal scroll | none | **none** at 1440/1024/768/430, both themes | ✅ | +| R2 | Element clipping inside `main` | — | **none** at any width or theme | ✅ | +| R3 | **The protocol row collapses into prose at 430** | one chip per line, each a 40px target | measured at 430: the seven inline labels wrap as running text, so a checkbox ends a line and its own label starts the next — `MSSP— server / self-description 5 games`. The band radios do the same | major | +| R4 | **The mock has no responsive treatment** | — | at 430 the mock keeps `minmax(0,1fr) minmax(0,310px)`: the panel stays 310px, the question column is crushed to **139px**, option chips wrap to 54px tall and the legend to 42px. **The drawing cannot be followed at 430** — the panel must stack above or below the questions, and that decision is ours to make, not the mock's | — | +| R5 | Both themes | mock is dark-only and the README says the palette is not a proposal | ours: `--surface` `rgb(15,17,19)` dark / `rgb(238,241,242)` light, both from the ramp part three of the parity doc set. Not a delta | ✅ | +| R6 | Count and submit visible at the last question | the assertion that makes the redesign true | untestable — no count, no submit outside normal flow. **Add it to the verification list once N2 lands** | — | + +--- + +# Part three — conflicts with CLAUDE.md, or with a decision already recorded + +Each of these is a place where doing what the bundle says would break a rule this repository states, +or would reverse something part two/three of the parity doc already settled. None should be actioned +without a decision recorded here first. + +## X1 — F7's dark-games default contradicts rule 3 + +The bundle wants `Include games that have gone dark?` to default to **`include them`**, so the page's +"none of the questions are required" claim becomes true. + +CLAUDE.md rule 3: *"Archiving removes a game from the default listing, the rankings and the 'active +today' figure — and from nothing else."* Excluding archived games **is** the listing's default, by +design and by spec §7.6. Inverting it on Find would make the wizard's default disagree with the +listing it submits to, so the same reader gets two different result sets from two doors into one +query — and the drift would be invisible. + +**The bundle offers the compatible branch itself:** *"If the product prefers the current default, +then the page must say a filter is already applied — but do not keep both the claim and the +contradiction."* That is the route: keep `no, only live games`, and fix the sentence. Note that C1 +deletes the sentence containing the claim, which resolves the contradiction on its own — but only if +the replacement does not reintroduce it. + +## X2 — The prototype's count is fabricated, and rule 4 forbids shipping it + +`countFor()` in the prototype multiplies marginal ratios (`n = n * (opt.n / TOTAL)` per answer). The +README says "do not ship that; it assumes independence and will be wrong." CLAUDE.md rule 4 makes it +stronger than advice: a number produced by an assumption, rendered in the site's mono accent beside +the word *games*, is a fabricated measurement on the one site whose product is that it does not +fabricate. **Every number in the panel — the count, the sub-line, the figure on the loosen button — +must come from a real query or not be drawn.** + +## X3 — The live count as specified needs JavaScript; this site has none + +F1's panel is a debounced `aria-live` region updating as answers change. There is no JS anywhere on +this site, by constraint. The no-JS shape of the same idea: + +- `/find` binds its own querystring (S1), so each answer is a link or a `submit` back to `/find`. +- The count is computed server-side and rendered statically; the CTA is a link to `/games` with the + same querystring. +- `aria-live` and the debounce then become unnecessary rather than unimplemented — a page load + announces its own heading, which is the behaviour the debounce was approximating. The bundle's + underlying requirements (announce a sentence, not a bare number; do not move focus) survive intact + and are satisfied differently. + +This should be written down as a deliberate divergence, in the shape of §H in the parity doc, rather +than left as "the aria-live work is outstanding". + +## X4 — F4's grouping map is mostly already built, and rule 5 governs the rest + +The bundle proposes a raw-string → group map (`LPMud, FluffOS, LDMud → LPMud family`) plus a +`◆ derived` badge, with maintainer sign-off first. Two corrections: + +1. `MUI.Catalog` already draws this distinction. `lineage` is the derived grouping facet and carries + `FacetEvidence.Derived`; `family` and `codebase` are the declared strings. The work is to point + *"What kind of game?"* at `lineage` and render the badge — **not** to author a new mapping table + in the web layer, which would be a second copy of a vocabulary the catalogue owns (the exact + failure mode `FindAGame.razor`'s own header comment warns about). +2. Rule 5 — *never record a decision of ours as a measurement of theirs* — is what the `◆ derived` + badge exists to satisfy, and it is not optional decoration. A grouped option is our judgement + about their string. The badge is the sentence that says so. + +## X5 — "Dragonball is a data error — fix the record, don't render it" collides with rules 3 and 4 + +`Dragonball (1)` is a theme sitting in the codebase question, and the bundle says to exclude it. +But it is a value **the game declared**. Suppressing a declared string because we judge it +miscategorised is us overriding what a game said about itself, and rule 3 says nothing is ever +deleted. What is permissible: + +- Group it under `unknown`/`not identified` *only if* the grouping is badged derived and the game's + own page still shows the raw string (which F4 already promises). +- Let the long tail (F5) absorb it as one of the "n more" — an aggregation, not a deletion. + +What is not permissible is a hard-coded suppression list in the web layer. Same reasoning applies to +"normalize `russian` → `Russian`": case-folding for *display* of a derived group is fine; rewriting +the stored declared value is not. + +Additionally, F5's `3 more genres` bucket must expand to real selectable values. If the bucket +itself were submittable, Find would offer a choice `/games` cannot express — the one invariant the +page's header comment names as the first thing that would drift. + +## X6 — Δ3 reverses a decision recorded in the code + +`FindAGame.razor` carries this, verbatim: + +> the listing shortened this to "uncounted" and this page went on saying "reachable, count unknown" + +— which is the note explaining why the band word is now read from `FacetWords.BandWord` rather than +spelled twice. Bundle 2 agreed (`uncounted (match the listing)`). Bundle 3 now asks for +`reachable, but we can't count`, which is (a) the drift that was fixed, and (b) a phrase the locked +glossary in bundle 2 does not contain — `uncounted` is a glossary entry with a translator note; the +new phrase is a sentence that would have to be translated freely in nine locales. + +**Recommendation: do not take Δ3.** If the copy is genuinely wanted, change the *listing's* word too +and change the glossary entry, in one commit, so the two surfaces never disagree again. + +## X7 — One word `unknown`, versus a word per facet + +The mock labels every silent bucket `unknown`. `FacetWords`' own doc comment states the opposite as +a rule: + +> Every facet spells its own absence, and none of them spells it as a *no* — "not identified" is a +> fact about our reach, "not declared" is a fact about what a game published, and neither is a fact +> about the game lacking the thing. + +Genre and language are `not declared` (the game published nothing); codebase is `not identified` (we +could not tell). Collapsing both into `unknown` loses precisely the distinction rule 1 and the locked +glossary exist to protect. **Take F3's substance — make silence a selectable option carrying its +count — and keep our per-facet wording.** + +## X8 — Nothing here reverses a §H decision from the earlier passes + +Checked against the ten items in `2026-08-17-mockup-parity-qa.md` §H. Bundle 3 touches two of them +and agrees with both: §H8 (provenance stays, drawn differently) is *reinforced* by Q4's per-question +badges, and §H6 (a form needs a firable submit) is unaffected — the wizard's submit is the form's, +and the mock keeps a submit too. No reversal is proposed by this bundle, and none is proposed here. + +--- + +# Part four — shape of the work + +Read off the deltas above, not estimated in the abstract. + +| Layer | Items | Character | +|---|---|---| +| **CSS** | L1–L9, N2–N5, N7, N10, Q1, R3, and the visual half of N8 | The largest count and the lowest risk. `app.css` has no `form.wizard` block at all, so this is additive: a fieldset reset, the 24/14/24 legend rhythm, the option chip, the two-column grid with the sticky panel (`overflow: clip`, never `hidden`), the footer row. Roughly **40%** of the items. | +| **Markup / Razor** | Q2–Q9, Q11, C1–C7, N8's label, L8, S3, and the plain rewrite in S2 | Mostly small and mostly mechanical, but it includes three real bugs — the missing space, `1 games`, and TLS listed twice with two meanings. `?plain=1` needs the six questions rather than the ten-group facet dump, which is the biggest single piece here. Roughly **35%**. | +| **Query layer** | S1, S2's option parity, S4, S6, N1, N6, Q3, Q10 | Smallest count, all of the risk, and the only part that reaches `MUI.Catalog`. `/find` must bind its own querystring; the intersection count and the drop-the-binding-answer count are two `SearchAsync` calls that must be real (X2); `family` → `lineage` is one key; the long tail needs an aggregation the listing can also express. Roughly **25%**. | + +The two blockers that unlock everything else are **S1** (`/find` reads its own answers) and **Q3** +(`unknown` is offered, since the query layer already answers it). N1 depends on S1 and on nothing +else. + +## Verify before closing out, once the work is done + +Bundle 3's own list, plus what this audit adds: + +- With the viewport at the last question, the count and the submit are both in view (R6). +- Rhythm inside each question measures 24 / 14 / 24 (L3). +- Six `fieldset`/`legend` pairs, exactly one option selected per question (Q1, Q6). +- Submitting with JavaScript disabled returns the same result set — trivially true here, and worth + asserting anyway because it is the constraint the whole design bends around (X3). +- `/find?…` is linkable and survives reload (S1). +- `/find?plain=1` carries the same six questions and the same options including the silent bucket, + in the same words (S2, S3, X7). +- A combination returning zero shows the loosen button and the number on it comes from a query (N6, + X2). +- No option is offered that `/games` cannot apply (Q10, X5). + +--- + +# Part five — what shipped, and where it diverges + +Measured on the branch, at 1440 / 1024 / 768 / 430 in both themes and at `/de/find`, on the fixture. + +## The verification list, run + +| Check | Result | +|---|---| +| Count and submit in view with the viewport at the last question (R6) | ✅ at 1440, 1024, 768 and 430, both with and without answers | +| Rhythm 24 / 14 / 24 inside each question (L3) | ✅ exactly, on all six | +| Six labelled groups, exactly one option chosen in each (Q1, Q6) | ✅ six `role="group"` with `aria-labelledby`, one `aria-current="true"` each, every option ≥ 40px | +| Works with scripting off (X3) | ✅ zero ` } @code { + /// The locale this page is being answered in. + [CascadingParameter] private HttpContext? Http { get; set; } + + private string L(string id) => Messages.For(Http.LocaleOf().Tag, id); + /// /// Whether there is a database to sign in against. /// diff --git a/src/MUI.Web/Components/Pages/Submit.razor b/src/MUI.Web/Components/Pages/Submit.razor index f79ca2e..a75c572 100644 --- a/src/MUI.Web/Components/Pages/Submit.razor +++ b/src/MUI.Web/Components/Pages/Submit.razor @@ -4,6 +4,7 @@ @using MUI.Catalog.Persistence @using MUI.Discovery @using MUI.Web.Data +@using MUI.Web.Localization @inject IServiceProvider Services @inject CatalogueSource Catalogue @inject NavigationManager Nav @@ -23,17 +24,17 @@ a cookie to remember what it just did. *@ - + @if (Plain) { - @PlainText.RenderSubmit(Answer, Catalogue.IsMeasured) + @PlainText.RenderSubmit(Answer, Catalogue.IsMeasured, Tag) } else { - @SubmitCopy.Title - @SubmitCopy.Lede + @SubmitCopy.Title(Tag) + @SubmitCopy.Lede(Tag) @if (Answer is { } answer) @@ -43,47 +44,47 @@ else @answer.Sentence @if (answer.Link is { } link) { - @link.Label + @link.Label } } @if (!Catalogue.IsMeasured) { - @SubmitCopy.NoCatalogue + @SubmitCopy.NoCatalogue(Tag) } } @if (Catalogue.IsMeasured) { - + - @SubmitCopy.HostLabel + @SubmitCopy.HostLabel(Tag) - @SubmitCopy.HostHint + @SubmitCopy.HostHint(Tag) - @SubmitCopy.PortLabel + @SubmitCopy.PortLabel(Tag) - @SubmitCopy.SubmitLabel + @SubmitCopy.SubmitLabel(Tag) @if (!Plain) { - What happens to an address + @L("submit.what.heading") - @foreach (var point in SubmitCopy.Points) + @foreach (var point in SubmitCopy.Points(Tag)) { @point } - plain text + @L("a11y.plainText") } } @@ -139,10 +140,17 @@ else // Hidden means submitted and unclaimed — the state whose only exit is a claim, so that // is what the link offers. Everything else gets its public page. link = game.SubmittedAt is not null && !game.IsClaimed - ? SubmitLink.Claim(game.Slug) + ? SubmitLink.Claim(game.Slug, Tag) : SubmitLink.Game(game.Slug); } - Answer = SubmitCopy.Answer(outcome, SubmitLinks.Address(Host, Port), link); + Answer = SubmitCopy.Answer(outcome, SubmitLinks.Address(Host, Port), link, Tag); } + + /// The locale this page is being answered in. + [CascadingParameter] private HttpContext? Http { get; set; } + + private string Tag => Http.LocaleOf().Tag; + + private string L(string id) => Messages.For(Tag, id); } diff --git a/src/MUI.Web/Components/PlainText.cs b/src/MUI.Web/Components/PlainText.cs index 0085c40..8e63282 100644 --- a/src/MUI.Web/Components/PlainText.cs +++ b/src/MUI.Web/Components/PlainText.cs @@ -1,5 +1,6 @@ using System.Text; using MUI.Catalog; +using MUI.Web.Localization; namespace MUI.Web.Components; @@ -15,20 +16,51 @@ namespace MUI.Web.Components; /// /// It renders from the same view models the graphical pages use, which is what bounds its /// maintenance cost — the main pages are these with graphics added, not second documents that have -/// to be kept in step. Nothing here is wider than 80 columns and every state is a word, never a -/// glyph, a colour or a cell shape. +/// to be kept in step. No prose here is wider than 80 columns — addresses excepted, see +/// — and every state is a word, never a glyph, a colour or a cell shape. /// /// public static class PlainText { - /// Nothing this renderer writes exceeds this, because text browsers are 80 wide. + /// + /// No prose this renderer writes exceeds this, because text browsers are 80 wide. + /// + /// + /// URLs are the one exception, and are exempt on purpose. The cap used to be documented + /// as covering everything, which was not true and could not be made true: an address is printed + /// whole because a wrapped one is not clickable in the browsers this surface exists for, and a + /// find query with six answers in it is longer than eighty columns on its own. The choice is + /// between a line a reader can follow and a line a reader can use, and for an address it is the + /// second. Everything that is not an address wraps — — and + /// NoPlainLineIsWiderThanEightyColumns enforces exactly that split. + /// public const int Columns = 80; + /// + /// An address this surface prints, in the locale it is printing. + /// + /// + /// + /// These are the addresses on this surface — there are no anchors here, so a path is + /// printed for a reader to type, follow in a text browser or paste to somebody. That makes them + /// links in every sense that matters and subject to the same rule: a German reader who copies + /// /g/ashen-court off /de/games?plain=1 must not arrive in English, or the mirror + /// is telling them something the page it mirrors does not. + /// + /// + /// A query-only address is not passed through here and does not need to be: ?window=30d& + /// plain=1 means this page, asked differently in every language, and the reader is + /// already on the localized one. + /// + /// + private static string Path(string tag, string address) => LocaleRouting.Link(tag, address); + public static string Render( GamePage page, DateTimeOffset now, ReachSummary? reach = null, - TrendSeries? trend = null) + TrendSeries? trend = null, + string tag = Locales.SourceTag) { var b = new StringBuilder(); var s = page.Summary; @@ -54,30 +86,38 @@ public static string Render( b.AppendLine(); - // Every state spelled as a word. "Unknown" is written out rather than left blank, because a + // Every state spelled as a word. The absence is written out rather than left blank, because a // blank reads as zero to a human exactly as it does to a parser — and the count says how it // was obtained here as it does on the listing, or this page is the less honest of the two. + // + // The absence names no cause, for the reason the graphical hero's does not: a null count + // covers an unmeasured game, a probe that answered with nothing countable, and a count older + // than the window, and this surface can no more tell them apart than that one can. b.AppendLine((s.PlayersNow is { } n - ? $"Players now: {n} {Label(s.PlayersNowProvenance, now)}" - : "Players now: unknown (no count could be measured)").TrimEnd()); + ? $"{Say(tag, "game.plain.playersNow", ("count", n))} {Label(tag, s.PlayersNowProvenance, now)}" + : Say(tag, "game.plain.playersNoCount")).TrimEnd()); if (page.ReachableFraction is { } r) { - b.AppendLine($"Reachable: {Wording.Percent(r)} of the last 90 days"); + b.AppendLine(Say( + tag, + "reach.plain.fraction", + ("percent", Wording.Percent(r)), + ("days", ReachSeries.WindowDays))); } if (page.LongestOutage is { } o) { - b.AppendLine($"Longest outage: {Wording.Duration(o)}"); + b.AppendLine(Say(tag, "reach.plain.longestOutage", ("duration", Wording.Duration(o)))); } - AppendActivity(b, page.Activity); - AppendTrend(b, trend); - AppendReachable(b, reach); - AppendCapabilities(b, page); - AppendDeclared(b, page, now); - AppendConnectScreen(b, page); - AppendChanges(b, page); + AppendActivity(b, page.Activity, tag); + AppendTrend(b, trend, tag); + AppendReachable(b, reach, tag); + AppendCapabilities(b, page, tag); + AppendDeclared(b, page, now, tag); + AppendConnectScreen(b, page, tag); + AppendChanges(b, page, tag); return b.ToString(); } @@ -87,28 +127,47 @@ public static string Render( /// the same content the graphical page hides behind "read as text". The three states of spec /// §5.4 are three different words here and never share one. /// - private static void AppendActivity(StringBuilder b, IReadOnlyList cells) + private static void AppendActivity(StringBuilder b, IReadOnlyList cells, string tag) { if (cells.Count == 0) { return; } - Heading(b, "When people are on (UTC)"); - Wrap(b, ActivitySummary.Sentence(cells)); + Heading(b, Say(tag, "activity.plain.heading")); + + // The same threshold the graphical page draws on, and the same words. Below it there is no + // grid there and no seven lines here: a week of prose about two measured hours would be this + // surface describing a shape the measurements do not have. + if (ActivitySummary.MeasuredDays(cells) < ActivitySummary.MeasuredDaysForGrid) + { + Wrap(b, ActivitySummary.Sparse(tag, cells)); + return; + } + + Wrap(b, ActivitySummary.Sentence(tag, cells)); b.AppendLine(); - foreach (var line in ActivitySummary.PerDay(cells)) + foreach (var line in ActivitySummary.PerDay(tag, cells)) { Wrap(b, line, " "); } b.AppendLine(); - b.AppendLine(" counted = we got in and read a number, including a measured zero"); - b.AppendLine(" uncounted = we got in and no number could be read"); - b.AppendLine(" no data = we have no measurement for that hour"); + + // The key. The three words on the left are the site's own — two of them the glossary's + // locked ids — rather than a third spelling invented for this surface: "no data" said here + // what "not measured" says everywhere else, which left a reader deciding whether the two + // were one state. Wrapped rather than padded to a column, because a language whose word for + // "uncounted" is four syllables must not push the line past eighty. + Key(b, tag, "activity.key.counted", "activity.key.counted.meaning"); + Key(b, tag, "state.uncounted", "activity.key.uncounted.meaning"); + Key(b, tag, "state.notMeasured", "activity.key.notMeasured.meaning"); } + private static void Key(StringBuilder b, string tag, string word, string meaning) => + Wrap(b, $"{Say(tag, word)} = {Say(tag, meaning)}", " "); + /// /// The trend in words: the direction first, then a line per week. /// @@ -124,15 +183,16 @@ private static void AppendActivity(StringBuilder b, IReadOnlyList /// function — which is the decoration §9 is testing for. /// /// - private static void AppendTrend(StringBuilder b, TrendSeries? trend) + private static void AppendTrend(StringBuilder b, TrendSeries? trend, string tag) { if (trend is null || trend.Days.Count == 0) { return; } - Heading(b, $"HOW MANY, OVER TIME ({trend.From:d MMM yyyy} – {trend.To:d MMM yyyy}, UTC)"); - Wrap(b, trend.Sentence); + Heading(b, $"{Say(tag, "trend.plain.heading")} " + + $"({Say(tag, "trend.plain.range", ("from", trend.From), ("to", trend.To))})"); + Wrap(b, trend.Sentence(tag)); if (!trend.HasAnyCount) { @@ -141,7 +201,7 @@ private static void AppendTrend(StringBuilder b, TrendSeries? trend) b.AppendLine(); - foreach (var line in trend.PerWeek()) + foreach (var line in trend.PerWeek(tag)) { Wrap(b, line, " "); } @@ -150,36 +210,42 @@ private static void AppendTrend(StringBuilder b, TrendSeries? trend) var range = new TrendRange(trend.From, trend.To); - b.AppendLine($" earlier: ?{range.Previous().Query}&plain=1"); - b.AppendLine(" a week is summarised over the days in it we counted; a week with none says so"); + b.AppendLine($" {Say(tag, "trend.plain.earlier")}: ?{range.Previous().Query}&plain=1"); + Wrap(b, Say(tag, "trend.plain.note"), " "); } /// The 90-day strip in words: the summary, then every spell that was not reachable. - private static void AppendReachable(StringBuilder b, ReachSummary? reach) + private static void AppendReachable(StringBuilder b, ReachSummary? reach, string tag) { if (reach is null) { return; } - Heading(b, $"Reachable (last {reach.Window} days)"); - Wrap(b, reach.Sentence); + Heading(b, $"{Say(tag, "reach.plain.heading")} " + + $"({Say(tag, "reach.plain.window", ("days", reach.Window))})"); + Wrap(b, reach.Sentence(tag)); - if (reach.Spells.Count == 0) + var spells = reach.Spells(tag); + if (spells.Count == 0) { return; } b.AppendLine(); - foreach (var spell in reach.Spells) + foreach (var spell in spells) { b.AppendLine($" {spell}"); } } - private static void AppendCapabilities(StringBuilder b, GamePage page) + private static void AppendCapabilities(StringBuilder b, GamePage page, string tag) { - Heading(b, $"Capabilities ({page.DisagreementCount} of {page.Capabilities.Count} disagree)"); + Heading(b, Say( + tag, + "game.plain.capabilities", + ("disagreeing", page.DisagreementCount), + ("total", page.Capabilities.Count))); // The same order the matrix uses: disagreements first, then measured-present, then absent, // then unknown. Two surfaces of one fact must not put it in two places. @@ -193,23 +259,24 @@ private static void AppendCapabilities(StringBuilder b, GamePage page) }) .ThenBy(c => c.Protocol, StringComparer.Ordinal)) { - var flag = c.Disagrees ? " ** disagree" : string.Empty; - b.AppendLine($" {c.Protocol,-10} measured: {Word(c.Measured),-7} declared: {Word(c.Declared)}{flag}"); + var flag = c.Disagrees ? " " + Say(tag, "game.plain.disagree") : string.Empty; + b.AppendLine($" {c.Protocol,-10} {Say(tag, "game.plain.measured")}: {Word(c.Measured),-7} " + + $"{Say(tag, "game.plain.declared.column")}: {Word(c.Declared)}{flag}"); } } - private static void AppendDeclared(StringBuilder b, GamePage page, DateTimeOffset now) + private static void AppendDeclared(StringBuilder b, GamePage page, DateTimeOffset now, string tag) { if (page.Declared.Count == 0) { return; } - Heading(b, "Declared by the game"); + Heading(b, Say(tag, "game.plain.declared")); foreach (var (name, chip) in page.Declared) { - b.AppendLine($" {name,-10} {chip.Value} {Label(chip, now)}"); + b.AppendLine($" {name,-10} {chip.Value} {Label(tag, chip, now)}"); } } @@ -230,48 +297,49 @@ private static void AppendDeclared(StringBuilder b, GamePage page, DateTimeOffse /// break, and a private spelling is one the fifth surface cannot reach even to obey. /// /// - internal static string Label(ProvenanceChip? chip, DateTimeOffset now) => chip is null + internal static string Label(string tag, ProvenanceChip? chip, DateTimeOffset now) => chip is null ? string.Empty - : $"({Provenance.How(chip)}, {Relative.Format(now - chip.LastConfirmedAt)}" - + (chip.IsStale ? ", stale)" : ")"); + : Messages.For( + tag, + chip.IsStale ? "chip.plain.stale" : "chip.plain", + new Dictionary(StringComparer.Ordinal) + { + ["how"] = Provenance.How(tag, chip), + ["age"] = Relative.Format(tag, now - chip.LastConfirmedAt), + }); /// - /// The connect screen with its SGR stripped. Colour codes are never announced, and the three - /// cases the frame has — suppressed, too small, oversized — are stated rather than left as an - /// absence for the reader to interpret. + /// The connect screen with its SGR stripped. Colour codes are never announced, and the cases the + /// frame has — suppressed, absent, too small — are stated rather than left as an absence for the + /// reader to interpret. /// - private static void AppendConnectScreen(StringBuilder b, GamePage page) + private static void AppendConnectScreen(StringBuilder b, GamePage page, string tag) { var screen = Ansi.Parse(page.ConnectScreen, page.ConnectScreenSuppressed); - Heading(b, "Connect screen"); + Heading(b, Say(tag, "game.plain.connectScreen")); switch (screen.State) { case AnsiScreenState.Suppressed: - b.AppendLine(" The owner asked us not to republish this game's connect screen."); + Wrap(b, Say(tag, "ansi.suppressed"), " "); return; case AnsiScreenState.Absent: - b.AppendLine(" No connect screen has been captured from this game."); + Wrap(b, Say(tag, "ansi.absent"), " "); return; case AnsiScreenState.TooSmall: - b.AppendLine($" Only {screen.RowCount} row(s) came back — too little to show."); + Wrap(b, Say(tag, "ansi.tooSmall", ("count", screen.RowCount)), " "); return; } // The same caption the figure carries, for the same reason: on a game whose bytes were not // UTF-8 this is how a reader learns which encoding they are looking at rather than blaming // their terminal. The two surfaces must not disagree about the screen. - var charset = page.ConnectScreenCharset is { Length: > 0 } read ? $", read as {read}" : string.Empty; - - b.AppendLine($" [connect screen: {screen.RowCount} lines, text only{charset}]"); - if (screen.IsOversized) - { - b.AppendLine($" Unusually long; the graphical page shows the first {Ansi.CropRows}."); - } - + b.AppendLine(" [" + (page.ConnectScreenCharset is { Length: > 0 } read + ? Say(tag, "ansi.plain.rows.readAs", ("count", screen.RowCount), ("charset", read)) + : Say(tag, "ansi.plain.rows", ("count", screen.RowCount))) + "]"); b.AppendLine(); foreach (var row in screen.Rows) { @@ -279,14 +347,14 @@ private static void AppendConnectScreen(StringBuilder b, GamePage page) } } - private static void AppendChanges(StringBuilder b, GamePage page) + private static void AppendChanges(StringBuilder b, GamePage page, string tag) { if (page.Changes.Count == 0) { return; } - Heading(b, "What changed"); + Heading(b, Say(tag, "game.plain.whatChanged")); foreach (var change in page.Changes.OrderByDescending(c => c.At)) { b.AppendLine($" {change.At:yyyy-MM-dd} {change.Summary}"); @@ -302,7 +370,8 @@ private static void AppendChanges(StringBuilder b, GamePage page) /// URL. A panel that only worked as a widget would fail §9's own test of itself: if a fact /// cannot survive in plain text, its graphic on the main site is decoration. /// - public static string RenderListing(GameListing listing, GameFilter filter, DateTimeOffset now) + public static string RenderListing( + GameListing listing, GameFilter filter, DateTimeOffset now, string tag = Locales.SourceTag) { ArgumentNullException.ThrowIfNull(listing); ArgumentNullException.ThrowIfNull(filter); @@ -322,7 +391,7 @@ public static string RenderListing(GameListing listing, GameFilter filter, DateT // The order, stated. A sorted list that does not say what it is sorted by is one a reader has // to reverse-engineer from the first few rows — and that is exactly how a tail of games // showing no number gets read as a tail of games with no players. - b.AppendLine($"Sorted by {FacetWords.Sort(filter.Sort)}"); + b.AppendLine($"Sorted by {FacetWords.Sort(tag, filter.Sort)}"); // And every order it could have been in, wrapped rather than run on: a text browser cannot // operate a but can perfectly well edit a URL, and nine sort tokens on one line is @@ -331,7 +400,7 @@ public static string RenderListing(GameListing listing, GameFilter filter, DateT // plain surface has that the rendered one does not. Wrap(b, $"?{FacetKeys.Sort}={string.Join(" / ", FacetTokens.Sorts)}", " "); - AppendFacets(b, listing.Facets); + AppendFacets(tag, b, listing.Facets); b.AppendLine(); if (games.Count == 0) @@ -350,32 +419,38 @@ public static string RenderListing(GameListing listing, GameFilter filter, DateT if (!broken && GameSorting.IsUnranked(g, filter.Sort)) { broken = true; - b.AppendLine($"-- from here: {FacetWords.Unranked(filter.Sort)}"); + b.AppendLine( + $"-- {Say(tag, "listing.plain.fromHere")}: {FacetWords.Unranked(tag, filter.Sort)}"); b.AppendLine(); } - var mark = g.State is LifecycleState.Archived ? "[archived]" : g.IsClaimed ? "[claimed]" : "[unclaimed]"; - b.AppendLine($"{g.Name} {mark}"); - b.AppendLine($" /g/{g.Slug}"); + // Archived and claimed are the two worth a mark; unclaimed is most of the catalogue and + // is not one, here for the same reason it is not on the rendered row. This surface and + // that one say the same things about a game or they are not two views of one listing. + var mark = g.State is LifecycleState.Archived ? $" [{Say(tag, "listing.plain.archived")}]" + : g.IsClaimed ? $" [{Say(tag, "listing.plain.claimed")}]" + : string.Empty; + b.AppendLine($"{g.Name}{mark}"); + b.AppendLine($" {Path(tag, $"/g/{g.Slug}")}"); // How we know, and how old it is — the same two words and the same relative age the game // page uses, because two surfaces of one fact must not have two vocabularies. The word // was hard-coded here and said "(measured)" over every count including the ones a game // asserted about itself, which is rule 5 broken by a format string. b.AppendLine((g.PlayersNow is { } n - ? $" Players now: {n} {Label(g.PlayersNowProvenance, now)}" + ? $" Players now: {n} {Label(tag, g.PlayersNowProvenance, now)}" : " Players now: unknown (no count could be measured)").TrimEnd()); // What a window sort ranked this row on. Only where there is one, because a line reading // "over 7 days: —" on every row of an alphabetical listing is a column of nothing. if (g.PlayersOverWindow is { } window) { - b.AppendLine($" Ranked on: {FacetWords.Window(window, filter.Sort)}"); + b.AppendLine($" Ranked on: {FacetWords.Window(tag, window, filter.Sort)}"); } // Never blank. "We could not identify it" is a measurement and a missing line is not. b.AppendLine((g.Codebase is { } codebase - ? $" Codebase: {codebase} {Label(g.CodebaseProvenance, now)}" + ? $" Codebase: {codebase} {Label(tag, g.CodebaseProvenance, now)}" : " Codebase: not identified").TrimEnd()); b.AppendLine(g.MeasuredProtocols.Count > 0 @@ -386,7 +461,7 @@ public static string RenderListing(GameListing listing, GameFilter filter, DateT // the oldest bucket, because a game we have never got an answer from has no date and // inventing one from our first sighting would read as its outage. b.AppendLine(g.LastReachableAt is { } seen - ? $" Last reached: {Relative.Ago(now - seen)}" + ? $" Last reached: {Relative.Ago(tag, now - seen, AgeSense.Reached)}" : " Last reached: never — no answer yet"); if (g.Tagline is { } tagline) @@ -409,7 +484,7 @@ public static string RenderListing(GameListing listing, GameFilter filter, DateT /// a game is not a no — those are the two readings this whole design exists to prevent, and a /// surface that leaves them to be inferred has left the important half out. /// - private static void AppendFacets(StringBuilder b, IReadOnlyList facets) + private static void AppendFacets(string tag, StringBuilder b, IReadOnlyList facets) { if (facets.Count == 0) { @@ -422,29 +497,58 @@ private static void AppendFacets(StringBuilder b, IReadOnlyList face // surface where the key is the only place the distinction is ever made. Wrap(b, "Each facet is marked " + string.Join(", ", Enum.GetValues() - .Select(e => $"{FacetWords.Evidence(e)} ({FacetWords.EvidenceMeaning(e)})")) + .Select(e => $"{FacetWords.Evidence(tag, e)} ({FacetWords.EvidenceMeaning(tag, e)})")) + "."); b.AppendLine(); Wrap(b, "Counts are exact, from the same query as the list below. A blank is a gap in our " + "measurement, never a \"no\": each facet spells its own. A measured zero is a count; " + "an unknown count is not a zero and never sorts as one."); + b.AppendLine(); + + // The marks in the left column, said once. Two of the three states are a filter the reader + // applied, and a surface that draws them the same way has published one as the other. + Wrap(b, Say(tag, "facet.plain.marks")); foreach (var group in facets) { b.AppendLine(); - b.AppendLine($" {FacetWords.Group(group.Key)}" - + $" — {FacetWords.Evidence(group.Evidence)} (?{group.Key}=…)"); + + // The rendered panel gathers these two under one heading and one note; here they are two + // groups in a list of nine, so the heading arrives with the first of them and the note + // arrives with it. Leaving the note to the graphical surface would put the one sentence + // that stops a hidden row reading as an empty game on only one of the two surfaces. + if (string.Equals(group.Key, FacetKeys.Uncounted, StringComparison.Ordinal)) + { + b.AppendLine($" {Say(tag, "facet.group.measure").ToUpperInvariant()}"); + Wrap(b, Say(tag, "facet.measure.note"), " "); + b.AppendLine(); + } + + b.AppendLine($" {FacetWords.Group(tag, group.Key)}" + + $" — {FacetWords.Evidence(tag, group.Evidence)} (?{group.Key}=…)"); foreach (var value in group.Values) { - var words = FacetWords.Value(group.Key, value); + var words = FacetWords.Value(tag, group.Key, value); var gloss = string.Equals(words, value.Token, StringComparison.Ordinal) ? string.Empty : " " + words; - // A star, not a colour: the selected value has to be visible where there is no ink. - b.AppendLine($" {(value.IsSelected ? '*' : ' ')} {value.Token,-24}{value.Count,5}{gloss}" - .TrimEnd()); + // A mark, not a colour: the selected value has to be visible where there is no ink. + // + // **Three marks, because the facet has three states.** A star for both included and + // excluded drew "only these" and "anything but these" identically — the exact defect + // the rendered panel was rebuilt to remove, left standing on the surface where a + // reader has the least else to go on. It matters most on the two measurement + // switches, whose ordinary gesture is the exclusion. + var mark = value.State switch + { + FacetState.Included => '*', + FacetState.Excluded => '-', + _ => ' ', + }; + + b.AppendLine($" {mark} {value.Token,-24}{value.Count,5}{gloss}".TrimEnd()); } } } @@ -453,30 +557,44 @@ private static void AppendFacets(StringBuilder b, IReadOnlyList face /// The three liveness feeds. All three are the same shape here, because the register the /// graphical cards carry is a tone and a tone is not a fact — the words have to do the work. /// - public static string RenderFeeds(LivenessFeeds feeds, DateTimeOffset now) + public static string RenderFeeds(string tag, LivenessFeeds feeds, DateTimeOffset now) { var b = new StringBuilder(); - Feed(b, "NEWLY DISCOVERED", feeds.NewlyDiscovered, "Nothing new.", now); - Feed(b, "WENT DARK", feeds.WentDark, "Nothing went dark.", now); - Feed(b, "CAME BACK", feeds.CameBack, "Nothing came back. We keep knocking.", now); + // Heading and empty state both through the bundle. The empty states are the ids the + // graphical cards already say, so the two surfaces cannot disagree in any language; the + // headings are plain-only ids, because the card's own kicker for the middle feed carries + // "— still probed" and this column has that promise elsewhere. + Feed(b, tag, "feed.plain.newlyDiscovered", feeds.NewlyDiscovered, "feed.nothingNew", now); + Feed(b, tag, "feed.plain.wentDark", feeds.WentDark, "feed.nothingDark", now); + Feed(b, tag, "feed.plain.cameBack", feeds.CameBack, "feed.nothingBack", now); return b.ToString(); - static void Feed(StringBuilder b, string title, IReadOnlyList entries, string empty, DateTimeOffset now) + static void Feed( + StringBuilder b, + string tag, + string title, + IReadOnlyList entries, + string empty, + DateTimeOffset now) { - b.AppendLine(title); + // Uppercased here rather than in the bundle, as every other heading on this surface is: + // the id carries the words, the renderer carries the typography, and a script with no + // case is left exactly as its translator wrote it. + b.AppendLine(Say(tag, title).ToUpperInvariant()); if (entries.Count == 0) { - b.AppendLine($" {empty}"); + b.AppendLine($" {Say(tag, empty)}"); b.AppendLine(); return; } foreach (var e in entries) { - b.AppendLine($" {e.Name} ({Relative.Format(now - e.At)} ago) /g/{e.Slug}"); + b.AppendLine( + $" {e.Name} ({Relative.Ago(tag, now - e.At)}) {Path(tag, $"/g/{e.Slug}")}"); Wrap(b, e.Detail, " "); } @@ -486,6 +604,7 @@ static void Feed(StringBuilder b, string title, IReadOnlyList entries /// The home page: what we know, then what changed. public static string RenderHome( + string tag, SiteCounts counts, LivenessFeeds feeds, CrawlerPulse pulse, @@ -495,12 +614,14 @@ public static string RenderHome( var b = new StringBuilder(); + // The wordmark, which is the site's name and stays out of the bundle for the same reason a + // hostname does. Everything under it is a sentence and goes through it. b.AppendLine("MU*INDEX"); b.AppendLine(); - b.AppendLine($"{counts.Known} games known"); - b.AppendLine($"{counts.WithPlayersOn} with players on now (measured)"); - b.AppendLine($"{counts.CountUnknown} answering, count unknown"); - b.AppendLine($"{counts.Archived} archived, still probed"); + b.AppendLine(Say(tag, "home.plain.known", ("count", counts.Known))); + b.AppendLine(Say(tag, "home.plain.connectedNow", ("count", counts.WithPlayersOn))); + b.AppendLine(Say(tag, "home.plain.uncounted", ("count", counts.CountUnknown))); + b.AppendLine(Say(tag, "home.plain.archived", ("count", counts.Archived))); // Same three facts as the rendered strip, in the same order and the same words, plus the // registry line the narrow page has no room for. Omitted entirely when there is nothing @@ -508,45 +629,69 @@ public static string RenderHome( if (pulse.State(now) is not CrawlState.NotYet) { b.AppendLine(); - b.AppendLine(CrawlerCopy.State(pulse, now)); + b.AppendLine(CrawlerCopy.State(tag, pulse, now)); - if (CrawlerCopy.LastCycle(pulse) is { } cycle) + if (CrawlerCopy.LastCycle(tag, pulse) is { } cycle) { b.AppendLine(cycle); } - b.AppendLine(CrawlerCopy.Registry(pulse)); + b.AppendLine(CrawlerCopy.Registry(tag, pulse)); } b.AppendLine(); - b.Append(RenderFeeds(feeds, now)); + b.Append(RenderFeeds(tag, feeds, now)); return b.ToString(); } /// The archive. Past tense, no alarm, and the run of years given as a fact. - public static string RenderArchive(IReadOnlyList entries, string? query, DateTimeOffset now) + /// + /// The label column is measured rather than hard-spaced. Four labels padded to seventeen + /// columns is an arrangement that holds for exactly one language, and a locale whose word for + /// "last reachable" is longer would have run its value into it. + /// + public static string RenderArchive( + IReadOnlyList entries, + string? query, + DateTimeOffset now, + string tag = Locales.SourceTag) { + ArgumentNullException.ThrowIfNull(entries); + var b = new StringBuilder(); - b.AppendLine("THE ARCHIVE"); - Wrap(b, "Games that have stopped answering. Nothing was deleted. Still probed weekly, and " - + "one successful probe puts a game back in the listing."); + b.AppendLine(Say(tag, "archive.title").ToUpperInvariant()); + Wrap(b, Say(tag, "archive.lede")); b.AppendLine(); - b.AppendLine($"{entries.Count} game(s)" - + (string.IsNullOrWhiteSpace(query) ? string.Empty : $" matching \"{query}\"")); + b.AppendLine(string.IsNullOrWhiteSpace(query) + ? Say(tag, "archive.plain.count", ("count", entries.Count)) + : Say(tag, "archive.plain.matching", ("count", entries.Count), ("query", query))); b.AppendLine(); + string[] labels = + [ + Say(tag, "archive.plain.lastReachable"), + Say(tag, "archive.plain.knownLive"), + Say(tag, "archive.plain.run"), + Say(tag, "archive.plain.codebase"), + ]; + + var width = Math.Max(17, labels.Max(l => l.Length) + 1); + var badge = Say(tag, "archive.badge"); + foreach (var entry in entries) { - b.AppendLine($"{entry.Summary.Name} [archived]"); - b.AppendLine($" /g/{entry.Summary.Slug}"); - b.AppendLine($" Last reachable: {entry.LastAnswered} ({entry.DarkFor(now)} ago)"); - b.AppendLine($" Known live: {entry.KnownLiveWording} of measured reachable time"); - - if (entry.Run is { } run) + b.AppendLine($"{entry.Summary.Name} [{badge}]"); + b.AppendLine($" {Path(tag, $"/g/{entry.Summary.Slug}")}"); + b.AppendLine($" {labels[0].PadRight(width)}{entry.LastAnswered(tag)} " + + Say(tag, "archive.darkFor", ("age", entry.DarkFor(tag, now)))); + b.AppendLine($" {labels[1].PadRight(width)}" + + Say(tag, "archive.plain.knownLiveValue", ("value", entry.KnownLiveWording(tag)))); + + if (entry.Run(tag) is { } run) { - b.AppendLine($" Run: {run}"); + b.AppendLine($" {labels[2].PadRight(width)}{run}"); } // Labelled here above all. This is where a value is oldest — nobody has confirmed an @@ -556,7 +701,7 @@ public static string RenderArchive(IReadOnlyList entries, string? if (entry.Summary.Codebase is { } codebase) { b.AppendLine( - $" Codebase: {codebase} {Label(entry.Summary.CodebaseProvenance, now)}" + $" {labels[3].PadRight(width)}{codebase} {Label(tag, entry.Summary.CodebaseProvenance, now)}" .TrimEnd()); } @@ -565,7 +710,7 @@ public static string RenderArchive(IReadOnlyList entries, string? if (entries.Count == 0) { - b.AppendLine("Nothing matched."); + b.AppendLine(Say(tag, "archive.empty")); } return b.ToString(); @@ -579,11 +724,16 @@ public static string RenderArchive(IReadOnlyList entries, string? /// 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) + public static string RenderAbout(AboutPage page, string tag = Locales.SourceTag) { + ArgumentNullException.ThrowIfNull(page); + var b = new StringBuilder(); - b.AppendLine("ABOUT MU*INDEX"); + // Upper-cased from the translated title rather than typed in capitals, so this surface keeps + // its own shape in a language that has one. A locale whose script has no case is unchanged, + // which is correct: the shape is English typography and the words are not. + b.AppendLine(Say(tag, "about.title").ToUpperInvariant()); b.AppendLine(); Wrap(b, page.Lede); @@ -600,22 +750,21 @@ public static string RenderAbout(AboutPage page) if (section.Identity is { } identity) { b.AppendLine(); - Wrap(b, identity.Wording, " "); + Wrap(b, identity.Wording(tag), " "); b.AppendLine(); - Wrap(b, $"Crawler: {identity.Name}", " "); - Wrap(b, $"Contact: {identity.InfoUrl}", " "); + Wrap(b, Say(tag, "about.identity.crawler.line", ("name", identity.Name)), " "); + Wrap(b, Say(tag, "about.identity.contact.line", ("url", identity.InfoUrl)), " "); if (!identity.ContactConfigured) { - Wrap(b, "No contact address is configured, so the one above is a placeholder " - + "and answers nobody.", " "); + Wrap(b, Say(tag, "about.identity.placeholder.plain"), " "); } } foreach (var source in section.Sources) { b.AppendLine(); - b.AppendLine($" {source.Name} — {source.StatusWording}"); + b.AppendLine($" {source.Name} — {source.StatusWording(tag)}"); b.AppendLine($" {source.Url}"); Wrap(b, source.Note, " "); } @@ -626,16 +775,16 @@ public static string RenderAbout(AboutPage page) // 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}", " "); + Wrap(b, Say(tag, "about.licence.code.line", ("licence", licence.CodeLicence)), " "); + Wrap(b, Say(tag, "about.licence.data.line", ("licence", 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}", " "); + Wrap(b, Say(tag, "about.licence.deployment"), " "); + Wrap(b, Say(tag, "about.licence.credit.line", ("credit", licence.Attribution)), " "); b.AppendLine(); Wrap(b, licence.Notice, " "); } @@ -652,37 +801,39 @@ public static string RenderAbout(AboutPage page) /// 122 of 310 (39.4%)" is the fact, and the bar is a way of seeing several of them at once. /// Nothing is lost here but the seeing-at-once, which is the test §9 sets for a graphic. /// - public static string RenderEcosystem(EcosystemDashboard dashboard, DateTimeOffset now) + public static string RenderEcosystem( + EcosystemDashboard dashboard, DateTimeOffset now, string tag = Locales.SourceTag) { ArgumentNullException.ThrowIfNull(dashboard); var b = new StringBuilder(); - b.AppendLine("THE ECOSYSTEM"); - Wrap(b, EcosystemCopy.NoTotals); + b.AppendLine(Say(tag, "ecosystem.title").ToUpperInvariant()); + Wrap(b, EcosystemCopy.NoTotals(tag)); b.AppendLine(); - Wrap(b, $"{dashboard.ListedGames} games listed · " - + $"{EcosystemCopy.Handshakes(dashboard.Handshakes)} · " - + $"{EcosystemCopy.MsspReports(dashboard.MsspReports)}."); + Wrap(b, Say(tag, "ecosystem.plain.counts", + ("listed", EcosystemCopy.Listed(tag, dashboard.ListedGames)), + ("handshakes", EcosystemCopy.Handshakes(tag, dashboard.Handshakes)), + ("mssp", EcosystemCopy.MsspReports(tag, dashboard.MsspReports)))); if (dashboard.OldestHandshake is { } oldest) { - Wrap(b, "The oldest handshake in this picture was last confirmed " - + $"{Relative.Format(now - oldest)} ago."); + Wrap(b, Say(tag, "ecosystem.plain.oldestHandshake", + ("age", Relative.Format(tag, now - oldest)))); } - Heading(b, "CODEBASES"); - Wrap(b, EcosystemCopy.CodebaseBasis(dashboard.Codebases)); + Heading(b, Say(tag, "ecosystem.codebases.title").ToUpperInvariant()); + Wrap(b, EcosystemCopy.CodebaseBasis(tag, dashboard.Codebases)); b.AppendLine(); foreach (var family in dashboard.Codebases.Shared) { - b.AppendLine($" {family.Label,-24} {EcosystemCopy.Share(family)}"); + b.AppendLine($" {family.Label,-24} {EcosystemCopy.Share(tag, family)}"); } if (dashboard.Codebases.Families.Count == 0) { - b.AppendLine(" No listed game has told us its codebase yet."); + b.AppendLine(" " + Say(tag, "ecosystem.codebases.none")); } // The graphic folds these behind a disclosure and this surface has none, so it prints them @@ -691,7 +842,7 @@ public static string RenderEcosystem(EcosystemDashboard dashboard, DateTimeOffse if (dashboard.Codebases.SoleUse.Count > 0) { b.AppendLine(); - Wrap(b, EcosystemCopy.SoleUse(dashboard.Codebases), " "); + Wrap(b, EcosystemCopy.SoleUse(tag, dashboard.Codebases), " "); b.AppendLine(); foreach (var alone in dashboard.Codebases.SoleUse) @@ -701,39 +852,37 @@ public static string RenderEcosystem(EcosystemDashboard dashboard, DateTimeOffse } - Heading(b, "LINEAGES"); - Wrap(b, "The same games, grouped by the tradition their server descends from. This is " - + $"{FacetWords.Evidence(FacetEvidence.Derived)} — " - + $"{FacetWords.EvidenceMeaning(FacetEvidence.Derived)} — and not anything a game " - + "published: no game reports \"MUSH\", because MSSP has no such value and most of the " - + "MUSH world publishes no MSSP at all."); + Heading(b, Say(tag, "ecosystem.lineages.title").ToUpperInvariant()); + Wrap(b, Say(tag, "ecosystem.plain.lineages", + ("evidence", FacetWords.Evidence(tag, FacetEvidence.Derived)), + ("meaning", FacetWords.EvidenceMeaning(tag, FacetEvidence.Derived)))); b.AppendLine(); foreach (var lineage in dashboard.Codebases.Lineages) { - b.AppendLine($" {lineage.Label,-24} {EcosystemCopy.Share(lineage)}"); + b.AppendLine($" {lineage.Label,-24} {EcosystemCopy.Share(tag, lineage)}"); } if (dashboard.Codebases.Lineages.Count == 0) { - b.AppendLine(" No listed game runs a codebase we place in a lineage yet."); + b.AppendLine(" " + Say(tag, "ecosystem.lineages.none")); } if (dashboard.Codebases.NotClassified > 0) { b.AppendLine(); - Wrap(b, $"{dashboard.Codebases.NotClassified} of those game(s) run a codebase we do not " - + "place in any lineage — several publish FAMILY Custom, saying so themselves. They " - + "are inside the denominator above and in nobody's share.", " "); + Wrap(b, Say(tag, "ecosystem.lineages.notClassified", + ("count", dashboard.Codebases.NotClassified), + ("family", EcosystemCopy.CustomFamily)), " "); } - Heading(b, "PROTOCOLS"); - Wrap(b, EcosystemCopy.Floor); + Heading(b, Say(tag, "ecosystem.protocols.title").ToUpperInvariant()); + Wrap(b, EcosystemCopy.Floor(tag)); if (dashboard.Mssp is { } mssp) { b.AppendLine(); - Wrap(b, EcosystemCopy.MsspBasis(mssp, dashboard.MsspReports)); + Wrap(b, EcosystemCopy.MsspBasis(tag, mssp, dashboard.MsspReports)); } b.AppendLine(); @@ -741,19 +890,21 @@ public static string RenderEcosystem(EcosystemDashboard dashboard, DateTimeOffse foreach (var protocol in dashboard.Protocols) { b.AppendLine($" {protocol.Protocol}"); - Wrap(b, $"measured: {EcosystemCopy.Measured(protocol)}", " "); - Wrap(b, $"declared: {EcosystemCopy.Declared(protocol)}", " "); + Wrap(b, Say(tag, "ecosystem.plain.measured", + ("value", EcosystemCopy.Measured(tag, protocol))), " "); + Wrap(b, Say(tag, "ecosystem.plain.declared", + ("value", EcosystemCopy.Declared(tag, protocol))), " "); } b.AppendLine(); - Wrap(b, $"Measured is of {EcosystemCopy.Handshakes(dashboard.Handshakes)}; declared is of " - + $"{EcosystemCopy.MsspReports(dashboard.MsspReports)}. Two sets of games, so two " - + "denominators."); + Wrap(b, Say(tag, "ecosystem.plain.denominators", + ("measured", EcosystemCopy.Handshakes(tag, dashboard.Handshakes)), + ("declared", EcosystemCopy.MsspReports(tag, dashboard.MsspReports)))); - Heading(b, "A SNAPSHOT, NOT A CURVE"); - Wrap(b, EcosystemCopy.NoCurve); + Heading(b, Say(tag, "ecosystem.snapshot.title").ToUpperInvariant()); + Wrap(b, EcosystemCopy.NoCurve(tag)); b.AppendLine(); - Wrap(b, EcosystemCopy.Transitions(dashboard.CapabilityTransitions)); + Wrap(b, EcosystemCopy.Transitions(tag, dashboard.CapabilityTransitions)); return b.ToString(); } @@ -768,13 +919,14 @@ public static string RenderEcosystem(EcosystemDashboard dashboard, DateTimeOffse /// around it — what happens to an address, and what happened to the last one — which is the part /// that could otherwise have been carried by layout. /// - public static string RenderSubmit(SubmitAnswer? answer, bool hasCatalogue) + public static string RenderSubmit( + SubmitAnswer? answer, bool hasCatalogue, string tag = Locales.SourceTag) { var b = new StringBuilder(); - b.AppendLine(SubmitCopy.Title.ToUpperInvariant()); + b.AppendLine(SubmitCopy.Title(tag).ToUpperInvariant()); b.AppendLine(); - Wrap(b, SubmitCopy.Lede); + Wrap(b, SubmitCopy.Lede(tag)); if (answer is not null) { @@ -783,20 +935,20 @@ public static string RenderSubmit(SubmitAnswer? answer, bool hasCatalogue) if (answer.Link is { } link) { - b.AppendLine($" {link.Label}: {link.Href}"); + b.AppendLine($" {link.Label}: {Path(tag, link.Href)}"); } } if (!hasCatalogue) { - Heading(b, "NOT HERE"); - Wrap(b, SubmitCopy.NoCatalogue); + Heading(b, Say(tag, "submit.notHere").ToUpperInvariant()); + Wrap(b, SubmitCopy.NoCatalogue(tag)); return b.ToString(); } - Heading(b, "WHAT HAPPENS TO AN ADDRESS"); + Heading(b, Say(tag, "submit.what.heading").ToUpperInvariant()); - foreach (var point in SubmitCopy.Points) + foreach (var point in SubmitCopy.Points(tag)) { b.AppendLine(); Wrap(b, point, " "); @@ -805,41 +957,190 @@ public static string RenderSubmit(SubmitAnswer? answer, bool hasCatalogue) return b.ToString(); } + /// + /// The find-a-game questions, as text — the same six, in the same words, with the same counts. + /// + /// + /// + /// Built from the same the rendered page draws, which is the + /// whole of the fix here. This was a different page: it dumped ten facet groups as querystring + /// recipes while the rendered page asked six questions, and the two disagreed about what could + /// be asked at all — plain offered the silent bucket the rendered page hid. A text mirror + /// showing a different set of facts is not a mirror. + /// + /// + /// The addresses differ from the rendered page's only because the querystring they are built + /// from carries plain=1, so following one stays in this surface. That falls out of the + /// construction rather than being arranged: every link on both surfaces is the page's own URL + /// with one parameter changed. + /// + /// + public static string RenderFind(FindScreen screen, string? tag = null) + { + ArgumentNullException.ThrowIfNull(screen); + + var locale = tag ?? Locales.SourceTag; + var b = new StringBuilder(); + + // Upper-cased from the translated word rather than typed in capitals, so a locale that + // has this page still gets the surface's own shape. Everything below is read off the + // screen, which was built for this same locale — there is no second translation here. + b.AppendLine(Say(locale, "find.title").ToUpperInvariant()); + + if (screen.Error is { } problem) + { + // Refused rather than ignored, in the same words the rendered page refuses it with. + Heading(b, Say(locale, "find.refused").ToUpperInvariant()); + Wrap(b, problem, " "); + b.AppendLine(); + b.AppendLine(" " + Path(locale, "/find?plain=1")); + return b.ToString(); + } + + Heading(b, Say(locale, "find.kicker").ToUpperInvariant()); + Wrap(b, Say(locale, "find.matching", ("count", screen.Matching)), " "); + Wrap(b, Say( + locale, + "find.basis", + ("listed", screen.Listed), + ("answers", screen.Answers.Count)), " "); + + if (screen.Matching > 0) + { + b.AppendLine(); + b.AppendLine(" " + Say(locale, "find.show", ("count", screen.Matching))); + b.AppendLine(" " + Path(locale, screen.ShowHref)); + } + + if (screen.Loosen is { } loosen) + { + b.AppendLine(); + b.AppendLine(" " + Say( + locale, "find.drop", ("answer", loosen.Label), ("count", loosen.Count))); + b.AppendLine(" " + Path(locale, loosen.Href)); + } + + if (screen.Answers.Count > 0) + { + Heading(b, Say(locale, "find.answersGiven").ToUpperInvariant()); + + foreach (var chip in screen.Answers) + { + b.AppendLine($" {chip.Label}"); + b.AppendLine($" {Say(locale, "find.clear", ("question", chip.Question))}"); + b.AppendLine($" {Path(locale, chip.ClearHref)}"); + } + + b.AppendLine(); + b.AppendLine(" " + Say(locale, "find.clearAll")); + b.AppendLine(" " + Path(locale, screen.ClearHref)); + } + + foreach (var question in screen.Questions) + { + Heading( + b, + question.Text.ToUpperInvariant() + + $" ({FacetWords.Evidence(locale, question.Evidence)})"); + + if (question.Any is { } any) + { + Answer(b, locale, any); + } + + // The tail is written out in full. There is no folding here and there should not be: + // a disclosure is a graphical economy, and the guarantee this surface carries is that + // every option the page offers can be reached from it. + foreach (var option in question.Options.Concat(question.Tail)) + { + Answer(b, locale, option); + } + } + + Heading(b, Say(locale, "find.wholeListing").ToUpperInvariant()); + b.AppendLine(" " + Path(locale, "/games?plain=1")); + + return b.ToString(); + } + + /// + /// One answer: whether it is the one in force, what it is called, what choosing it returns. + /// + /// + /// The state is a pair of characters and never a colour or an indent, and the count is in the + /// same parentheses the rendered page puts it in a column — so the two surfaces can be read + /// against each other line for line. + /// + private static void Answer(StringBuilder b, string tag, FindOption option) + { + var mark = $" [{(option.IsChosen ? "x" : " ")}] "; + var text = $"{option.Label} ({option.Count})"; + + if (mark.Length + text.Length <= Columns) + { + b.AppendLine(mark + text); + } + else + { + // A label too long for the line keeps its checkbox on the first line and hangs its + // continuation under the label rather than under the box, so a wrapped option cannot be + // read as a second one. The labels that reach this are the catalogue's own — a genre, a + // language, a lineage — and nothing bounds their length but the games. + var wrapped = new StringBuilder(); + Wrap(wrapped, text, new string(' ', mark.Length)); + + b.Append(mark).Append(wrapped.ToString().AsSpan(mark.Length)); + } + + // The address is never wrapped: see the note on Columns. + b.AppendLine($" {Path(tag, option.Href)}"); + } + + private static string Say(string tag, string id, params (string Key, object? Value)[] args) => + Messages.For(tag, id, args.ToDictionary(a => a.Key, a => a.Value, StringComparer.Ordinal)); + /// The rankings, with every basis in the same words the rendered page uses. - public static string RenderRankings(Rankings rankings, DateTimeOffset now) + public static string RenderRankings( + Rankings rankings, DateTimeOffset now, string tag = Locales.SourceTag) { ArgumentNullException.ThrowIfNull(rankings); var b = new StringBuilder(); + var days = (int)rankings.Window.TotalDays; - b.AppendLine("RANKINGS"); - Wrap(b, EcosystemCopy.NoVote); + b.AppendLine(Say(tag, "rankings.title").ToUpperInvariant()); + Wrap(b, EcosystemCopy.NoVote(tag)); - Heading(b, $"BUSIEST — median measured players, last {(int)rankings.Window.TotalDays} days"); - Wrap(b, EcosystemCopy.BusiestBasis(rankings)); + Heading(b, Say(tag, "rankings.plain.busiest", ("days", days)).ToUpperInvariant()); + Wrap(b, EcosystemCopy.BusiestBasis(tag, rankings)); b.AppendLine(); - Wrap(b, EcosystemCopy.SpanChoice); + Wrap(b, EcosystemCopy.SpanChoice(tag)); b.AppendLine(); // The three windows as addresses, because a text browser cannot use a tab strip and the // choice must survive here or the graphical selector is decoration (spec §9). One per line: // the addresses do not fit on one inside eighty columns, and a wrapped URL is not clickable // in the browsers this surface exists for. - b.AppendLine(" windows:"); + b.AppendLine(" " + Say(tag, "rankings.plain.windows")); + + // The width the three labels are padded to is measured rather than assumed: "7 days" is + // six columns and its German is nine, and a hard -8 would have run the address into it. + var labels = RankingSpans.All.ToDictionary(s => s, s => EcosystemCopy.SpanLabel(tag, s)); + var width = labels.Values.Max(l => l.Length); + var here = Say(tag, "rankings.plain.thisOne"); foreach (var span in RankingSpans.All) { b.AppendLine(span == rankings.Span - ? $" [{EcosystemCopy.SpanLabel(span),-8}] this one" - : $" {EcosystemCopy.SpanLabel(span),-8} /rankings?window={span.Slug()}&plain=1"); + ? $" [{labels[span].PadRight(width)}] {here}" + : $" {labels[span].PadRight(width)} {Path(tag, $"/rankings?window={span.Slug()}&plain=1")}"); } b.AppendLine(); if (rankings.Busiest.Count == 0) { - Wrap(b, "No listed game has enough counted samples to rank yet — a statement about how " - + "long we have been measuring, not about how busy anybody is.", " "); + Wrap(b, Say(tag, "rankings.busiest.empty"), " "); } var place = 0; @@ -848,18 +1149,21 @@ public static string RenderRankings(Rankings rankings, DateTimeOffset now) { place++; b.AppendLine($" {place,3} {game.Name}"); - b.AppendLine($" median {game.Median} · peak {game.Peak} · " - + $"{game.Samples} counted samples over {game.Days} of " - + $"{(int)rankings.Window.TotalDays} days · /g/{game.Slug}"); + Wrap(b, Say(tag, "rankings.plain.row", + ("median", game.Median), + ("peak", game.Peak), + ("samples", game.Samples), + ("days", game.Days), + ("window", days)) + $" · {Path(tag, $"/g/{game.Slug}")}", " "); } - Heading(b, "LONGEST UNBROKEN REACHABLE SPELL"); - Wrap(b, EcosystemCopy.SpellBasis); + Heading(b, Say(tag, "rankings.spells.title").ToUpperInvariant()); + Wrap(b, EcosystemCopy.SpellBasis(tag)); b.AppendLine(); if (rankings.LongestUnbroken.Count == 0) { - Wrap(b, "No listed game is in an unbroken reachable spell right now.", " "); + Wrap(b, Say(tag, "rankings.spells.empty"), " "); } place = 0; @@ -868,8 +1172,9 @@ public static string RenderRankings(Rankings rankings, DateTimeOffset now) { place++; b.AppendLine($" {place,3} {spell.Name}"); - b.AppendLine($" reachable on every probe since {spell.Since:d MMMM yyyy} · " - + $"{Wording.Duration(spell.LengthAt(now))} · /g/{spell.Slug}"); + Wrap(b, Say(tag, "rankings.plain.spellRow", + ("date", Dates.Absolute(tag, spell.Since)), + ("duration", Wording.Duration(spell.LengthAt(now)))) + $" · {Path(tag, $"/g/{spell.Slug}")}", " "); } return b.ToString(); @@ -909,7 +1214,16 @@ internal static void Wrap(StringBuilder b, string text, string indent = "") } } - /// Colour is never the only carrier of a state, and here there is no colour at all. + /// + /// Colour is never the only carrier of a state, and here there is no colour at all. + /// + /// + /// The one thing on this surface left in English on purpose. These are a two-column tabular + /// token in a fixed-width table eighty columns wide, read the way a flag in ls -l is, + /// and the row's own labels beside them are translated — so what a reader meets is a + /// German sentence naming a machine token, which is the arrangement every other machine value + /// on this site already has. A four-syllable translation would push the row past eighty. + /// private static string Word(CapabilityState state) => state switch { CapabilityState.Present => "yes", diff --git a/src/MUI.Web/Components/PresenceTrend.razor b/src/MUI.Web/Components/PresenceTrend.razor index 08aecb5..4fc0e1d 100644 --- a/src/MUI.Web/Components/PresenceTrend.razor +++ b/src/MUI.Web/Components/PresenceTrend.razor @@ -1,3 +1,5 @@ +@using MUI.Web.Localization + @* The presence trend: measured players per day, over a calendar range the reader can move. @@ -22,8 +24,8 @@ under the graphic uses rather than through a second implementation that could disagree. *@ -@Series.Sentence -days in UTC · @Series.From.ToString("d MMM yyyy") – @Series.To.ToString("d MMM yyyy") +@Sentence +@L("trend.range", ("from", Series.From), ("to", Series.To)) @if (Series.HasAnyCount) { @@ -31,12 +33,12 @@ @* One image with one label. Every day is announced through the disclosure below rather than as ninety focusable shapes, which is the same choice the availability strip makes. *@ + role="img" aria-label="@Sentence"> @* The plot is a surface rather than a hole in the page. Without it ninety unmeasured days are the page's own background, and the graphic reads as a fault. *@ - @foreach (var month in TrendGeometry.Months(Series)) + @foreach (var month in TrendGeometry.Months(Tag, Series)) { @@ -57,12 +59,12 @@ @if (bar.HasCap) { - @bar.Day.Label + @bar.Day.Label(Tag) } - @bar.Day.Label + @bar.Day.Label(Tag) } } @@ -81,7 +83,7 @@ @* A day with no measured neighbour, which no path can carry. Bigger than the mark a line would give it and ringed in the surface colour, because on a sparsely probed game these dots are the whole chart. *@ - @foreach (var dot in TrendGeometry.Dots(Series)) + @foreach (var dot in TrendGeometry.Dots(Tag, Series)) { @dot.Label @@ -90,7 +92,7 @@ } @* The hatched state, in its own gutter under the baseline. *@ - @foreach (var tick in TrendGeometry.Ticks(Series)) + @foreach (var tick in TrendGeometry.Ticks(Tag, Series)) { @@ -105,7 +107,7 @@ { - @column.Day.Label + @column.Day.Label(Tag) } @@ -113,40 +115,42 @@ @* Positioned by the same fractions the rules are drawn at, and outside the SVG because the canvas is stretched to the page width — text inside it would be stretched with it. *@ - @foreach (var month in TrendGeometry.Months(Series)) + @foreach (var month in TrendGeometry.Months(Tag, Series)) { @month.Label } - @Series.Ceiling at the top - @Series.CountedDays of @Series.Days.Count days counted + @L("trend.ceiling", ("value", Series.Ceiling)) + @L("trend.counted", ("counted", Series.CountedDays), ("days", Series.Days.Count)) @if (Shape is TrendShape.Bar) { - mean of the counts we read that day - up to the busiest count that day + @L("trend.legend.mean") + @L("trend.legend.peak") } else { - mean of the counts we read that day - lowest to highest count that day + @L("trend.legend.mean") + @L("trend.legend.band") } - probed, no count could be read + @L("trend.legend.notCounted") + @* The whole clause is the id, not "not measured" plus a dash and a fragment: what + follows the dash is a fact about the drawing and moves inside the sentence. *@ - @(Shape is TrendShape.Bar ? "not measured — no bar at all" : "not measured — a break in the line") + @L(Shape is TrendShape.Bar ? "trend.legend.notMeasured.bar" : "trend.legend.notMeasured.line") - read as text + @L("a11y.readAsText") - @foreach (var line in Series.PerWeek()) + @foreach (var line in Series.PerWeek(Tag)) { @line } @@ -158,7 +162,7 @@ else @* No cause given, and none available: a failed probe writes no presence row at all, so an empty range covers days we could not reach and days we never probed alike. Naming one of them would be recording a decision of ours as a measurement of theirs. *@ - Nothing counted in this range. + @L("trend.empty") } @code { @@ -167,6 +171,24 @@ else /// Which drawing of the same numbers — see for why there are two. [Parameter] public TrendShape Shape { get; set; } = TrendShape.Line; + /// The request, for the locale its ninety tooltips are written in. + [CascadingParameter] private HttpContext? Http { get; set; } + + private string Tag => Http.LocaleOf().Tag; + + /// + /// The chart said in words. Rendered once and used twice — as the paragraph above the drawing + /// and as the drawing's own label — because they are one sentence and a second call would be a + /// second chance for them to differ. + /// + private string Sentence => Series.Sentence(Tag); + + private string L(string id) => Messages.For(Tag, id); + + /// This component's tag, supplied once — the rendering is . + private string L(string id, params (string Key, object? Value)[] args) => + Messages.Say(Tag, id, args); + private static string Box => $"{N(TrendGeometry.Width)} {N(TrendGeometry.Height)}"; diff --git a/src/MUI.Web/Components/PreviewCopy.cs b/src/MUI.Web/Components/PreviewCopy.cs index 5676840..79924d2 100644 --- a/src/MUI.Web/Components/PreviewCopy.cs +++ b/src/MUI.Web/Components/PreviewCopy.cs @@ -1,6 +1,7 @@ using System.Globalization; using MUI.Catalog; +using MUI.Web.Localization; namespace MUI.Web.Components; @@ -23,20 +24,33 @@ namespace MUI.Web.Components; /// be in the metadata itself, which is what is for — otherwise a pasted demo /// link is indistinguishable from a measured one in the context where a reader can check least. /// +/// +/// And a preview localizes, because a page does. These were English constants passed to +/// SitePreview from every page, so a German page had a German body and an English +/// <title>, description and Open Graph card — it advertised itself in a language its +/// reader had not chosen, to a reader, a search engine and every client that unfurls a pasted link. +/// A sweep that walks visible text and title/aria-label/placeholder never sees +/// a <meta>, which is why this outlived four locales shipping. +/// /// public static class PreviewCopy { - /// The wordmark, as an unfurler prints it beside the title. + /// + /// The wordmark, as an unfurler prints it beside the title. + /// + /// + /// Not a message id and never one. The site's name is machine voice, like a hostname or a + /// codebase string; it is handed to the bundle as an argument so a translator can move it + /// within a sentence and cannot translate it. + /// public const string SiteName = "mu*index"; /// What the site is, in one sentence, for every page that has nothing better to say. - public const string Site = - "A directory of the MU* hobby — MUSHes, MUDs, MUCKs, MOOs — where every fact was measured " - + "by a crawler and carries how it was obtained and how old it is."; + public static string Site(string tag) => Messages.For(tag, "preview.site"); /// Prefixed over the fixture, on every surface no banner reaches. - public static string Demo(string description) => - $"Demo data — nothing here was measured. {description}"; + public static string Demo(string tag, string description) => + Messages.For(tag, "preview.demo", Args(("description", description))); /// /// One sentence per surface, kept together rather than inline in fifteen components. @@ -44,42 +58,63 @@ public static string Demo(string description) => /// /// A description repeated across a site is one a search engine discards, and a page with none /// is summarised from whatever text happens to come first in its body — which on this site is - /// the demo banner or a facet panel. They live here for the same reason EcosystemCopy and - /// SubmitCopy exist: prose that has to stay consistent is easier to keep consistent when - /// it is in one file, and easier to correct by somebody reading it as prose. + /// the demo banner or a facet panel. These are the ids rather than the sentences now — the text + /// is in Messages with the rest of the site's copy — and they stay gathered here so that + /// a page names one thing and so that the set can still be read as prose in one place. /// public static class Pages { - public const string Games = - "Every MU* we have reached, faceted on what we measured: codebase, the protocols a " - + "server offered in the handshake, TLS, charset, language, and when we last got in."; + public static string Games(string tag) => Messages.For(tag, "preview.desc.games"); + + public static string Archive(string tag) => Messages.For(tag, "preview.desc.archive"); + + public static string Rankings(string tag) => Messages.For(tag, "preview.desc.rankings"); + + public static string Ecosystem(string tag) => Messages.For(tag, "preview.desc.ecosystem"); + + public static string Reference(string tag) => Messages.For(tag, "preview.desc.reference"); + + public static string About(string tag) => Messages.For(tag, "preview.desc.about"); + + public static string NotFound(string tag) => Messages.For(tag, "preview.desc.notFound"); + + public static string Random(string tag) => Messages.For(tag, "preview.desc.random"); + + public static string Account(string tag) => Messages.For(tag, "preview.desc.account"); + + public static string Claim(string tag) => Messages.For(tag, "preview.desc.claim"); + } + + /// The title each page puts before the wordmark. + /// + /// Separate ids from the <h1> the same page draws, even where the English is the + /// same word. A title is a noun phrase in a browser tab and a heading is the first line of a + /// document; the languages that decline the two differently have nowhere to stand if they share + /// an id, which is S7's whole argument for granularity the source language does not need. + /// + public static class Titles + { + public static string Games(string tag) => Messages.For(tag, "preview.title.games"); - public const string Archive = - "The games that went dark, kept. Each keeps its page, history and URL, is still " - + "probed weekly, and returns to the listing on one successful connection."; + public static string Archive(string tag) => Messages.For(tag, "preview.title.archive"); - public const string Rankings = - "Busiest, most reachable, longest running — computed from measurements only. No " - + "votes, stars or ratings anywhere on this site."; + public static string Rankings(string tag) => Messages.For(tag, "preview.title.rankings"); - public const string Ecosystem = - "Codebase share and protocol adoption across the games we measure, with what servers " - + "offer set beside what they declare. Shares, never totals."; + public static string Ecosystem(string tag) => Messages.For(tag, "preview.title.ecosystem"); - public const string Reference = - "Hand-written pages on the codebases, clients and protocols of the MU* hobby, " - + "cross-linked to counts taken from the crawl."; + public static string Reference(string tag) => Messages.For(tag, "preview.title.reference"); - public const string About = - "How this catalogue is built: what the crawler does, what it refuses to do, and how " - + "to make it stop."; + public static string About(string tag) => Messages.For(tag, "preview.title.about"); - public const string NotFound = - "No game at this address. Nothing here is ever deleted, so a game that once lived at " - + "this URL still does — check the spelling."; + public static string NotFound(string tag) => Messages.For(tag, "preview.title.notFound"); - public const string Random = - "One game from the catalogue, chosen at random and never the same one twice."; + public static string Random(string tag) => Messages.For(tag, "preview.title.random"); + + public static string Account(string tag) => Messages.For(tag, "preview.title.account"); + + /// A claim page names its game, which is the game's own bytes and not ours. + public static string Claim(string tag, string game) => + Messages.For(tag, "preview.title.claim", Args(("game", game))); } /// @@ -90,8 +125,9 @@ public static class Pages /// connect. An unfurler truncates, so the ordering is the design — the address survives being /// cut, and the sentence that identifies the game does not. /// - public static string ForGame(GamePage page, DateTimeOffset now) + public static string ForGame(string tag, GamePage page, DateTimeOffset now) { + ArgumentNullException.ThrowIfNull(tag); ArgumentNullException.ThrowIfNull(page); var summary = page.Summary; @@ -108,10 +144,13 @@ public static string ForGame(GamePage page, DateTimeOffset now) // one. What matters is that it went dark and roughly when — and §7.4's promise that it // is still here, which is the thing no incumbent directory can say. sentences.Add(summary.LastReachableAt is { } last - ? $"Archived — last reachable {Relative.Ago(now - last)}, and still probed" - : "Archived, and still probed"); + ? Messages.For( + tag, + "preview.game.archived", + Args(("age", Relative.Ago(tag, now - last, AgeSense.Reached)))) + : Messages.For(tag, "preview.game.archived.undated")); } - else if (Count(summary, now) is { } count) + else if (Count(tag, summary, now) is { } count) { sentences.Add(count); } @@ -122,7 +161,7 @@ public static string ForGame(GamePage page, DateTimeOffset now) } return sentences.Count == 0 - ? Site + ? Site(tag) : string.Join(" ", sentences.Select(Terminated)); } @@ -135,15 +174,13 @@ public static string ForGame(GamePage page, DateTimeOffset now) /// preview states our parser's limit as a fact about their game (rule 4, rule 5). A measured /// zero is the opposite case and is published: we got in and nobody was there. /// - private static string? Count(GameSummary summary, DateTimeOffset now) + private static string? Count(string tag, GameSummary summary, DateTimeOffset now) { if (summary.PlayersNow is not { } players) { - return "Player count unknown — the game answers, and publishes no number we can read"; + return Messages.For(tag, "preview.game.countUnknown"); } - var word = players == 1 ? "player" : "players"; - if (summary.PlayersNowProvenance is not { } chip) { // A value with no chip is a value nobody labelled, and the whole site's claim is that @@ -152,7 +189,16 @@ public static string ForGame(GamePage page, DateTimeOffset now) return null; } - return $"{players} {word}, {Provenance.How(chip)} {Relative.Ago(now - chip.LastConfirmedAt)}"; + // The noun agrees with the count inside the message rather than being chosen here: picking + // "player" or "players" in C# is an English plural rule compiled into a component, and the + // languages with three forms have nowhere to put the other two. + return Messages.For( + tag, + "preview.game.count", + Args( + ("count", players), + ("how", Provenance.How(tag, chip)), + ("age", Relative.Ago(tag, now - chip.LastConfirmedAt)))); } /// Where you connect, which is the fact a reader most often wanted the page for. @@ -207,12 +253,22 @@ private static string Terminated(string sentence) => : sentence + "."; /// The alt text on a preview card, for a reader whose client reads it out. - public static string CardAlt(string? title) => + public static string CardAlt(string tag, string? title) => title is null - ? $"{SiteName} — measured, not asserted" - : $"{title} on {SiteName}"; + ? Messages.For(tag, "preview.cardAlt", Args(("site", SiteName))) + : Messages.For(tag, "preview.cardAlt.named", Args(("title", title), ("site", SiteName))); /// The document title, with the wordmark the site is known by. - public static string Title(string? page) => - page is null ? SiteName : $"{page} — {SiteName}"; + /// + /// The front page is the bare wordmark and goes nowhere near the bundle: there is no sentence + /// there, only the name. Every other page is a page name joined to that name, and the joining + /// is a message because the order and the separator are not English's to fix. + /// + public static string Title(string tag, string? page) => + page is null + ? SiteName + : Messages.For(tag, "preview.documentTitle", Args(("page", page), ("site", SiteName))); + + private static Dictionary Args(params (string Key, object? Value)[] args) => + args.ToDictionary(a => a.Key, a => a.Value, StringComparer.Ordinal); } diff --git a/src/MUI.Web/Components/Provenance.cs b/src/MUI.Web/Components/Provenance.cs index b1505a9..5aff501 100644 --- a/src/MUI.Web/Components/Provenance.cs +++ b/src/MUI.Web/Components/Provenance.cs @@ -1,9 +1,11 @@ using MUI.Catalog; +using MUI.Web.Localization; + namespace MUI.Web.Components; /// -/// How we know a value, in one word. +/// How we know a value, in one word — and what carried it to us, in one name. /// /// /// @@ -22,15 +24,62 @@ namespace MUI.Web.Components; public static class Provenance { /// Measured, owner-declared, or declared — never two of those collapsed into one. - public static string How(ProvenanceChip chip) + public static string How(string tag, ProvenanceChip chip) { + ArgumentNullException.ThrowIfNull(tag); ArgumentNullException.ThrowIfNull(chip); - return chip switch + return Messages.For(tag, chip switch + { + { IsMeasured: true } => "provenance.game.measured", + { Source: FieldSource.Owner } => "provenance.game.ownerDeclared", + _ => "provenance.game.declared", + }); + } + + /// + /// What carried the value to us, as a reader should see it written. + /// + /// + /// + /// A display name is not an enum member, and the difference reached a reader. The chip's + /// tooltip interpolated directly and said a value arrived "via Mssp": + /// an acronym mis-cased by C#'s naming convention, in a sentence no translator could reach, + /// because the word was never in a file they are sent. Upper-casing it at the call site would + /// have fixed the four letters and left both problems — the next member added is mis-cased + /// again, and the ones that are not acronyms are still English hard-coded into markup. + /// + /// + /// Which half a member falls in is the same line the message bundle draws everywhere else. + /// MSSP, WHO, INFO and I3 are protocol and command names — machine + /// voice, identical in every locale, and translating one destroys evidence. The handshake, the + /// connect screen, the owner and this project's staff are ours to say, so they are said in the + /// reader's language. + /// + /// + /// The switch is exhaustive and throws rather than falling back to ToString, because a + /// silent fallback is precisely how Mssp reached a page in the first place. + /// + /// + public static string Via(string tag, FieldSource source) + { + ArgumentNullException.ThrowIfNull(tag); + + return Messages.For(tag, source switch { - { IsMeasured: true } => "measured", - { Source: FieldSource.Owner } => "owner-declared", - _ => "declared", - }; + FieldSource.Staff => "source.staff", + FieldSource.Handshake => "source.handshake", + FieldSource.Owner => "source.owner", + FieldSource.Who => "source.who", + FieldSource.I3 => "source.i3", + FieldSource.Mssp => "source.mssp", + FieldSource.Info => "source.info", + FieldSource.I3Mudlist => "source.i3Mudlist", + FieldSource.Banner => "source.banner", + _ => throw new ArgumentOutOfRangeException( + nameof(source), + source, + "No display name for this field source. Add one rather than letting ToString answer."), + }); } } diff --git a/src/MUI.Web/Components/ReachSeries.cs b/src/MUI.Web/Components/ReachSeries.cs index d35b970..7fc2a2d 100644 --- a/src/MUI.Web/Components/ReachSeries.cs +++ b/src/MUI.Web/Components/ReachSeries.cs @@ -1,4 +1,5 @@ using MUI.Catalog; +using MUI.Web.Localization; namespace MUI.Web.Components; @@ -9,21 +10,41 @@ namespace MUI.Web.Components; /// public sealed record ReachDay(DateOnly Date, ReachState State, FailureCause Cause) { - public string Label => State switch + /// + /// One bar's title, in the reader's language. Ninety of these are drawn per game. + /// + /// + /// The fourth arm is the one that carries the rule: a day before we knew this game existed says + /// so about us and never that the game was down. Painting seventy-eight such days as + /// unreachable would record a decision of ours as a measurement of theirs, so it is its own id + /// rather than a shade of the unreachable one, and nothing in it can be translated into a cause. + /// + public string Label(string tag) => State switch { - ReachState.Reachable => $"{Date:d MMM} — reachable all day", - ReachState.Degraded => $"{Date:d MMM} — degraded ({Wording.Cause(Cause)}): answered, could not finish", - ReachState.Unreachable => $"{Date:d MMM} — unreachable ({Wording.Cause(Cause)})", - _ => $"{Date:d MMM} — not measured; we were not watching this game yet", + ReachState.Reachable => Messages.Say(tag, "reach.day.reachable", ("d", Date)), + ReachState.Degraded => + Messages.Say(tag, "reach.day.degraded", ("d", Date), ("cause", Wording.Cause(tag, Cause))), + ReachState.Unreachable => + Messages.Say(tag, "reach.day.unreachable", ("d", Date), ("cause", Wording.Cause(tag, Cause))), + _ => Messages.Say(tag, "reach.day.notMeasured", ("d", Date)), }; - public string Word => State switch + /// + /// The state as a noun, for the middle of a spell — "3 days unreachable". + /// + /// + /// Its own four ids rather than the legend's, though the English is the same two words twice: + /// the legend is a caption beside a swatch and this declines inside a sentence, and a language + /// with cases needs them in different ones. Collapsing them because English cannot tell them + /// apart is how a translation ends up ungrammatical in one of the two places. + /// + public string Word(string tag) => Messages.For(tag, State switch { - ReachState.Reachable => "reachable", - ReachState.Degraded => "degraded", - ReachState.Unreachable => "unreachable", - _ => "not measured", - }; + ReachState.Reachable => "reach.word.reachable", + ReachState.Degraded => "reach.word.degraded", + ReachState.Unreachable => "reach.word.unreachable", + _ => "reach.word.notMeasured", + }); } /// @@ -70,101 +91,110 @@ public sealed record ReachSummary( public bool HasAnyMeasurement => Days.Any(d => d.State is not ReachState.Unmeasured); /// The sentence the graphic is an illustration of, never a substitute for. - public string Sentence + public string Sentence(string tag) { - get + if (!HasAnyMeasurement) { - if (!HasAnyMeasurement) - { - return $"Not yet measured over the last {Window} days."; - } - - var parts = new List(4); - - var bad = Days.Count(d => d.State is ReachState.Unreachable); - var degraded = Days.Count(d => d.State is ReachState.Degraded); - var unmeasured = Days.Count(d => d.State is ReachState.Unmeasured); - var measured = Window - unmeasured; - - // The percentage's denominator is *observed* time, not the window (Reachability - // .FractionReachable). The sentence has to say so, or a game found an hour ago reads - // "Reachable 100.0% of the last 90 days" off a single successful probe — a true number - // wearing a claim eighty-nine days wider than the evidence. Only when the window is - // fully measured do the two denominators coincide and the shorter phrasing become true. - parts.Add(ReachableFraction is { } f - ? unmeasured == 0 - ? $"Reachable {Wording.Percent(f)} of the last {Window} days." - : $"Reachable {Wording.Percent(f)} of the {DayCount(measured)} we have measured." - : $"Reachability over the last {Window} days is not yet measured."); - - parts.Add(bad == 0 - ? unmeasured == 0 - ? "No day in the window was unreachable." - : "No day we measured was unreachable." - : $"{bad} {(bad == 1 ? "day" : "days")} unreachable."); - - if (degraded > 0) - { - parts.Add($"{degraded} {(degraded == 1 ? "day" : "days")} degraded — we got in and could not finish."); - } + return Messages.Say(tag, "reach.none", ("days", Window)); + } - if (LongestOutage is { } outage) - { - // The longest outage's own cause, not the most recent one. They are different - // intervals and pairing a duration with somebody else's cause invents an event. - parts.Add($"Longest outage {Wording.Duration(outage)}" - + (LongestOutageCause is FailureCause.None - ? "." - : $" ({Wording.Cause(LongestOutageCause)}).")); - } + var parts = new List(5); + + var bad = Days.Count(d => d.State is ReachState.Unreachable); + var degraded = Days.Count(d => d.State is ReachState.Degraded); + var unmeasured = Days.Count(d => d.State is ReachState.Unmeasured); + var measured = Window - unmeasured; + + // The percentage's denominator is *observed* time, not the window (Reachability + // .FractionReachable). The sentence has to say so, or a game found an hour ago reads + // "Reachable 100.0% of the last 90 days" off a single successful probe — a true number + // wearing a claim eighty-nine days wider than the evidence. Only when the window is + // fully measured do the two denominators coincide and the shorter phrasing become true. + // Two ids and not one with a substituted noun, so no locale can end up with one sentence + // doing both jobs. + parts.Add(ReachableFraction is { } f + ? unmeasured == 0 + ? Messages.Say(tag, "reach.fraction.window", ("percent", Wording.Percent(f)), ("days", Window)) + : Messages.Say(tag, "reach.fraction.measured", ("percent", Wording.Percent(f)), ("days", measured)) + : Messages.Say(tag, "reach.fraction.unknown", ("days", Window))); + + parts.Add(bad == 0 + ? Messages.For(tag, unmeasured == 0 + ? "reach.unreachable.noneInWindow" + : "reach.unreachable.noneMeasured") + : Messages.Say(tag, "reach.unreachable.days", ("count", bad))); + + if (degraded > 0) + { + parts.Add(Messages.Say(tag, "reach.degraded.days", ("count", degraded))); + } - if (unmeasured > 0) - { - parts.Add($"{unmeasured} {(unmeasured == 1 ? "day" : "days")} predate anything we measured."); - } + if (LongestOutage is { } outage) + { + // The longest outage's own cause, not the most recent one. They are different + // intervals and pairing a duration with somebody else's cause invents an event. + parts.Add(LongestOutageCause is FailureCause.None + ? Messages.Say(tag, "reach.longestOutage", ("duration", Wording.Duration(outage))) + : Messages.Say( + tag, + "reach.longestOutage.cause", + ("duration", Wording.Duration(outage)), + ("cause", Wording.Cause(tag, LongestOutageCause)))); + } - return string.Join(' ', parts); + if (unmeasured > 0) + { + // Never "unreachable for N days", and never a cause. These are days before we knew the + // game existed, which is a fact about our crawl. + parts.Add(Messages.Say(tag, "reach.predate", ("count", unmeasured))); } - } - private static string DayCount(int n) => n == 1 ? "1 day" : $"{n} days"; + return string.Join(' ', parts); + } /// Every spell that was not plain reachable, as words. The strip's text alternative. - public IReadOnlyList Spells + public IReadOnlyList Spells(string tag) { - get - { - var spells = new List(); - var i = 0; + var spells = new List(); + var i = 0; - while (i < Days.Count) + while (i < Days.Count) + { + var state = Days[i].State; + if (state is ReachState.Reachable) { - var state = Days[i].State; - if (state is ReachState.Reachable) - { - i++; - continue; - } - - var start = i; - while (i < Days.Count && Days[i].State == state && Days[i].Cause == Days[start].Cause) - { - i++; - } - - var days = i - start; - var range = days == 1 - ? $"{Days[start].Date:d MMM}" - : $"{Days[start].Date:d MMM} – {Days[i - 1].Date:d MMM}"; - var cause = Days[start].Cause is FailureCause.None - ? string.Empty - : $" ({Wording.Cause(Days[start].Cause)})"; + i++; + continue; + } - spells.Add($"{range}: {days} {(days == 1 ? "day" : "days")} {Days[start].Word}{cause}"); + var start = i; + while (i < Days.Count && Days[i].State == state && Days[i].Cause == Days[start].Cause) + { + i++; } - return spells; + var days = i - start; + var range = days == 1 + ? Messages.Say(tag, "reach.spell.oneDay", ("d", Days[start].Date)) + : Messages.Say(tag, "reach.spell.range", ("from", Days[start].Date), ("to", Days[i - 1].Date)); + + spells.Add(Days[start].Cause is FailureCause.None + ? Messages.Say( + tag, + "reach.spell", + ("range", range), + ("count", days), + ("word", Days[start].Word(tag))) + : Messages.Say( + tag, + "reach.spell.cause", + ("range", range), + ("count", days), + ("word", Days[start].Word(tag)), + ("cause", Wording.Cause(tag, Days[start].Cause)))); } + + return spells; } } @@ -303,15 +333,23 @@ public static (DateTimeOffset? LastReachableAt, TimeSpan KnownLive, DateTimeOffs /// Machine vocabulary said the way a person would say it. public static class Wording { - public static string Cause(FailureCause cause) => cause switch + /// + /// Why a dial did not complete, in the reader's language. + /// + /// + /// What our socket saw and never a judgement of the game: "connection refused" is an event on a + /// wire, and a locale that rendered it as "the game was down" would turn our measurement into + /// their obituary. The acronyms inside — dns, tls — are machine voice and stay put. + /// + public static string Cause(string tag, FailureCause cause) => Messages.For(tag, cause switch { - FailureCause.Dns => "dns did not resolve", - FailureCause.Refused => "connection refused", - FailureCause.Tls => "tls failed", - FailureCause.Timeout => "timed out", - FailureCause.HandshakeStalled => "handshake stalled", - _ => "no cause recorded", - }; + FailureCause.Dns => "cause.dns", + FailureCause.Refused => "cause.refused", + FailureCause.Tls => "cause.tls", + FailureCause.Timeout => "cause.timeout", + FailureCause.HandshakeStalled => "cause.handshakeStalled", + _ => "cause.none", + }); /// /// A fraction as a percentage. Hand-formatted because P1 under an invariant culture puts @@ -329,18 +367,4 @@ public static string Duration(TimeSpan span) return span.TotalHours >= 1 ? $"{(int)span.TotalHours}h" : $"{(int)span.TotalMinutes}m"; } - - /// A span of years said as a run, which is the fact an archive entry is worth having for. - public static string Run(DateTimeOffset from, DateTimeOffset to) - { - var years = (int)((to - from).TotalDays / 365.25); - var span = years switch - { - 0 => $"{(int)((to - from).TotalDays / 30.44)} months", - 1 => "1 year", - _ => $"{years} years", - }; - - return $"{from:yyyy} – {to:yyyy} · {span}"; - } } diff --git a/src/MUI.Web/Components/ReferencePlainText.cs b/src/MUI.Web/Components/ReferencePlainText.cs index d0669b7..590e2ed 100644 --- a/src/MUI.Web/Components/ReferencePlainText.cs +++ b/src/MUI.Web/Components/ReferencePlainText.cs @@ -1,6 +1,7 @@ using System.Text; using MUI.Catalog; +using MUI.Web.Localization; using MUI.Web.Reference; namespace MUI.Web.Components; @@ -20,6 +21,12 @@ namespace MUI.Web.Components; /// here from the same records the graphical page uses, so the two surfaces cannot report different /// counts. Nothing here re-reads the content files. /// +/// +/// The tag comes first, as it does on , and it reaches every sentence this +/// file writes — but never the article. The Markdown body is a document somebody owns and is +/// translated as a document rather than as a string; this file renders whichever one the library +/// hands it and puts no word of its own inside it. +/// /// public static class ReferencePlainText { @@ -31,20 +38,28 @@ public static class ReferencePlainText /// reading of somebody's documentation. Saying so beside the table is not modesty: this site's /// whole claim is that a reader can tell a measurement from an assertion, and a table that looks /// like the game pages' measured matrix while being neither would spend that credit. + /// + /// The unknown word is passed in rather than written into the sentence, so the sentence and the + /// cells it is explaining cannot end up quoting different words in the same locale. + /// /// - public const string ClientMatrixCaveat = - "Read off each project's own documentation, not measured by us — a client has no handshake " - + "for us to observe. \"unknown\" means we looked and did not establish it. It never means no."; + public static string ClientMatrixCaveat(string tag) => + Messages.Say(tag, "reference.capabilities.caveat", + ("unknown", Messages.For(tag, "reference.capability.unknown"))); /// /// The sentence a protocol page's remainder needs, on both surfaces. /// - public const string ProtocolRemainderCaveat = - "The games not counted here are not games without the protocol. A game is counted when we " - + "observed its server offering the option in a handshake; the rest are servers that did not " - + "offer it to us and servers whose handshake we have not read, and we cannot tell you which."; + /// + /// Rule 5 in one paragraph: the games not counted are servers that did not offer it to us + /// and servers whose handshake we have never read, and a locale that collapsed the two + /// would publish our own gap as a fact about somebody's game. + /// + public static string ProtocolRemainderCaveat(string tag) => + Messages.For(tag, "reference.protocol.remainder"); public static string Render( + string tag, ReferenceDocument document, CodebaseFigures? codebase = null, ProtocolFigures? protocol = null, @@ -54,7 +69,7 @@ public static string Render( var b = new StringBuilder(); - b.AppendLine($"{document.Title.ToUpperInvariant()} [{Kind(document.Kind)}]"); + b.AppendLine($"{document.Title.ToUpperInvariant()} [{Kind(tag, document.Kind)}]"); PlainText.Wrap(b, document.Summary); if (document.Home is { } home) @@ -64,22 +79,23 @@ public static string Render( if (document.Platforms.Count > 0) { - b.AppendLine($"Runs on: {string.Join(", ", document.Platforms)}"); + b.AppendLine(Messages.Say( + tag, "reference.plain.runsOn", ("platforms", string.Join(", ", document.Platforms)))); } if (codebase is not null) { - AppendCodebase(b, document, codebase); + AppendCodebase(b, tag, document, codebase); } if (protocol is not null) { - AppendProtocol(b, document, protocol); + AppendProtocol(b, tag, document, protocol); } if (document.Kind is ReferenceKind.Client) { - AppendClientMatrix(b, document); + AppendClientMatrix(b, tag, document); } b.AppendLine(); @@ -88,10 +104,10 @@ public static string Render( if (related is { Count: > 0 }) { b.AppendLine(); - b.AppendLine("See also"); + b.AppendLine(Messages.For(tag, "reference.seeAlso")); foreach (var other in related) { - b.AppendLine($" {other.Title} — {other.Path}"); + b.AppendLine($" {other.Title} — {LocaleRouting.Link(tag, other.Path)}"); } } @@ -103,52 +119,63 @@ public static string Render( /// are, because a game whose codebase we could not read is not a game running something /// else — and the difference is the whole reason the figure is worth printing. /// - private static void AppendCodebase(StringBuilder b, ReferenceDocument document, CodebaseFigures figures) + private static void AppendCodebase( + StringBuilder b, string tag, ReferenceDocument document, CodebaseFigures figures) { b.AppendLine(); - b.AppendLine("Games we have identified as running this codebase"); + b.AppendLine(Messages.For(tag, "reference.plain.codebase.heading")); if (figures.Known == 0) { - PlainText.Wrap(b, "None yet. That is a statement about what we have measured, not about " - + "what exists — a game we have not reached, or whose codebase we could not read, is " - + "not counted here.", " "); + PlainText.Wrap(b, Messages.For(tag, "reference.plain.codebase.none"), " "); return; } - b.AppendLine($" {figures.Listed} listed, {figures.Archived} archived"); + b.AppendLine(" " + Messages.Say( + tag, + "reference.plain.codebase.counts", + ("listed", figures.Listed), + ("archived", figures.Archived))); if (document.GamesPath is { } path) { - b.AppendLine($" {path}"); + b.AppendLine($" {LocaleRouting.Link(tag, path)}"); } - b.AppendLine(figures.MeasuredProtocols.Count > 0 - ? $" Measured in their handshakes: {string.Join(", ", figures.MeasuredProtocols)}" - : " Nothing was offered in any handshake we have read from them."); + b.AppendLine(" " + (figures.MeasuredProtocols.Count > 0 + ? Messages.Say( + tag, + "reference.plain.codebase.offered", + ("protocols", string.Join(", ", figures.MeasuredProtocols))) + : Messages.For(tag, "reference.plain.codebase.nothingOffered"))); } - private static void AppendProtocol(StringBuilder b, ReferenceDocument document, ProtocolFigures figures) + private static void AppendProtocol( + StringBuilder b, string tag, ReferenceDocument document, ProtocolFigures figures) { b.AppendLine(); - b.AppendLine("Measured adoption"); + b.AppendLine(Messages.For(tag, "reference.protocol.heading")); if (figures.Listed == 0) { - b.AppendLine(" Nothing measured yet."); + b.AppendLine(" " + Messages.For(tag, "reference.protocol.none")); return; } - b.AppendLine($" {figures.Offering} of {figures.Listed} listed games were observed offering it " - + $"({Wording.Percent(figures.Share ?? 0)})"); + PlainText.Wrap(b, Messages.Say( + tag, + "reference.plain.protocol.share", + ("offering", figures.Offering), + ("listed", figures.Listed), + ("percent", Wording.Percent(figures.Share ?? 0))), " "); if (document.GamesPath is { } path) { - b.AppendLine($" {path}"); + b.AppendLine($" {LocaleRouting.Link(tag, path)}"); } b.AppendLine(); - PlainText.Wrap(b, ProtocolRemainderCaveat, " "); + PlainText.Wrap(b, ProtocolRemainderCaveat(tag), " "); var rows = figures.ByCodebase.Where(r => r.IsMeasured).ToList(); @@ -158,25 +185,32 @@ private static void AppendProtocol(StringBuilder b, ReferenceDocument document, } b.AppendLine(); - b.AppendLine(" By codebase, of the games we identified"); + b.AppendLine(" " + Messages.For(tag, "reference.plain.protocol.byCodebase")); foreach (var row in rows) { - b.AppendLine($" {row.Codebase,-20} {row.Offering,4} of {row.Identified,-4} offered it"); + // The family name is padded to a column and the sentence beside it is not: the numbers + // are inside a message now, and a language that puts them in the other order has nowhere + // to say so if this file is still counting characters into it. + b.AppendLine($" {row.Codebase,-20} " + Messages.Say( + tag, + "reference.plain.protocol.row", + ("offering", row.Offering), + ("identified", row.Identified))); } } - private static void AppendClientMatrix(StringBuilder b, ReferenceDocument document) + private static void AppendClientMatrix(StringBuilder b, string tag, ReferenceDocument document) { b.AppendLine(); - b.AppendLine("Capabilities"); - PlainText.Wrap(b, ClientMatrixCaveat, " "); + b.AppendLine(Messages.For(tag, "reference.capabilities.heading")); + PlainText.Wrap(b, ClientMatrixCaveat(tag), " "); b.AppendLine(); var claims = ClientCapabilities.For(document); foreach (var claim in claims) { - b.AppendLine($" {claim.Name,-16} {ClientCapabilities.Word(claim.State)}"); + b.AppendLine($" {claim.Name,-16} {ClientCapabilities.Word(tag, claim.State)}"); // The source on its own line, unwrapped. A URL broken across two lines is not a URL, and // this is the one place the eighty-column rule gives way to a thing being usable. @@ -191,23 +225,23 @@ private static void AppendClientMatrix(StringBuilder b, ReferenceDocument docume if (unknown > 0) { b.AppendLine(); - PlainText.Wrap(b, $"{unknown} of {claims.Count} rows are unknown: we did not find the " - + "project's own documentation saying either way. A short honest table beats a long " - + "guessed one.", " "); + PlainText.Wrap(b, Messages.Say( + tag, + "reference.plain.capabilities.unknown", + ("count", unknown), + ("total", claims.Count)), " "); } } /// The index, which is the only page in the section that is a list of the others. - public static string RenderIndex(ReferenceLibrary library) + public static string RenderIndex(string tag, ReferenceLibrary library) { ArgumentNullException.ThrowIfNull(library); var b = new StringBuilder(); - b.AppendLine("REFERENCE"); - PlainText.Wrap(b, "Hand-written, single-author, and versioned in git. The prose here is ours; " - + "every number beside it was measured by the crawler and is recomputed on each request. " - + "This is not a wiki, and there is no way to edit it from this page."); + b.AppendLine(Messages.For(tag, "reference.title").ToUpperInvariant()); + PlainText.Wrap(b, Messages.For(tag, "reference.plain.lede")); foreach (var kind in new[] { @@ -222,12 +256,12 @@ public static string RenderIndex(ReferenceLibrary library) } b.AppendLine(); - b.AppendLine(Heading(kind).ToUpperInvariant()); + b.AppendLine(Heading(tag, kind).ToUpperInvariant()); foreach (var document in documents) { b.AppendLine($" {document.Title}"); - b.AppendLine($" {document.Path}"); + b.AppendLine($" {LocaleRouting.Link(tag, document.Path)}"); PlainText.Wrap(b, document.Summary, " "); } } @@ -235,19 +269,24 @@ public static string RenderIndex(ReferenceLibrary library) return b.ToString(); } - public static string Kind(ReferenceKind kind) => kind switch + /// What one page is — the kicker above its title, and the tag beside it in plain text. + public static string Kind(string tag, ReferenceKind kind) => Messages.For(tag, kind switch { - ReferenceKind.Codebase => "codebase", - ReferenceKind.Client => "client", - ReferenceKind.Protocol => "protocol", - _ => "orientation", - }; + ReferenceKind.Codebase => "reference.kind.codebase", + ReferenceKind.Client => "reference.kind.client", + ReferenceKind.Protocol => "reference.kind.protocol", + _ => "reference.kind.orientation", + }); - public static string Heading(ReferenceKind kind) => kind switch + /// + /// The heading over a list of them, which is a different job from and so a + /// different id: English writes one word for both and most languages do not. + /// + public static string Heading(string tag, ReferenceKind kind) => Messages.For(tag, kind switch { - ReferenceKind.Codebase => "Codebases", - ReferenceKind.Client => "Clients", - ReferenceKind.Protocol => "Protocols", - _ => "Start here", - }; + ReferenceKind.Codebase => "reference.section.codebase", + ReferenceKind.Client => "reference.section.client", + ReferenceKind.Protocol => "reference.section.protocol", + _ => "reference.section.orientation", + }); } diff --git a/src/MUI.Web/Components/Relative.cs b/src/MUI.Web/Components/Relative.cs index 38f80f3..15998d8 100644 --- a/src/MUI.Web/Components/Relative.cs +++ b/src/MUI.Web/Components/Relative.cs @@ -1,5 +1,30 @@ +using MUI.Web.Localization; + namespace MUI.Web.Components; +/// +/// Which question an age is answering, which is not always the same question. +/// +/// +/// English says "2w ago" for both and several languages will not. Splitting the id is the only way a +/// translator can tell them apart at all, because the source text cannot. +/// +public enum AgeSense +{ + /// How long ago we last confirmed a value — how fresh this measurement is. + Confirmed, + + /// + /// How long since the game was last reached — how long it has been dark. + /// + /// + /// Never offline, down or up, in any language. We measured a socket + /// from one vantage point at intervals; a game with a routing problem to our host is unreachable + /// and perfectly alive, and saying otherwise files our vantage point as a fact about their game. + /// + Reached, +} + /// /// Ages render as relative time, never as a decay bar. /// @@ -10,25 +35,62 @@ namespace MUI.Web.Components; /// public static class Relative { + /// + /// One ladder, everywhere: minutes to ninety, hours to forty-eight, then days, then weeks, then + /// months and years. + /// + /// + /// + /// The rungs matter because a column of these is read by eye and 84m sorts below + /// 1h to anybody scanning it. Two days of hours rather than a day and a half is the + /// widest a rung can be before the number in it stops meaning anything — and it is where the + /// site's own probe cadence puts most ages that are not minutes. + /// + /// + /// A bare duration takes no register: it is a length of time and not a claim about anything. + /// is where the register lives, because that is where a verb appears. + /// + /// + public static string Format(string tag, TimeSpan age) => Say(tag, "age.short", age); + /// /// The same age, as something that already happened. /// /// - /// 's freshest bucket is the word "now", so a caller appending " ago" to it - /// wrote "last reached now ago" for the ninety seconds after every probe — which, on a listing - /// rendered while a crawl is running, was most of the rows on the page. The suffix belongs to - /// whoever knows whether the bucket came back a duration or a word, and that is here. + /// The freshest rung is the word "just now" rather than a duration with a suffix glued on. It + /// used to be exactly that, and a caller appending " ago" to 's "now" wrote + /// "last reached now ago" for the ninety seconds after every probe — which, on a listing + /// rendered while a crawl is running, was most of the rows on the page. Each rung now carries + /// its own whole phrase, so there is nowhere left to append one. /// - public static string Ago(TimeSpan age) => Format(age) is "now" ? "just now" : Format(age) + " ago"; + public static string Ago(string tag, TimeSpan age, AgeSense sense = AgeSense.Confirmed) => + Say(tag, sense is AgeSense.Reached ? "age.dark" : "age.ago", age); - public static string Format(TimeSpan age) => age switch + private static string Say(string tag, string family, TimeSpan age) + { + ArgumentNullException.ThrowIfNull(tag); + + var (rung, count) = Rung(age); + + return Messages.For( + tag, + family + "." + rung, + new Dictionary(StringComparer.Ordinal) { ["count"] = count }); + } + + /// Which rung of the ladder an age lands on, and the number that goes on it. + /// + /// The ladder is chosen once and the three families all read from it, so a rung cannot mean one + /// span in a tooltip and another in a column. + /// + internal static (string Rung, int Count) Rung(TimeSpan age) => age switch { - { TotalSeconds: < 90 } => "now", - { TotalMinutes: < 90 } => $"{(int)age.TotalMinutes}m", - { TotalHours: < 36 } => $"{(int)age.TotalHours}h", - { TotalDays: < 14 } => $"{(int)age.TotalDays}d", - { TotalDays: < 70 } => $"{(int)(age.TotalDays / 7)}w", - { TotalDays: < 730 } => $"{(int)(age.TotalDays / 30)}mo", - _ => $"{(int)(age.TotalDays / 365)}y", + { TotalSeconds: < 90 } => ("now", 0), + { TotalMinutes: < 90 } => ("minutes", (int)age.TotalMinutes), + { TotalHours: < 48 } => ("hours", (int)age.TotalHours), + { TotalDays: < 14 } => ("days", (int)age.TotalDays), + { TotalDays: < 70 } => ("weeks", (int)(age.TotalDays / 7)), + { TotalDays: < 730 } => ("months", (int)(age.TotalDays / 30)), + _ => ("years", (int)(age.TotalDays / 365)), }; } diff --git a/src/MUI.Web/Components/Sentence.cs b/src/MUI.Web/Components/Sentence.cs new file mode 100644 index 0000000..318f477 --- /dev/null +++ b/src/MUI.Web/Components/Sentence.cs @@ -0,0 +1,108 @@ +using System.Text; + +using MUI.Web.Localization; + +namespace MUI.Web.Components; + +/// One run of a sentence: plain text, or the text that fills a named slot. +/// What to render. +/// +/// Which slot this run is, or null for the prose between them. The caller switches on it to choose +/// an element — an anchor, a <code>, an emphasis — because the element is markup and +/// the words are not. +/// +public sealed record SentencePart(string Text, string? Slot); + +/// +/// A sentence that places its own links, code spans and emphasis. +/// +/// +/// +/// Neither half of the obvious approach works. Gluing English around an anchor gives a +/// language that wants the link earlier, or a different preposition before it, nowhere to say so: +/// the markup owns the word order and a translator only ever receives the fragments. Formatting the +/// anchor into the string and trusting the result through a MarkupString fixes that +/// and makes every bundle a place somebody could put a tag. +/// +/// +/// So the message places a marker and the caller walks the runs. What a translator writes is text +/// either way, and the word order is theirs. RandomGame invented this for its empty state and +/// kept it private; the owner dashboard, the claim page and the owner panel each need it two or +/// three times over, and a fourth private copy is how the escaping stops matching. +/// +/// +/// The markers are Unicode private-use characters, which is what makes the split safe: no message, +/// no game name, no hostname and no token can contain one, so a run boundary is never something a +/// translator or an operator typed. +/// +/// +public static class Sentence +{ + /// The first private-use code point, one per slot in the order they are named. + private const char FirstMarker = '\uE000'; + + /// + /// One message, split into the runs its markup renders. + /// + /// The locale the sentence is being answered in. + /// The message id. + /// + /// The argument names the message places as markup rather than as text. Each one appears in the + /// result as a carrying that name and no text of its own. + /// + /// Ordinary arguments — a count, a game's name — substituted as text. + public static IReadOnlyList Place( + string tag, + string id, + IReadOnlyList slots, + params (string Key, object? Value)[] args) + { + ArgumentNullException.ThrowIfNull(slots); + ArgumentNullException.ThrowIfNull(args); + + if (slots.Count > 16) + { + // Not a real limit so much as a smell: a sentence with seventeen pieces of markup in it + // is a paragraph pretending to be a string, and no translator can keep it in order. + throw new ArgumentException("A sentence places at most 16 slots.", nameof(slots)); + } + + var values = args.ToDictionary(a => a.Key, a => a.Value, StringComparer.Ordinal); + + for (var i = 0; i < slots.Count; i++) + { + values[slots[i]] = ((char)(FirstMarker + i)).ToString(); + } + + var formatted = Messages.For(tag, id, values); + + var parts = new List(); + var run = new StringBuilder(); + + foreach (var c in formatted) + { + var slot = c - FirstMarker; + + if (slot < 0 || slot >= slots.Count) + { + run.Append(c); + continue; + } + + if (run.Length > 0) + { + parts.Add(new SentencePart(run.ToString(), null)); + run.Clear(); + } + + parts.Add(new SentencePart(string.Empty, slots[slot])); + } + + if (run.Length > 0) + { + parts.Add(new SentencePart(run.ToString(), null)); + } + + return parts; + } +} diff --git a/src/MUI.Web/Components/SitePreview.razor b/src/MUI.Web/Components/SitePreview.razor index f74906c..2b082d1 100644 --- a/src/MUI.Web/Components/SitePreview.razor +++ b/src/MUI.Web/Components/SitePreview.razor @@ -1,5 +1,6 @@ @using MUI.Catalog @using MUI.Web.Data +@using MUI.Web.Localization @inject CatalogueSource Catalogue @* @@ -17,13 +18,33 @@ + @* + The same document in every language it exists in. + + A locale that lives only in a cookie or in Accept-Language gives one URL two bodies, and a + search engine indexes whichever it happened to be served. These say the addresses out loud, + and x-default points at the unprefixed one — which is what tells a crawler that the English + URL is the one to show a reader whose language nothing here matches, rather than leaving it + to pick among them. + + Emitted only where there is more than one to name. Ceremony for a site that ships one + language would be markup asserting a choice nobody has. + *@ + @if (Http is { } request && Locales.Offered.Count > 1) + { + @foreach (var (hreflang, path) in LocaleRouting.Alternates(request.PathWithinLocale())) + { + + } + } + - + @* summary_large_image and not summary: the cards are 1200×630 and a small card would letterbox @@ -64,10 +85,22 @@ [Parameter] public string? Title { get; set; } + /// The locale this request is being answered in, which the metadata is written in too. + /// + /// A German page used to carry an English title, description and card. Nothing visible said so — + /// no reader sees a <meta> — and the surfaces that do render it are a search + /// result, a chat client unfurling a link and a bookmark, none of which the reader can correct. + /// + private string Tag => Http.LocaleOf().Tag; + /// What this page is, in a sentence or two. Required — a page without one is summarised /// by whoever links to it, from whatever text happens to come first in the body. + /// + /// Defaulted to null and resolved against the request's locale, because the default sentence is + /// itself a translated one and a parameter initialiser has no locale to resolve it in. + /// [Parameter, EditorRequired] - public string Description { get; set; } = PreviewCopy.Site; + public string? Description { get; set; } [Parameter] public PreviewCard Card { get; set; } = PreviewCard.Site; @@ -94,11 +127,13 @@ [Parameter] public Func? Graph { get; set; } - private string Heading => PreviewCopy.Title(Title); + private string Heading => PreviewCopy.Title(Tag, Title); + + private string Said => Description ?? PreviewCopy.Site(Tag); private string Text => Data && !Catalogue.IsMeasured - ? PreviewCopy.Demo(Description) - : Description; + ? PreviewCopy.Demo(Tag, Said) + : Said; private string Canonical => Http is null ? "/" diff --git a/src/MUI.Web/Components/SubmitCopy.cs b/src/MUI.Web/Components/SubmitCopy.cs index fda6115..289b431 100644 --- a/src/MUI.Web/Components/SubmitCopy.cs +++ b/src/MUI.Web/Components/SubmitCopy.cs @@ -1,6 +1,7 @@ using System.Globalization; using MUI.Discovery; +using MUI.Web.Localization; namespace MUI.Web.Components; @@ -11,10 +12,17 @@ namespace MUI.Web.Components; /// /// Here rather than in the page because the plain surface renders the same words (spec §9), and a /// form whose refusal reads differently in a text browser is a form with two policies. Nothing here -/// touches a database or a socket: this maps a to English, and +/// touches a database or a socket: this maps a to a message id, and /// is what decides. /// /// +/// Every member takes a locale, and the address is an ICU argument rather than a prefix. The +/// answers used to open with the address glued to the front of an English sentence, which is a fact +/// about English word order and not about addresses — a language that puts the subject elsewhere had +/// nowhere to say so. The default is the source language, so a caller with no request behind it +/// still gets a sentence. +/// +/// /// No refusal here explains itself with an address. The receipt carries a detail naming what /// the host resolved to, and it is for an operator's log — showing it to the submitter would turn /// this form into a free scan of whatever network the crawler happens to run inside, which is the @@ -23,91 +31,74 @@ namespace MUI.Web.Components; /// public static class SubmitCopy { - public const string Title = "Submit a game"; + public static string Title(string tag = Locales.SourceTag) => Messages.Say(tag, "submit.title"); - public const string Lede = - "Tell us where a game is. A host and a port is the whole form; everything else on this site " - + "is measured by our own crawler."; + public static string Lede(string tag = Locales.SourceTag) => Messages.Say(tag, "submit.lede"); /// What happens to an address after it is submitted, in the order it happens. /// /// The third point is the one people are surprised by, so it is stated on the form rather than /// discovered afterwards: a submission does not put a listing up. /// - public static IReadOnlyList Points { get; } = + public static IReadOnlyList Points(string tag = Locales.SourceTag) => [ - "We resolve the address before dialling it, and refuse anything that resolves off the public " - + "internet. That is a decision about our own socket, never a fact about a game.", - - "If whoever runs that host has asked us not to crawl it, we will not take the address, " - + "whoever submits it. A stranger cannot put your game back on this site.", - - "If it answers, we read what the server says for itself and keep reading it on its own " - + "schedule, for ever. An address only has to be given once.", - - "Nothing appears on the site until somebody proves they run it. Claiming takes a passkey and " - + "one line published on the game itself.", - - "An address we already have collapses onto the existing entry. Sending it twice makes no " - + "second listing and brings no probe forward.", + Messages.Say(tag, "submit.what.resolve"), + Messages.Say(tag, "submit.what.optOut"), + Messages.Say(tag, "submit.what.schedule"), + Messages.Say(tag, "submit.what.claim"), + Messages.Say(tag, "submit.what.duplicate"), ]; /// The label on each box, so the rendered form and the plain one cannot drift. - public const string HostLabel = "Host"; + public static string HostLabel(string tag = Locales.SourceTag) => Messages.Say(tag, "submit.host.label"); - public const string PortLabel = "Port"; + public static string PortLabel(string tag = Locales.SourceTag) => Messages.Say(tag, "submit.port.label"); - public const string HostHint = "mud.example.org, or paste mud.example.org:4201 and leave the port empty"; + public static string HostHint(string tag = Locales.SourceTag) => Messages.Say(tag, "submit.host.hint"); - public const string SubmitLabel = "Submit"; + public static string SubmitLabel(string tag = Locales.SourceTag) => Messages.Say(tag, "submit.button"); /// What the site says when it has no database and therefore no registry to write into. - public const string NoCatalogue = - "Submitting needs a database, and this site is running on the demo fixture. There is no " - + "crawl registry to write into, so the form is absent rather than quietly doing nothing."; + public static string NoCatalogue(string tag = Locales.SourceTag) => Messages.Say(tag, "submit.noCatalogue"); /// The answer to one submission, or null when nothing has been submitted yet. - public static SubmitAnswer? Answer(SubmissionOutcome? outcome, string? address, SubmitLink? link = null) => + public static SubmitAnswer? Answer( + SubmissionOutcome? outcome, + string? address, + SubmitLink? link = null, + string tag = Locales.SourceTag) => outcome switch { null => null, SubmissionOutcome.Accepted => new SubmitAnswer( - "In the registry.", - $"{Named(address)} will be dialled on the next crawl cycle, then on its own schedule " - + "for ever. It appears here once somebody proves they run it — come back to this " - + "form with the same address and it will hand you the link."), + Messages.Say(tag, "submit.accepted.heading"), + Messages.Say(tag, "submit.accepted.sentence", ("address", Named(tag, address)))), // Two arms, and the link is what differs. A game we list gets its page; a submitted one // nobody has claimed gets its claim page, because that is the only exit from hidden and // a person who has just told us the address is exactly who should be offered it. SubmissionOutcome.AlreadyListed when link?.IsClaim is true => new SubmitAnswer( - "We have it, unclaimed.", - $"{Named(address)} is one we already measure. It stays off the site until somebody " - + "proves they run it. If that is you, this is the way in.", + Messages.Say(tag, "submit.unclaimed.heading"), + Messages.Say(tag, "submit.unclaimed.sentence", ("address", Named(tag, address))), link), SubmissionOutcome.AlreadyListed when link is not null => new SubmitAnswer( - "We already have that one.", - $"{Named(address)} is a game we already measure. Nothing was created and nothing " - + "was changed.", + Messages.Say(tag, "submit.known.heading"), + Messages.Say(tag, "submit.known.sentence", ("address", Named(tag, address))), link), SubmissionOutcome.AlreadyListed => new SubmitAnswer( - "We already have that address.", - $"{Named(address)} is already known to us. Nothing was created and nothing was " - + "changed."), + Messages.Say(tag, "submit.knownAddress.heading"), + Messages.Say(tag, "submit.knownAddress.sentence", ("address", Named(tag, address)))), SubmissionOutcome.AlreadyQueued => new SubmitAnswer( - "Already waiting.", - $"{Named(address)} is in the crawl registry and has not answered yet. Sending it " - + "again does not bring it forward: a target keeps its own schedule, so nobody can " - + "hurry us at somebody else's server."), + Messages.Say(tag, "submit.queued.heading"), + Messages.Say(tag, "submit.queued.sentence", ("address", Named(tag, address)))), SubmissionOutcome.Malformed => new SubmitAnswer( - "Not an address we can dial.", - "A host needs a dot or a colon in it, and a port is a number between 1 and 65535. " - + "Fill in both boxes, or paste mud.example.org:4201 into the first."), + Messages.Say(tag, "submit.malformed.heading"), + Messages.Say(tag, "submit.malformed.sentence")), // ONE SENTENCE FOR ALL THREE, AND THE VAGUENESS IS THE POINT. §7.2 keeps "did not // resolve" and "resolved somewhere we will not go" apart because they are two facts and @@ -124,22 +115,25 @@ public static class SubmitCopy SubmissionOutcome.RefusedNotRoutable or SubmissionOutcome.Unresolvable or SubmissionOutcome.RefusedOptOut => new SubmitAnswer( - "We cannot dial that.", - $"Three things produce this answer for {Named(address)}: the name may not resolve, " - + "it may resolve off the public internet, or whoever runs that host may have asked " - + "us to stay away. We deliberately do not say which, because answering that for a " - + "stranger maps a network from outside it. Nothing was recorded about the address; " - + "the decision was ours and it is filed as ours."), + Messages.Say(tag, "submit.undialable.heading"), + Messages.Say(tag, "submit.undialable.sentence", ("address", Named(tag, address)))), SubmissionOutcome.TooMany => new SubmitAnswer( - "Enough for now.", - "This form is rate-limited by sender, and you have hit the bound. Come back in an " - + "hour. Nothing was lost — anything we took is already in the registry."), + Messages.Say(tag, "submit.tooMany.heading"), + Messages.Say(tag, "submit.tooMany.sentence")), _ => null, }; - private static string Named(string? address) => address is { Length: > 0 } ? address : "that address"; + /// + /// The address as the sentence names it — or the noun phrase that stands where one would. + /// + /// + /// Its own id rather than a fragment of every sentence that can take it: it is a noun phrase + /// filling a hostname's slot, and it declines in the languages that decline. + /// + private static string Named(string tag, string? address) => + address is { Length: > 0 } ? address : Messages.Say(tag, "submit.answer.thatAddress"); } /// One answer, as both surfaces render it. @@ -152,9 +146,17 @@ public sealed record SubmitAnswer(string Heading, string Sentence, SubmitLink? L /// public sealed record SubmitLink(string Href, string Label, bool IsClaim = false) { + /// + /// A game's page, labelled with its own address. + /// + /// + /// The label is the URL, which is machine voice and takes no locale: a reader following it is + /// being shown where they are about to go, and translating a path would be inventing one. + /// public static SubmitLink Game(string slug) => new($"/g/{slug}", $"/g/{slug}"); - public static SubmitLink Claim(string slug) => new($"/g/{slug}/claim", "claim this game", IsClaim: true); + public static SubmitLink Claim(string slug, string tag = Locales.SourceTag) => + new($"/g/{slug}/claim", Messages.For(tag, "submit.link.claim"), IsClaim: true); } /// diff --git a/src/MUI.Web/Components/ThemeToggle.razor b/src/MUI.Web/Components/ThemeToggle.razor index 99d558c..04e3fc5 100644 --- a/src/MUI.Web/Components/ThemeToggle.razor +++ b/src/MUI.Web/Components/ThemeToggle.razor @@ -1,3 +1,4 @@ +@using MUI.Web.Localization @using MUI.Web.Theme @* @@ -12,12 +13,18 @@ Each button is its own submit with its own value, so choosing is one press from anywhere in the set. A radio group would have been the other honest shape and costs two. *@ - - theme +@* + Posted to this locale's copy of the endpoint, which is what tells it which language to send the + reader back into. A reader who followed a shared /de/… link carries no cookie, so the locale is + in the path or it is nowhere — and a theme control that answered in English would take a German + reader out of German for the crime of preferring a light background. +*@ + + @Messages.For(Tag, "theme.label") @foreach (var choice in ReaderTheme.Choices) { - @ReaderTheme.Word(choice) + @Messages.For(Tag, "theme." + ReaderTheme.Word(choice)) } @@ -28,6 +35,8 @@ private ThemeChoice InForce => ReaderTheme.Of(HttpContext); + private string Tag => HttpContext.LocaleOf().Tag; + /// /// The page this control was rendered on, so choosing does not lose the reader's place. /// diff --git a/src/MUI.Web/Components/TrendGeometry.cs b/src/MUI.Web/Components/TrendGeometry.cs index 9b49eaa..eb898db 100644 --- a/src/MUI.Web/Components/TrendGeometry.cs +++ b/src/MUI.Web/Components/TrendGeometry.cs @@ -1,6 +1,8 @@ using System.Globalization; using System.Text; +using MUI.Web.Localization; + namespace MUI.Web.Components; /// One column of the trend chart — a day, and the strip of canvas it owns. @@ -188,7 +190,7 @@ public static IReadOnlyList BandPaths(TrendSeries series) } /// Counted days with no counted neighbour, which no path can carry. - public static IReadOnlyList Dots(TrendSeries series) + public static IReadOnlyList Dots(string tag, TrendSeries series) { var ceiling = Ceiling(series); @@ -197,7 +199,7 @@ public static IReadOnlyList Dots(TrendSeries series) .Select(run => new TrendDot( X(run[0]), Y(run[0].Day.Average ?? 0, ceiling), - run[0].Day.Label)) + run[0].Day.Label(tag))) .ToList(); } @@ -207,10 +209,10 @@ public static IReadOnlyList Dots(TrendSeries series) /// measured zero — which is the collapse of §5.4's middle state into a filled cell, and the /// worst bug this codebase can ship. It sits in its own gutter and the legend names it. /// - public static IReadOnlyList Ticks(TrendSeries series) => + public static IReadOnlyList Ticks(string tag, TrendSeries series) => Columns(series) .Where(c => c.Day.IsUncountable) - .Select(c => new TrendTick(c.X, c.Width, c.Day.Label)) + .Select(c => new TrendTick(c.X, c.Width, c.Day.Label(tag))) .ToList(); /// @@ -223,7 +225,7 @@ public static IReadOnlyList Ticks(TrendSeries series) => /// sixty month names into a grey smear. The exact ends of the range are printed as words above /// the chart, so thinning here loses nothing. /// - public static IReadOnlyList Months(TrendSeries series) + public static IReadOnlyList Months(string tag, TrendSeries series) { var apart = Width / 9; var months = new List(); @@ -240,7 +242,7 @@ public static IReadOnlyList Months(TrendSeries series) continue; } - months.Add(new TrendMonth(column.X, column.X / Width, Month(column.Day.Date))); + months.Add(new TrendMonth(column.X, column.X / Width, Month(tag, column.Day.Date))); } return months; @@ -357,8 +359,21 @@ private static string Path(IEnumerable<(double X, double Y)> points) return b.ToString().TrimEnd(); } - private static string Month(DateOnly date) => - date.Month == 1 ? date.ToString("MMM yyyy") : date.ToString("MMM"); + /// + /// A rule's label: the month, and the year as well where the calendar turns over. + /// + /// + /// Through the message pipeline rather than ToString("MMM"), which reads the month name + /// off whatever culture the thread happens to be under — the request's, in one place, and the + /// invariant one in the headless renderer and the tests. The pattern carries the date style, so + /// a locale that writes the year first says so in its own copy instead of being given an + /// English ordering with translated words in it. + /// + private static string Month(string tag, DateOnly date) => + Messages.For( + tag, + date.Month == 1 ? "trend.axis.monthYear" : "trend.axis.month", + new Dictionary(StringComparer.Ordinal) { ["d"] = date }); private static double Y(double value, int ceiling) => Baseline - (value / ceiling * (Baseline - TopPad)); diff --git a/src/MUI.Web/Components/TrendSeries.cs b/src/MUI.Web/Components/TrendSeries.cs index d9bdaf8..3ff864e 100644 --- a/src/MUI.Web/Components/TrendSeries.cs +++ b/src/MUI.Web/Components/TrendSeries.cs @@ -1,6 +1,7 @@ using System.Globalization; using MUI.Catalog; +using MUI.Web.Localization; namespace MUI.Web.Components; @@ -45,16 +46,49 @@ public sealed record TrendDay( /// public int? Typical => Mean is { } m ? (int)Math.Floor(m) : null; - public string Label => this switch + /// + /// What one column says, as a sentence in the reader's language. + /// + /// + /// + /// Four shapes and the reader gets exactly one, so all four are ids and none is assembled. The + /// English this replaces glued a date to a fragment and a number to the word "probes", which is + /// two facts about English — the month name and the plural rule — standing where a measurement + /// was supposed to be. Ninety of these are drawn per game, which is why this one method was most + /// of what the site still said in English however it was asked. + /// + /// + /// The gap and the uncountable day are two different ids and must stay two. A day nobody + /// measured names no cause — a failed probe writes no presence row at all, so it covers a dial + /// we could not complete and a day we never dialled alike — and a day probed all through without + /// a readable count is a measurement of a game that was answering. Collapsing them is §5.4's + /// worst bug, and it is a collapse a translator could make silently, so the two ids read nothing + /// like each other and a test holds them apart in every locale. + /// + /// + public string Label(string tag) => this switch { - { IsGap: true } => $"{Date:d MMM yyyy} — no measurement", - { IsUncountable: true } => $"{Date:d MMM yyyy} — probed, no count could be read", - { Min: { } lo, Max: { } hi } when lo == hi => - $"{Date:d MMM yyyy} — {lo} players, every one of {Probes(CountedSamples)}", - _ => $"{Date:d MMM yyyy} — {Typical} on average, {Min}–{Max} across {Probes(CountedSamples)}", + { IsGap: true } => Messages.Say(tag, "trend.day.notMeasured", ("d", Date)), + { IsUncountable: true } => Messages.Say(tag, "trend.day.notCounted", ("d", Date)), + + // Every probe returning the same number is its own sentence rather than a range with that + // number written twice: "24–24" is arithmetic printed where a measurement was asked for. + { Min: { } lo, Max: { } hi } when lo == hi => Messages.Say( + tag, + "trend.day.flat", + ("d", Date), + ("count", lo), + ("probes", CountedSamples)), + + _ => Messages.Say( + tag, + "trend.day.counted", + ("d", Date), + ("typical", Typical), + ("low", Min), + ("high", Max), + ("probes", CountedSamples)), }; - - private static string Probes(int n) => n == 1 ? "1 probe" : $"{n} probes"; } /// @@ -113,26 +147,32 @@ public sealed record TrendSeries(DateOnly From, DateOnly To, IReadOnlyList - public string Sentence + public string Sentence(string tag) { - get + if (!HasAnyCount) { - if (!HasAnyCount) - { - return HasAnyMeasurement - ? "Probed in this range, and no player count could be read from any of it." - : "No measurement in this range."; - } + // Neither of these names a cause, and the two are different facts: probed and never + // countable is a measurement of a game that answered, and no measurement is a statement + // about our crawl and not about their game. + return Messages.For(tag, HasAnyMeasurement + ? "trend.none.probed" + : "trend.none.notMeasured"); + } - var counted = Days.Where(d => d.IsCounted).ToList(); - var typical = Median(counted.Select(d => d.Average!.Value)); - var peak = Ceiling; + var counted = Days.Where(d => d.IsCounted).ToList(); + var typical = Median(counted.Select(d => d.Average!.Value)); - var start = $"Typically {Number(typical)} on, peaking at {peak}, " - + $"over {CountedDays} of {Measured(Days.Count)}."; + var start = Messages.Say( + tag, + "trend.summary", + ("typical", Number(typical)), + ("peak", Ceiling), + ("counted", CountedDays), + ("days", Days.Count)); - return Direction(counted) is { } direction ? $"{start} {direction}" : start; - } + // Two sentences and not one with a word substituted in: "Steady across the range" is a whole + // clause, and a language that opens with the direction has nowhere to put it otherwise. + return Direction(tag, counted) is { } direction ? $"{start} {direction}" : start; } /// One line per week, which is the "read as text" disclosure and the plain rendering. @@ -148,13 +188,13 @@ public string Sentence /// with nothing counted still says which kind of nothing it was. /// /// - public IEnumerable PerWeek() + public IEnumerable PerWeek(string tag) { foreach (var week in Days.Chunk(7)) { var span = week.Length == 1 - ? $"{week[0].Date:d MMM}" - : $"{week[0].Date:d MMM}–{week[^1].Date:d MMM}"; + ? Messages.Say(tag, "trend.week.oneDay", ("d", week[0].Date)) + : Messages.Say(tag, "trend.week.span", ("from", week[0].Date), ("to", week[^1].Date)); var counted = week.Where(d => d.IsCounted).ToList(); var uncountable = week.Count(d => d.IsUncountable); @@ -162,31 +202,41 @@ public IEnumerable PerWeek() if (counted.Count == 0) { - yield return uncountable > 0 - ? $"{span}: probed, no count could be read" - : $"{span}: not measured"; + yield return Messages.Say( + tag, + uncountable > 0 ? "trend.week.notCounted" : "trend.week.notMeasured", + ("span", span)); continue; } - var typical = Number(Median(counted.Select(d => d.Average!.Value))); - var high = counted.Max(d => d.Max!.Value); - var line = $"{span}: typically {typical}, peak {high}, {Measured(counted.Count)} counted"; + var line = Messages.Say( + tag, + "trend.week.counted", + ("span", span), + ("typical", Number(Median(counted.Select(d => d.Average!.Value)))), + ("peak", counted.Max(d => d.Max!.Value)), + ("days", counted.Count)); + // Folded through a two-argument message rather than joined on a comma written here: the + // separator belongs to a language, and Chinese uses neither of ours. if (uncountable > 0) { - line += $", {Measured(uncountable)} probed without a count"; + line = Join(tag, line, Messages.Say(tag, "trend.week.uncounted", ("count", uncountable))); } if (unmeasured > 0) { - line += $", {Measured(unmeasured)} not measured"; + line = Join(tag, line, Messages.Say(tag, "trend.week.unmeasured", ("count", unmeasured))); } yield return line; } } + private static string Join(string tag, string line, string clause) => + Messages.Say(tag, "trend.week.and", ("line", line), ("clause", clause)); + /// /// The rollup's buckets as a day per column, with the days it does not hold left as gaps. /// @@ -213,7 +263,7 @@ public static TrendSeries Over(DateOnly from, DateOnly to, IReadOnlyList counted) + private static string? Direction(string tag, IReadOnlyList counted) { if (counted.Count < 6) { @@ -236,16 +286,21 @@ public static TrendSeries Over(DateOnly from, DateOnly to, IReadOnlyList before - ? $"Up about {change}% from the start of the range to the end." - : $"Down about {change}% from the start of the range to the end."; + return Messages.Say( + tag, + after > before ? "trend.direction.up" : "trend.direction.down", + ("change", change)); } + private static double Median(IEnumerable values) { var sorted = values.Order().ToList(); @@ -256,6 +311,4 @@ private static double Median(IEnumerable values) /// A count of players, floored — see for why down. private static string Number(double value) => Math.Floor(value).ToString("0", CultureInfo.InvariantCulture); - - private static string Measured(int n) => n == 1 ? "1 day" : $"{n} days"; } diff --git a/src/MUI.Web/Components/TrendShape.cs b/src/MUI.Web/Components/TrendShape.cs index a71ea8f..19805ce 100644 --- a/src/MUI.Web/Components/TrendShape.cs +++ b/src/MUI.Web/Components/TrendShape.cs @@ -1,3 +1,5 @@ +using MUI.Web.Localization; + namespace MUI.Web.Components; /// How the trend is drawn. @@ -41,6 +43,14 @@ public static TrendShape Parse(string? value) => /// What the address calls it. public static string Slug(this TrendShape shape) => shape is TrendShape.Bar ? "bar" : "line"; - /// What the selector calls it. - public static string Label(this TrendShape shape) => shape is TrendShape.Bar ? "bars" : "line"; + /// + /// What the selector calls it, in the reader's language. + /// + /// + /// Separate from , which is what the address calls it and stays + /// English for ever: a URL a reader copies out of a German page has to keep working when it is + /// pasted into an English one, so the query value is machine voice and only the label is not. + /// + public static string Label(this TrendShape shape, string tag) => + Messages.For(tag, shape is TrendShape.Bar ? "trend.shape.bar" : "trend.shape.line"); } diff --git a/src/MUI.Web/Components/_Imports.razor b/src/MUI.Web/Components/_Imports.razor index cc1b407..dba9550 100644 --- a/src/MUI.Web/Components/_Imports.razor +++ b/src/MUI.Web/Components/_Imports.razor @@ -6,3 +6,6 @@ @using MUI.Web.Components @using MUI.Web.Components.Layout @using MUI.Web.Data +@* Every component writes a link and most already say a sentence, and both go through here: a page + that forgot the using wrote its addresses in English on a page in another language. *@ +@using MUI.Web.Localization diff --git a/src/MUI.Web/CrawlerContact.cs b/src/MUI.Web/CrawlerContact.cs index 6af4deb..15ac98e 100644 --- a/src/MUI.Web/CrawlerContact.cs +++ b/src/MUI.Web/CrawlerContact.cs @@ -1,3 +1,5 @@ +using MUI.Web.Localization; + namespace MUI.Web; /// @@ -39,11 +41,15 @@ public static IEndpointRouteBuilder MapMuiCrawlerContact(this IEndpointRouteBuil // HEAD as well as GET: a link checker asking whether the address we published still works is // exactly the reader this route is for, and MapGet alone answers one with 405. - endpoints.MapMethods(Path, [HttpMethods.Get, HttpMethods.Head], (HttpRequest request) => + endpoints.MapMethods(Path, [HttpMethods.Get, HttpMethods.Head], (HttpContext context) => // The query string travels with it, and the fragment goes last because that is where a // fragment goes: ?plain=1 is a real second surface (§9), and an admin who asked for the - // plain rendering of the page that explains us should get it. - TypedResults.Redirect($"/about{request.QueryString}#{Fragment}")); + // plain rendering of the page that explains us should get it. And the locale travels + // with it too, for a reader who already had one: this lands on a page, and a page is a + // document in a language. + TypedResults.Redirect(LocaleRouting.Link( + context.LocaleOf().Tag, + $"/about{context.Request.QueryString}#{Fragment}"))); return endpoints; } diff --git a/src/MUI.Web/Fixtures/FixtureGameQueries.cs b/src/MUI.Web/Fixtures/FixtureGameQueries.cs index defa936..8f06e62 100644 --- a/src/MUI.Web/Fixtures/FixtureGameQueries.cs +++ b/src/MUI.Web/Fixtures/FixtureGameQueries.cs @@ -19,6 +19,15 @@ namespace MUI.Web.Fixtures; /// states, and those states are the product. /// /// +/// Eldertale and Midnight Sun II are the pair the whole catalogue turns on, and Hollow Bell is +/// the third of them. Both of the first two show a listing row with no number above it, and one is a +/// game we counted and found empty while the other is a game we got into and could not read; Hollow +/// Bell is a game we could not get into at all. Three rows that look alike and three different facts, +/// which is what the uncounted and unreachable switches exist to keep apart — and a +/// fixture holding only one of the three could not tell whether they had been implemented or merely +/// spelled. +/// +/// /// The archived and suppressed entries are the design handoff's own exemplars rather than real /// games, deliberately: asserting that a named game is dead, or that its owner asked us to stop /// republishing it, is exactly the kind of claim this site may not make without a measurement. @@ -79,6 +88,30 @@ public sealed class FixtureGameQueries : IGameQueries, IAvailabilityHistory IsClaimed: true, PlayersNow: 9, Codebase: "Evennia", MeasuredProtocols: ["MSSP", "GMCP", "TLS"], LastReachableAt: Now.AddMinutes(-9)); + /// + /// Stopped answering six weeks ago and has not been archived: unreachable, and not uncounted. + /// + /// + /// + /// The state between "went dark" and "archived", which nothing else here occupies — every other + /// silent game in this fixture is already archived, so the whole of the demo's + /// unreachable facet sat behind the archive switch and the dark activity band was + /// a rung no game stood on. + /// + /// + /// It is the counter-example the pair of switches needs. Midnight Sun answers and cannot + /// be counted; this one cannot be reached at all. Both listing rows show no number and the + /// reasons are opposite, which is exactly the distinction a reader is being offered — and its + /// grid is empty rather than hatched, because we did not get in to fail to count. + /// + /// + private static readonly GameSummary HollowBell = new( + Guid.Parse("aaaaaaaa-0000-0000-0000-00000000000a"), "hollow-bell", "Hollow Bell", + "Has not answered since June. Still probed, and one answer puts it back.", + LifecycleState.Dark, IsClaimed: false, + PlayersNow: null, Codebase: "PennMUSH 1.8.5", MeasuredProtocols: ["MSSP"], + LastReachableAt: Now.AddDays(-44)); + private static readonly GameSummary Gaslight = new( Guid.Parse("aaaaaaaa-0000-0000-0000-000000000005"), "gaslight-row", "Gaslight Row", "Ceased answering in March 2023. We still try the door every week.", @@ -108,7 +141,8 @@ public sealed class FixtureGameQueries : IGameQueries, IAvailabilityHistory [ .. new[] { - Mush, Eldertale, Aardwolf, MidnightSun, Enormous, Ashen, Gaslight, Verdigris, Cinder, + Mush, Eldertale, Aardwolf, MidnightSun, Enormous, Ashen, HollowBell, Gaslight, + Verdigris, Cinder, } .Select(Labelled), ]; @@ -258,13 +292,47 @@ public async Task> ListAsync( // Through the same predicate the database reads, rather than a slug test of its own — the // two implementations disagreeing about which games a filter returns is the failure that // put the filtering itself into one shared function. - IsAdult: AdultContent.Declared(Genre(game), AdultMaterial(game))); + IsAdult: AdultContent.Declared(Genre(game), AdultMaterial(game)), + Uncounted: Uncounted(game), + + // The same rule and the same constant the Postgres reader applies, for the same reason the + // line above is derived rather than declared. + Unreachable: FacetedSearch.NotReachedRecently(game.LastReachableAt, Now)); + + /// + /// Whether every hour this game answered in produced no number. + /// + /// + /// + /// Read off the fixture's own grid rather than listed beside it, so the panel cannot + /// promise something the heatmap on the game's page contradicts — the same argument + /// is derived by. A slug switch here would let the demo advertise + /// "uncounted" over a week of filled cells. + /// + /// + /// Both clauses matter, and the fixture exercises both. Eldertale is a measured zero in + /// every hour: counted, and emphatically not this. Hollow Bell answered in no hour at all: not + /// measured, which names no cause and is not this either. Midnight Sun answered in every hour and + /// produced a number in none, which is the one game here that is uncounted. Every other game has + /// one hatched hour beside its counts and is not uncounted for it. + /// + /// + private static bool Uncounted(GameSummary game) + { + var week = Activity(game); + + return week.Any(c => c.IsUnmeasurable) && !week.Any(c => c.IsCounted); + } private static ActivityBand Band(GameSummary g) => g.Slug switch { "gaslight-row" or "verdigris" => ActivityBand.Archived, "eldertale" => ActivityBand.ActiveThisWeek, "midnight-sun" => ActivityBand.Quiet, + + // Not reached in six weeks, and not archived — the one rung of this scale no fixture game + // stood on, so the demo panel never drew it. + "hollow-bell" => ActivityBand.Dark, _ => ActivityBand.PlayersNow, }; @@ -392,6 +460,7 @@ public Task> ForGameAsync( "midnight-sun" => [Endpoint("midnightsun2.org", 3000, "telnet", tls: false, sinceDays: 1500)], "batmud" => [Endpoint("bat.org", 23, "telnet", tls: false, sinceDays: 5000)], "ashen-court" => [Endpoint("ashen.example", 4000, "tls", tls: true, sinceDays: 600)], + "hollow-bell" => [Endpoint("hollowbell.example", 4201, "telnet", tls: false, sinceDays: 2400)], "gaslight-row" => [Endpoint("gaslight.example", 4201, "telnet", tls: false, sinceDays: 3000)], "verdigris" => [Endpoint("verdigris.example", 6250, "telnet", tls: false, sinceDays: 800)], _ => [Endpoint("eldertale.example", 4000, "telnet", tls: false, sinceDays: 1200)], @@ -583,6 +652,15 @@ AvailabilityInterval Span(double fromDaysAgo, double? toDaysAgo, AvailabilitySta Span(60, 58, AvailabilityState.Unreachable, FailureCause.Timeout), Span(58, null, AvailabilityState.Reachable, FailureCause.None), ], + + // Answered for years and then stopped, with the run still open. This is what the + // `unreachable` facet actually reads: an interval, which can say we tried and got + // nothing — where the empty half of a heatmap could not, and may not be asked to. + "hollow-bell" => + [ + Span(2400, 44, AvailabilityState.Reachable, FailureCause.None), + Span(44, null, AvailabilityState.Unreachable, FailureCause.Timeout), + ], _ => [ Span(4000, 51, AvailabilityState.Reachable, FailureCause.None), @@ -731,8 +809,9 @@ public Task FeedsAsync(CancellationToken cancellationToken = defa ], WentDark: [ - Event("verdigris", Now.AddDays(-6), - "unreachable since 24 July · connection refused · page stays, we keep knocking weekly"), + // The cause, and not the promise to keep probing: the section heading says that + // once, rather than every row saying it again. + Event("verdigris", Now.AddDays(-6), "connection refused"), ], CameBack: [ diff --git a/src/MUI.Web/Localization/Glossary.cs b/src/MUI.Web/Localization/Glossary.cs new file mode 100644 index 0000000..698282f --- /dev/null +++ b/src/MUI.Web/Localization/Glossary.cs @@ -0,0 +1,211 @@ +namespace MUI.Web.Localization; + +/// +/// The grammatical gender and number of the noun a provenance word describes. +/// +/// +/// Metadata for the translator, not a fact about English. "Measured" is one string in +/// English and four in Russian — измерен / измерена / измерено / измерены — chosen by the gender and +/// number of whatever it describes, and this site applies that one word to a count, a game, a value, +/// a capability and a connect screen. A single reused string is guaranteed to be wrong somewhere, +/// and a wrong-gender adjective does not read as a typo to a native speaker; it reads as illiterate, +/// which is an expensive impression for a site whose credibility is its product. +/// +public enum Subject +{ + /// The word modifies nothing — a bare column kicker. + Standalone, + + Masculine, + Feminine, + Neuter, + Plural, +} + +/// One locked string: what it must go on meaning, and what it may not become. +/// The context-keyed id. Never shared between two contexts. +/// The source text, which is also the fallback. +/// What the word agrees with, where it agrees with anything. +/// +/// Why this string is locked, shipped to translators as the brief rather than withheld. +/// +public sealed record LockedString(string Id, string English, Subject Subject, string Rationale); + +/// +/// The strings a translation may not paraphrase. +/// +/// +/// +/// Every row here exists because a plausible synonym in the target language would make the site +/// claim something it did not measure. That is the only kind of localization bug that damages +/// this product rather than merely embarrassing it. "not measured", "uncounted", +/// "reachable, count unreadable" and "unreachable" are four distinct states and the whole argument +/// of the site is that it never conflates them — and a translation engine treats them as stylistic +/// variants of *unavailable* and picks whichever target phrase is most frequent. In German all four +/// plausibly become "nicht verfügbar", and the reader can no longer tell a game that answered from +/// one that did not. +/// +/// +/// Ownership is maintainers only, and the rationale ships with the string. Contributors +/// translate everything outside this file freely. Inside it, a change is a maintainer action — and +/// the reason is published alongside each entry so a contributor can see why a string is locked +/// rather than reading the lock as distrust. A well-meaning "improvement" that arrives as a pull +/// request nobody knows how to refuse is the failure this is written to prevent. +/// +/// +/// Where a locale has no approved translation for a locked string, the English shows. A +/// reader who meets one English phrase inside a German sentence learns something true — that this +/// particular claim has not been translated yet. A smoothed-over mistranslation teaches them +/// something false, and they have no way to tell. +/// +/// +/// And never machine-translate these, even temporarily. A sighted reader who meets a garbled +/// label can look at the graphic and correct for it. A blind reader has only the label: a bad +/// translation of alt text or of a provenance word is the one case where a wrong string leaves +/// somebody with no way to recover, and it is precisely the case where English would have served +/// them better. +/// +/// +public static class Glossary +{ + /// Every locked id, with its English and the reason it is locked. + public static IReadOnlyList Locked { get; } = + [ + // ── provenance, per context ────────────────────────────────────────────────────────── + // One id per subject, because English collapses four Russian forms into one word. Never let + // two contexts share an id just because the source language cannot tell them apart. + new("provenance.count.measured", "measured", Subject.Feminine, + "We connected and read this number ourselves. The strongest claim on the site; must not " + + "weaken to 'verified' or 'confirmed', which imply a second party checked it."), + new("provenance.game.measured", "measured", Subject.Neuter, + "The same claim as provenance.count.measured, agreeing with the game rather than with the " + + "count. Separate id because English collapses the two and inflected languages do not."), + new("provenance.capability.measured", "measured", Subject.Plural, + "The same claim again, agreeing with a set of capabilities — plural, where the two above are " + + "singular. A shared id would be wrong here in every gendered language."), + new("provenance.screen.measured", "measured", Subject.Masculine, + "The same claim once more, agreeing with the connect screen. Four subjects, four ids: this is " + + "the entry that shows why the id space is keyed by context and not by word."), + new("kicker.measured", "measured", Subject.Standalone, + "A bare column header, modifying nothing — which is why it needs an id of its own rather " + + "than borrowing one that agrees with something."), + + new("provenance.count.declared", "declared", Subject.Feminine, + "The game said so and we did not check. Must not become a synonym of measured: the whole " + + "distinction is that this one is hearsay."), + new("provenance.game.declared", "declared", Subject.Neuter, + "The same hearsay claim as provenance.count.declared, agreeing with the game rather than with " + + "the count. Separate id for the same reason its measured counterpart has one."), + new("provenance.capability.declared", "declared", Subject.Plural, + "The same claim again, agreeing with a set of capabilities — plural, where the two above are " + + "singular. A shared id would be wrong here in every gendered language."), + new("kicker.declared", "declared", Subject.Standalone, + "The bare column header, modifying nothing — which in an inflected language is a different " + + "form from any of the three above, and so needs an id of its own."), + + new("provenance.derived", "derived", Subject.Neuter, + "We computed it from things we measured. Not observed directly, and not claimed by " + + "anybody — the third state exists so neither of the other two has to stretch."), + new("kicker.derived", "derived", Subject.Standalone, + "The bare column header, modifying nothing. Same argument as the two kickers above."), + + // ── the four kinds of absence, which are four different facts ──────────────────────── + new("state.notMeasured", "not measured", Subject.Standalone, + "We have not looked yet. Distinct from every kind of failure — must not become " + + "'unavailable', which implies we tried and something refused."), + new("state.uncounted", "uncounted", Subject.Standalone, + "The game answered and we could not read a count. Never zero, never empty. The string " + + "most likely to be mistranslated into 'no players', which is defamatory to a busy game."), + new("state.unreachable", "unreachable", Subject.Standalone, + "We tried and got nothing back. Must not imply the game is gone: the site keeps probing " + + "for ever and games come back."), + new("state.notCounted", "not counted", Subject.Standalone, + "The listing cell's own wording for an unreadable count. Never rendered as 0."), + + // ── the words the whole product rests on ───────────────────────────────────────────── + new("term.connected", "connected", Subject.Standalone, + "A count of open connections, not of people. Must not become 'players', 'users' or " + + "'online members' — one person with two clients is two connections and a bot is a " + + "connection. The grammatically safe phrasing is also the more truthful one."), + new("term.unclaimed", "unclaimed", Subject.Standalone, + "No owner has taken over the record. Administrative, and not a judgement about the " + + "game's quality or activity."), + new("term.claimedByOwner", "claimed by its owner", Subject.Standalone, + "Names the role, never the person, and assumes no gender for somebody real. The passive " + + "is the point rather than a shortening."), + new("term.stillProbed", "still probed", Subject.Standalone, + "We keep trying even though it is dark. Preserves the promise that a game which comes " + + "back will be noticed."), + new("term.typical", "typical", Subject.Standalone, + "A median over a stated window. A statistical term — must not collapse into 'average' " + + "with the one below it."), + new("term.peak", "peak", Subject.Standalone, + "The highest count seen in a stated window. Must not collapse into 'average'."), + + // ── the two accessibility promises ─────────────────────────────────────────────────── + new("a11y.readAsText", "read as text", Subject.Standalone, + "The label on every graphic's text alternative. Must read as an EQUIVALENT, not a " + + "summary or a fallback: a reader choosing it should expect nothing to be missing."), + new("a11y.plainText", "plain text", Subject.Standalone, + "The ?plain=1 mirror. Must not become 'simple' or 'basic', which suggest a reduced " + + "version — it is the complete page without the graphics."), + new("a11y.skipToContent", "skip to content", Subject.Standalone, + "The first thing a keyboard reader meets on every page."), + new("a11y.asciiBanner", "ASCII banner: the connect screen of {game}.", Subject.Standalone, + "The one-line alternative for a connect screen's artwork, and a blind reader's only " + + "route into that block. It names the drawing's kind and the game, and never describes " + + "the art: a paragraph about somebody else's ASCII would be our reading of their work " + + "presented as a fact about their game. Keep {game} exactly as it is."), + ]; + + private static readonly Dictionary ById = + Locked.ToDictionary(l => l.Id, StringComparer.Ordinal); + + /// Whether an id may only be changed by a maintainer. + public static bool IsLocked(string id) => ById.ContainsKey(id); + + /// The locked entry for an id, or null. + public static LockedString? Of(string id) => ById.GetValueOrDefault(id); + + /// + /// The brief a translator is handed, rather than a spreadsheet of bare strings. + /// + /// + /// Generated from the same data the site renders, so the document a translator works from cannot + /// drift from the strings that ship. The subject is stated because that is what an inflected + /// language keys agreement off, and it is invisible in the English. + /// + public static string Brief() + { + var b = new System.Text.StringBuilder(); + + b.AppendLine("# The locked glossary"); + b.AppendLine(); + b.AppendLine("Every string below is locked. Translate it, and preserve what the note says it"); + b.AppendLine("means — a plausible synonym here makes the site claim something it did not"); + b.AppendLine("measure, which is the only kind of localization bug that damages the product."); + b.AppendLine(); + b.AppendLine("Where you have no approved translation, leave it: the English will show, and a"); + b.AppendLine("reader who meets one English phrase learns something true."); + b.AppendLine(); + + foreach (var entry in Locked) + { + b.AppendLine($"## {entry.Id}"); + b.AppendLine(); + b.AppendLine($" {entry.English}"); + b.AppendLine(); + + if (entry.Subject is not Subject.Standalone) + { + b.AppendLine($"Agrees with: {entry.Subject.ToString().ToLowerInvariant()}."); + b.AppendLine(); + } + + b.AppendLine(entry.Rationale); + b.AppendLine(); + } + + return b.ToString(); + } +} diff --git a/src/MUI.Web/Localization/IcuMessage.cs b/src/MUI.Web/Localization/IcuMessage.cs new file mode 100644 index 0000000..85efd4d --- /dev/null +++ b/src/MUI.Web/Localization/IcuMessage.cs @@ -0,0 +1,374 @@ +using System.Collections.Concurrent; +using System.Globalization; +using System.Text; + +namespace MUI.Web.Localization; + +/// +/// ICU MessageFormat, rendered. +/// +/// +/// +/// What it is for. 545 on, 23 games and 7 days measured · 168 probes each +/// glue a number to an English fragment in English word order, and there is nowhere in that for a +/// translator to intervene without editing markup. One message per fact, the count as a named +/// argument and the plural clause written out in full is what gives them somewhere to stand — and +/// it is what stopped this site rendering "1 games" on every accessible name in its facet panel. +/// +/// +/// The whole of MessageFormat 1.0. Simple arguments, number, date and +/// time with styles and skeletons, plural and selectordinal with offset: +/// and =value matches, select, arbitrary nesting, and ICU's apostrophe quoting in its +/// default mode. See for the parse and for why choice is refused +/// and MessageFormat 2.0 is not yet the target. +/// +/// +/// Patterns are parsed once. A message is rendered on every request that draws the surface it +/// belongs to — the facet panel alone renders forty of them per page — and re-parsing a string that +/// has not changed since startup is work nobody asked for. The cache is keyed on the pattern text +/// because that is the only thing the parse depends on; the locale is applied at format time. +/// +/// +/// The cache is unbounded, and that is safe only because of what may be passed to it. A +/// pattern comes from a resource bundle or from a literal in this repository, so the set of distinct +/// keys is fixed when the process starts and is a few hundred entries. A pattern built from +/// request data must never reach or — a querystring +/// or a game's own name interpolated into a pattern would make this dictionary grow without limit +/// for as long as somebody kept asking. Interpolate into an argument, which is not cached +/// and not parsed; the pattern is the part that has to be written down in advance. +/// +/// +public static class IcuMessage +{ + private static readonly ConcurrentDictionary Parsed = new(StringComparer.Ordinal); + + private static readonly IReadOnlyDictionary NoArguments = + new Dictionary(StringComparer.Ordinal); + + /// Renders for . + /// The pattern is malformed, or an argument is missing. + public static string Format( + string pattern, + string tag, + IReadOnlyDictionary? arguments = null) + { + ArgumentNullException.ThrowIfNull(pattern); + ArgumentNullException.ThrowIfNull(tag); + + var b = new StringBuilder(pattern.Length); + + Render(Compile(pattern), tag, arguments ?? NoArguments, b, hash: null); + + return b.ToString(); + } + + /// The parsed form of a pattern, from the cache or freshly parsed. + public static MessagePattern Compile(string pattern) + { + ArgumentNullException.ThrowIfNull(pattern); + + return Parsed.GetOrAdd(pattern, MessagePattern.Parse); + } + + private static void Render( + MessagePattern message, + string tag, + IReadOnlyDictionary args, + StringBuilder b, + string? hash) + { + foreach (var part in message.Parts) + { + switch (part) + { + case LiteralPart literal: + b.Append(literal.Text); + break; + + case HashPart: + // Outside a plural branch the parser never produces one of these, so a `#` that + // reaches here always has a number behind it. + b.Append(hash); + break; + + case ArgumentPart argument: + Argument(argument, tag, args, b, hash); + break; + } + } + } + + private static void Argument( + ArgumentPart argument, + string tag, + IReadOnlyDictionary args, + StringBuilder b, + string? hash) + { + var culture = Culture(tag); + + if (!args.TryGetValue(argument.Name, out var value)) + { + throw new FormatException($"No argument named '{argument.Name}' was supplied."); + } + + switch (argument.Kind) + { + case ArgumentKind.None: + b.Append(Convert.ToString(value, culture)); + return; + + case ArgumentKind.Number: + b.Append(Number(argument.Name, value, argument.Style, culture)); + return; + + case ArgumentKind.Date: + case ArgumentKind.Time: + b.Append(Temporal(value, argument.Kind, argument.Style, culture)); + return; + + case ArgumentKind.Plural: + case ArgumentKind.SelectOrdinal: + Plural(argument, tag, args, b, value, culture); + return; + + case ArgumentKind.Select: + var chosen = Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty; + + Render( + argument.Branches.GetValueOrDefault(chosen) ?? argument.Branches["other"], + tag, args, b, hash); + return; + } + } + + private static void Plural( + ArgumentPart argument, + string tag, + IReadOnlyDictionary args, + StringBuilder b, + object? value, + CultureInfo culture) + { + var number = Quantity(argument.Name, value); + var operands = value is PluralOperands given ? given : PluralOperands.Of(number); + var kind = argument.Kind is ArgumentKind.SelectOrdinal + ? PluralKind.Ordinal + : PluralKind.Cardinal; + + // An `=value` match is tested against the number as written, before the offset — ICU says + // so, and it is what lets "=0 {nobody}" work in a message that also subtracts one. + var exact = "=" + Plain(number, operands.V); + + if (argument.Branches.TryGetValue(exact, out var matched)) + { + Render(matched, tag, args, b, Hash(number, operands.V, culture)); + return; + } + + // The category, and `#`, are both taken from the offset-adjusted number. "{n, plural, + // offset:1 other {and # others}}" over three people is "and 2 others". + var adjusted = number - argument.Offset; + var keyword = PluralRules.Keyword( + PluralRules.Of(tag, PluralOperands.Of(adjusted, operands.V), kind)); + + var branch = argument.Branches.GetValueOrDefault(keyword) ?? argument.Branches["other"]; + + Render(branch, tag, args, b, Hash(adjusted, operands.V, culture)); + } + + /// The signed number a caller passed, or a refusal naming the argument. + private static decimal Quantity(string name, object? value) => value switch + { + int i => i, + long l => l, + short s => s, + byte by => by, + decimal d => d, + double db => (decimal)db, + float f => (decimal)f, + + // Absolute by construction: the operands CLDR states are, so a caller handing them over + // directly has already discarded whatever sign the quantity had. + PluralOperands o => o.N, + + null => throw new FormatException($"'{name}' is a plural argument and was null."), + _ => throw new FormatException( + $"'{name}' is a plural argument and must be a number, not {value.GetType().Name}."), + }; + + /// + /// The number a # stands for, written exactly as {n, number} would write it. + /// + /// + /// + /// One bundle may not print one quantity two ways. # was the raw integer digits + /// and {n, number} was the culture's grouped form, so a listing past a thousand said + /// "1234 games" in a sentence and "1,234" in the column beside it — and in German the separator + /// is a full stop, which no invariant rendering reaches at all. ICU replaces # with the + /// argument's own formatted number, so the default pattern here is the default pattern there. + /// + /// + /// The sign travels with it. CLDR takes the absolute value to choose a category and never to + /// display one, and "3 games" for minus three is a measurement stated backwards. + /// + /// + private static string Hash(decimal number, int visibleFractionDigits, CultureInfo culture) => + number.ToString( + visibleFractionDigits == 0 ? "#,##0" : "#,##0." + new string('0', visibleFractionDigits), + culture); + + /// The same number as an = key, which is matched and never shown to anybody. + /// + /// Plain and invariant on purpose: it is compared against what a message author typed between + /// the braces, and a translator writing =1000 should not have to know which separator + /// their locale would have inserted. + /// + private static string Plain(decimal number, int visibleFractionDigits) => + number.ToString( + visibleFractionDigits == 0 ? "0" : "0." + new string('0', visibleFractionDigits), + CultureInfo.InvariantCulture); + + /// + /// {n, number, style}, with ICU's named styles and its :: skeletons. + /// + /// + /// Nothing on this site calls it, and it is here anyway. Counts, versions and ages are + /// machine output and stay in Western digits in every locale — Arabic-Indic digits have no + /// tabular figures in most faces, so a localized count column loses the alignment that is the + /// only reason it is a column. What this covers is prose: a percentage inside a sentence is a + /// number a reader reads rather than scans, and that one localizes. + /// + private static string Number(string name, object? value, string? style, CultureInfo culture) + { + decimal number; + + try + { + number = Convert.ToDecimal(value, CultureInfo.InvariantCulture); + } + catch (Exception e) when (e is InvalidCastException or OverflowException or FormatException) + { + // The documented contract on Format is FormatException, and Convert raises two others + // this could not have said anything about. Neither of them names the argument, and a + // page rendering forty messages needs to be told which one it was. + throw new FormatException( + $"'{name}' is a number argument and must be a number, " + + $"not {value?.GetType().Name ?? "null"}.", e); + } + + if (style is { Length: > 0 } && style.StartsWith("::", StringComparison.Ordinal)) + { + return Skeleton(number, style[2..].Trim(), culture); + } + + return style switch + { + null or "" => number.ToString("#,##0.###", culture), + "integer" => Math.Round(number, MidpointRounding.ToEven).ToString("#,##0", culture), + // Built rather than "P0": .NET's percent pattern inserts a space before the sign in + // several cultures and ICU's does not, so the two disagree on en for no reason a + // message author could predict. + "percent" => (number * 100m).ToString("#,##0.###", culture) + culture.NumberFormat.PercentSymbol, + "currency" => number.ToString("C", culture), + + // Anything else is a .NET format string, which is what ICU does with an unrecognised + // style too: it hands it to the underlying number formatter. + _ => number.ToString(style, culture), + }; + } + + /// The handful of number skeletons that mean anything without a full ICU behind them. + private static string Skeleton(decimal number, string skeleton, CultureInfo culture) + { + var parts = skeleton.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var format = "#,##0.###"; + var percent = false; + + foreach (var token in parts) + { + if (token is "percent") + { + percent = true; + continue; + } + + // .00 / .0 / .### — the fraction-precision stem, which is the one anybody writes. + if (token.StartsWith('.')) + { + format = "#,##0" + token; + continue; + } + + if (token is "group-off") + { + format = format.Replace("#,##0", "0", StringComparison.Ordinal); + } + } + + var rendered = (percent ? number * 100m : number).ToString(format, culture); + + return percent ? rendered + culture.NumberFormat.PercentSymbol : rendered; + } + + /// + /// {d, date, style} and {d, time, style}. + /// + /// + /// Also uncalled, and also deliberate. Every date this site prints goes through + /// , which states UTC explicitly and uses an abbreviated month + /// name rather than a numeric one — 08/17 means two different days on two continents. + /// This exists so a message can carry a date without the formatter refusing the + /// pattern, which is the difference between supporting the grammar and supporting the half of + /// it we happen to use. + /// + private static string Temporal(object? value, ArgumentKind kind, string? style, CultureInfo culture) + { + var when = value switch + { + DateTimeOffset offset => offset, + + // An Unspecified kind takes the machine's own offset from this constructor, so the same + // message rendered on two hosts said two different times and neither said which. This + // site is UTC everywhere on purpose — every date it prints says so — and a date that + // arrived without a zone did not arrive from somewhere else. + DateTime { Kind: DateTimeKind.Unspecified } bare => new DateTimeOffset(bare, TimeSpan.Zero), + DateTime dt => new DateTimeOffset(dt), + + // A day-grain fact carries a DateOnly, and every day-grain surface on this site — the + // trend chart's ninety columns, the reachability strip's ninety bars — has one to say. + // Refusing it would have meant each of those formatting its own date at the call site, + // which is the hard-coded month name this argument exists to remove. + DateOnly day => new DateTimeOffset(day.ToDateTime(TimeOnly.MinValue), TimeSpan.Zero), + _ => throw new FormatException($"A {kind} argument must be a date, not {value?.GetType().Name ?? "null"}."), + }; + + var date = kind is ArgumentKind.Date; + + if (style is { Length: > 0 } && style.StartsWith("::", StringComparison.Ordinal)) + { + // A date skeleton names the fields it wants; .NET has no skeleton engine, so the + // closest honest thing is the culture's own long or short pattern. + style = style.Contains('y', StringComparison.Ordinal) ? "long" : "short"; + } + + return style switch + { + null or "" or "medium" => when.ToString(date ? "d MMM yyyy" : "HH:mm:ss", culture), + "short" => when.ToString(date ? "d" : "t", culture), + "long" => when.ToString(date ? "D" : "T", culture), + "full" => when.ToString(date ? "D" : "T", culture), + _ => when.ToString(style, culture), + }; + } + + /// + /// The culture a tag names, or the invariant one where .NET has never heard of it. + /// + /// + /// 's cache rather than one of its own: the number formats a + /// message renders and the day names a sentence names are the same CLDR data, and two lookups + /// of one tag are two places for it to be answered differently. + /// + private static CultureInfo Culture(string tag) => Locales.CultureOf(tag); +} diff --git a/src/MUI.Web/Localization/LocaleRouting.cs b/src/MUI.Web/Localization/LocaleRouting.cs new file mode 100644 index 0000000..7e4ddef --- /dev/null +++ b/src/MUI.Web/Localization/LocaleRouting.cs @@ -0,0 +1,565 @@ +using Microsoft.AspNetCore.Http.Extensions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace MUI.Web.Localization; + +/// Which locale this request is being answered in, and how it was decided. +/// The locale in force. +/// +/// Whether a path segment named it — which is what makes a locale linkable, cacheable and +/// indexable rather than a property of whoever's cookie jar the request arrived with. +/// +public sealed record LocaleContext(Locale Locale, bool FromPath) +{ + /// The tag, which is what every message lookup is keyed on. + public string Tag => Locale.Tag; + + /// The source locale, for a request nothing has decided yet. + public static LocaleContext Default { get; } = new(Locales.Source, FromPath: false); +} + +/// +/// The locale in the path, and the two ways a reader gets one. +/// +/// +/// +/// In the path and not in a header. A locale that lives only in a cookie or in +/// Accept-Language gives one URL two bodies: a shared link opens in the sender's language for +/// them and the recipient's for everybody else, a cache in front of the site serves whichever +/// arrived first to whoever asks next, and a search engine indexes one of them arbitrarily. +/// /de/games?plain=1 is one address for one document, which is the same argument the +/// querystring already wins for the filters. +/// +/// +/// The source locale has no prefix. /games is the canonical English address and +/// /en/games redirects to it, because two URLs for one document is the thing this is +/// avoiding — and every link written across this site, every bookmark and every inbound link +/// already spells the unprefixed one. +/// +/// +/// Accept-Language decides the first visit and nothing after it. A header is a +/// standing preference about content in general and not a choice about this site, so it is worth one +/// redirect and no more: once a reader has chosen, the cookie is what answers, and a reader who +/// deliberately opened the English page must not be bounced out of it by their browser's settings on +/// every request. +/// +/// +public static class LocaleRouting +{ + /// Where the middleware leaves its answer for the rest of the request. + public const string ItemKey = "mui.locale"; + + /// + /// Whether this request is being answered by a deployment somebody is reviewing. + /// + /// + /// + /// Asked of the request, because the alternatives were a global and an injection and both + /// had already failed. A component taking IWebHostEnvironment as a dependency cannot + /// be rendered without a web host behind it, and every headless component test in this suite + /// renders one without. A static bool written from composition fixed that and bought a + /// worse problem: a test process starts many hosts, in Development and in Production, so the + /// last one to start decided what the switcher listed on every request served by any of them. + /// + /// + /// The request's own services answer it, and a request that has none — a component rendered + /// with no HttpContext at all — is not a review build. Nothing has to be told; the + /// question simply has an answer wherever it is asked. + /// + /// + public static bool IsReviewBuild(this HttpContext? context) => + context?.RequestServices?.GetService()?.IsDevelopment() is true; + + /// The locale this request is being answered in. + public static LocaleContext LocaleOf(this HttpContext? context) => + context?.Items.TryGetValue(ItemKey, out var found) is true && found is LocaleContext ctx + ? ctx + : LocaleContext.Default; + + /// Reads the locale out of the path, or decides one for a request that carries none. + public static IApplicationBuilder UseMuiLocale(this IApplicationBuilder app) + { + ArgumentNullException.ThrowIfNull(app); + + return app.Use(async (context, next) => + { + var path = context.Request.Path.Value ?? "/"; + var segment = FirstSegment(path); + + // The same path, still escaped, for anything written into a Location header. + // + // Request.Path.Value is decoded: a segment containing %2F comes back as a bare slash and + // becomes two segments, a %23 comes back as a `#` and truncates the target at the + // fragment, a %3F becomes a `?` and turns the rest of the path into a query, and every + // non-ASCII character comes back raw into a header field that may not carry one. A + // reader following that redirect is sent somewhere they did not ask for. Splitting on + // '/' is still right here, because in the escaped form a literal slash is the only + // separator and an encoded one is three characters that are not it. + var escaped = context.Request.Path.ToUriComponent(); + + if (Locales.Find(segment) is { } named) + { + // The canonical English address carries no prefix, so /en/... is a second URL for a + // document that already has one. Permanent, because it always was one — but only for + // a request a redirect can carry. A 301 answering a POST is followed as a GET with + // the body dropped, so /en/theme would lose the theme it was posted. + if (named.Tag == Locales.SourceTag) + { + if (Redirectable(context.Request)) + { + context.Response.Redirect(Rest(escaped) + context.Request.QueryString, permanent: true); + return; + } + + context.Items[ItemKey] = LocaleContext.Default; + context.Request.Path = Rest(path); + + await next(context); + return; + } + + context.Items[ItemKey] = new LocaleContext(named, FromPath: true); + + // The rest of the pipeline routes, links and renders as though the prefix were not + // there — which is what lets every @page directive stay written once. + context.Request.PathBase = context.Request.PathBase.Add("/" + segment); + context.Request.Path = Rest(path); + + await next(context); + return; + } + + // No prefix, and nothing here may move this request. A locale is a property of a + // document, so only a request for a document is worth relocating. + if (!Redirectable(context.Request)) + { + context.Items[ItemKey] = Remembered(context) ?? LocaleContext.Default; + + await next(context); + return; + } + + // A reader who has chosen is sent to their choice; a reader who has not is offered one + // exactly once, off the header their browser sends. + var remembered = Locales.Find(context.Request.Cookies[Locales.CookieName]); + + if (remembered is { IsChoosable: true } && remembered.Tag != Locales.SourceTag) + { + context.Response.Redirect("/" + remembered.Tag + escaped + context.Request.QueryString); + return; + } + + if (remembered is null && Preferred(context.Request.Headers.AcceptLanguage) is { } guessed) + { + context.Response.Redirect("/" + guessed.Tag + escaped + context.Request.QueryString); + return; + } + + context.Items[ItemKey] = LocaleContext.Default; + + await next(context); + }); + } + + /// + /// Whether this request is one a locale redirect may move. + /// + /// + /// + /// The locale was a one-way door, and the switcher out of it was the thing it shut. A 302 + /// answering a POST is followed as a GET with the body discarded, and every control on this site + /// is a form: with mui_locale=de set, POST /theme was answered with a redirect to + /// /de/theme, which no endpoint serves, so a German reader could not change the theme — + /// and POST /locale went the same way, so they could not change the language back either. + /// + /// + /// The API and the crawler's own files are excluded for a different reason. They are not + /// documents in a language: /api/games answers the same JSON to every reader — it is + /// pinned to the source locale by name — and robots.txt and sitemap.xml have one + /// canonical address each, which is where a crawler looks and where the sitemap says they are. + /// Bouncing them through a prefix cost a round trip and published a second URL for a file that + /// is supposed to have exactly one. + /// + /// + private static bool Redirectable(HttpRequest request) => + (HttpMethods.IsGet(request.Method) || HttpMethods.IsHead(request.Method)) + && !IsUnlocalized(request.Path); + + /// The paths that are the same in every language, so a prefix says nothing about them. + public static bool IsUnlocalized(PathString path) => + path.StartsWithSegments(Api.ApiRoutes.Base, StringComparison.OrdinalIgnoreCase) + || IsFile(path); + + /// + /// The extensions this site serves as files rather than as documents. + /// + /// + /// + /// Named by extension because the stylesheet's address is not knowable. It carries a + /// content fingerprint — /app.gt0hup1p9v.css — which changes whenever the bytes do, so no + /// list of paths can hold it, and a reader with a locale cookie paid a redirect for the + /// stylesheet, the script and the touch icon on every page load. A 302 that answers a + /// request for a file is pure latency: the bytes are the same in every language. + /// + /// + /// An allowlist rather than "the last segment has a dot", because {Slug} is a route + /// parameter and a game's slug is not this file's to make promises about. A game called + /// foo.css would still be wrong, and wrong by one missing prefix on one page rather than + /// by a 404 — the request is answered in the reader's locale either way, because a request that + /// is not redirected still reads the cookie. + /// + /// + private static readonly string[] FileExtensions = + [ + ".css", ".js", ".map", ".json", ".xml", ".txt", ".webmanifest", + ".png", ".svg", ".ico", ".jpg", ".jpeg", ".gif", ".webp", ".avif", + ".woff", ".woff2", ".ttf", + ]; + + /// Whether this path names a file, which is the same file in every language. + private static bool IsFile(PathString path) + { + var value = path.Value; + + if (string.IsNullOrEmpty(value)) + { + return false; + } + + var lastSegment = value.AsSpan()[(value.LastIndexOf('/') + 1)..]; + var dot = lastSegment.LastIndexOf('.'); + + if (dot < 0) + { + return false; + } + + var extension = lastSegment[dot..]; + + foreach (var known in FileExtensions) + { + if (extension.Equals(known, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + /// The locale a reader has already chosen, where they have chosen one. + private static LocaleContext? Remembered(HttpContext context) => + Locales.Find(context.Request.Cookies[Locales.CookieName]) is { IsChoosable: true } chosen + ? new LocaleContext(chosen, FromPath: false) + : null; + + /// + /// The switcher's endpoint: remember a choice, and go back to the same page in it. + /// + /// + /// + /// Anti-forgery is off here, deliberately, and on the same reasoning as the theme control + /// beside it. Everything a forged post can achieve is that the victim's next page view is + /// in a language they did not pick — visible on arrival, stated in their own language's name in + /// the switcher, and undone by one click of the control that is already on the page. Nothing is + /// read, nothing is written that survives the reader clearing it, and this site holds no + /// user-specific state beyond a theme and a language. + /// + /// + /// The price of buying protection against that is not a token: it is an + /// <AntiforgeryToken /> in the header of every page, because that is where + /// the switcher is. The token rides in a cookie, so it would put a Set-Cookie on every + /// response from a site that otherwise sets none for a signed-out reader, and make every one of + /// those responses uncacheable by anything in front of us — and then answer a reader whose + /// cached page outlived its token with a 400 where they expected a language. + /// + /// + /// SameSite=Lax is not what makes this safe, and it is worth being exact about why. + /// SameSite governs when a cookie is sent, not whether one may be set: a + /// cross-site form post is a top-level navigation, the Set-Cookie in the answer is + /// stored, and a later top-level GET does carry a Lax cookie. The attack works. What makes it + /// not worth defending against is its consequence, which is that a stranger can change the + /// language of one page view. + /// + /// + /// The measure that would be worth having is a Sec-Fetch-Site check, which costs no + /// token, no cookie, no cache entry and no script. It belongs to this endpoint and the theme + /// endpoint together — one control guarded and its twin not is worse than neither — so it is a + /// change of its own rather than a line here. + /// + /// + public static IEndpointRouteBuilder MapMuiLocale(this IEndpointRouteBuilder endpoints) + { + ArgumentNullException.ThrowIfNull(endpoints); + + endpoints.MapPost(Locales.Path, async context => + { + var form = await context.Request.ReadFormAsync(); + var chosen = Locales.Find(form[Locales.Field]) ?? Locales.Source; + var back = Back(form[Locales.ReturnField]); + + var options = new CookieOptions + { + Path = "/", + HttpOnly = true, + SameSite = SameSiteMode.Lax, + + // Essential in the sense the consent rules mean: it is which language a reader + // asked to read in, held because they asked, and it identifies nobody. + IsEssential = true, + Secure = context.Request.IsHttps, + MaxAge = TimeSpan.FromDays(365), + }; + + if (chosen.Tag == Locales.SourceTag) + { + // The source language is the absence of a choice rather than a choice of its own, + // which is what stops a reader who picks English being pinned out of a future + // Accept-Language answer. Same shape as the theme control's "auto". + context.Response.Cookies.Delete(Locales.CookieName, options); + context.Response.Redirect(back); + return; + } + + context.Response.Cookies.Append(Locales.CookieName, chosen.Tag, options); + context.Response.Redirect(Link(chosen.Tag, back)); + }); + + return endpoints; + } + + /// + /// An address on this site, in the locale the page carrying it is being read in. + /// + /// + /// + /// Every internal link was written as an absolute path and emitted verbatim, so a German + /// page's links all pointed out of German. /de/games rendered href="/games", + /// href="/about", href="/find" — and a reader who followed a shared /de/… + /// link has no cookie, so nothing could send them back and their first click landed in English. + /// The locale is in the path precisely so it is linkable, shareable, cacheable and indexable; + /// a page that throws it away on every link has the property in its address and nowhere else. + /// + /// + /// Written once and called at every link site, rather than at each producer. The pages + /// build addresses in a dozen places — ListingLinks, FindScreen, the facet panel, + /// the reference library — and threading a locale through all of them would leave the next + /// producer to remember. This is the last thing that touches the string before it becomes an + /// attribute, so what it is given is whatever a caller had, and every shape it can be given is + /// answered here. + /// + /// + /// What is left exactly as it arrived, because none of it is an app path in a language: + /// anything not starting with / — a query-only ?plain=1 or ?window=30d, + /// which means this page, asked differently and is relative to it on purpose; a + /// fragment; an absolute URL; a mailto: — and anything starting // or /\, + /// which is another host wearing a path's clothes. names the rest: + /// the read API, robots.txt, sitemap.xml, the icons and the manifest each have one + /// canonical address, which is the same list the middleware refuses to redirect and not a second + /// copy of it. + /// + /// + /// The locale and not Request.PathBase, though the middleware puts the prefix + /// there and reading it back would have been cheaper. The two agree on every request that + /// arrives with a prefix and disagree on the one that does not: a request nothing may redirect — + /// a POST — is answered in the locale the reader's cookie names with an empty PathBase, + /// so a page rendered from it would link to English while reading German. The question a link + /// asks is which language is this page in, and that is what + /// answers. + /// + /// + public static string Link(string tag, string? path) + { + ArgumentNullException.ThrowIfNull(tag); + + if (string.IsNullOrEmpty(path) || tag == Locales.SourceTag || path[0] != '/') + { + return path ?? string.Empty; + } + + if (path.Length > 1 && path[1] is '/' or '\\') + { + return path; + } + + // The path alone decides, so /games?plain=1 is localized and /sitemap.xml?x=1 is not: the + // list is about which document an address names, and a querystring does not change that. + var cut = path.AsSpan().IndexOfAny('?', '#'); + + return IsUnlocalized(cut < 0 ? path : path[..cut]) ? path : "/" + tag + path; + } + + /// The same, for the request a component is being rendered inside. + /// + /// The form nearly every call site uses: a component already holds the HttpContext it + /// reads its messages from, so a link asks the same thing the words did. A component rendered + /// with no request at all — which is how this suite renders most of them — is in the source + /// locale, and there the answer is the path it was given. + /// + public static string Link(this HttpContext? context, string? path) => + Link(context.LocaleOf().Tag, path); + + /// + /// The same page, in every locale it exists in — for <link rel="alternate">. + /// + /// + /// x-default points at the unprefixed address, which is what tells a search engine that + /// the English URL is the one to show a reader whose language nothing here matches, rather than + /// having it pick one of the seven. + /// + public static IEnumerable<(string HrefLang, string Path)> Alternates(string pathWithinLocale) + { + ArgumentNullException.ThrowIfNull(pathWithinLocale); + + var path = pathWithinLocale.Length == 0 ? "/" : pathWithinLocale; + + yield return ("x-default", path); + + foreach (var locale in Locales.Offered) + { + // The same rule the links on the page follow, from the same function: an alternate that + // spelled the prefix itself would be a second copy of it to keep in step. + yield return (locale.Tag, Link(locale.Tag, path)); + } + } + + /// + /// The best offered locale for a browser's own list, or null to leave the reader where they are. + /// + /// + /// + /// Quality values are honoured because a browser sends them meaning something — de;q=0.9, + /// en;q=0.8 is a reader who reads both and prefers German — and a match on the language + /// subtag alone counts, so a reader asking for zh-CN reaches zh-Hans. Anything + /// scoring zero is a language the reader has explicitly refused. + /// + /// + /// English never wins here, because English is where the reader already is: returning it would + /// be a redirect to the page being served. + /// + /// + public static Locale? Preferred(string? acceptLanguage) + { + if (string.IsNullOrWhiteSpace(acceptLanguage)) + { + return null; + } + + var best = default(Locale); + var bestScore = 0d; + + foreach (var part in acceptLanguage.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) + { + var bits = part.Split(';', StringSplitOptions.TrimEntries); + var tag = bits[0]; + + var quality = 1d; + + foreach (var parameter in bits.Skip(1)) + { + if (parameter.StartsWith("q=", StringComparison.OrdinalIgnoreCase) + && double.TryParse(parameter[2..], System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out var q)) + { + quality = q; + } + } + + if (quality <= 0 || tag == "*") + { + continue; + } + + var match = Locales.Offered.FirstOrDefault(l => Matches(l.Tag, tag)); + + if (match is null || match.Tag == Locales.SourceTag) + { + continue; + } + + if (quality > bestScore) + { + best = match; + bestScore = quality; + } + } + + return best; + } + + /// Whether an offered tag answers to what a browser asked for. + /// + /// Both directions, on the language subtag: zh-Hans answers a request for zh and + /// for zh-CN alike, because the script is ours to choose and the region is not something + /// this site varies on. + /// + private static bool Matches(string offered, string asked) => + offered.Equals(asked, StringComparison.OrdinalIgnoreCase) + || Language(offered).Equals(Language(asked), StringComparison.OrdinalIgnoreCase); + + private static string Language(string tag) + { + var dash = tag.IndexOf('-', StringComparison.Ordinal); + + return dash < 0 ? tag : tag[..dash]; + } + + private static string FirstSegment(string path) + { + var trimmed = path.AsSpan().TrimStart('/'); + var slash = trimmed.IndexOf('/'); + + return (slash < 0 ? trimmed : trimmed[..slash]).ToString(); + } + + private static string Rest(string path) + { + var trimmed = path.AsSpan().TrimStart('/'); + var slash = trimmed.IndexOf('/'); + + return slash < 0 ? "/" : trimmed[slash..].ToString(); + } + + /// + /// The page to return to, as a path on this site. + /// + /// + /// It arrives in a form field, so it is whatever the poster typed rather than whatever we + /// rendered — and it is written into a Location header. Same guard as the theme + /// endpoint's, and for the same reasons: //elsewhere.example is a different host wearing + /// a path's clothes and walks straight through a StartsWith('/') check, several browsers + /// read /\elsewhere.example as the same thing, and a CR or LF here is a response-splitting + /// attempt rather than a page anybody asked for. + /// + public static string Back(string? path) => + path is { Length: > 1 } + && path[0] == '/' + && path[1] is not ('/' or '\\') + && path.All(c => c is >= ' ' and < (char)0x7f) + + // Any locale prefix already on it is stripped: the field carries where the reader is, + // and the endpoint decides which language that page is served in. + ? StripLocale(path) + : "/"; + + private static string StripLocale(string path) => + Locales.IsLocaleSegment(FirstSegment(path)) ? Rest(path) : path; + + /// This request's path with its locale prefix removed, for the alternates. + public static string PathWithinLocale(this HttpContext context) + { + ArgumentNullException.ThrowIfNull(context); + + return context.Request.Path.Value is { Length: > 0 } path ? path : "/"; + } + + /// The absolute address of this request, for a canonical or an alternate. + public static string Absolute(this HttpContext context, string path) + { + ArgumentNullException.ThrowIfNull(context); + + return new Uri(new Uri(UriHelper.BuildAbsolute( + context.Request.Scheme, context.Request.Host)), path).ToString(); + } +} diff --git a/src/MUI.Web/Localization/Locales.cs b/src/MUI.Web/Localization/Locales.cs new file mode 100644 index 0000000..df4232e --- /dev/null +++ b/src/MUI.Web/Localization/Locales.cs @@ -0,0 +1,249 @@ +using System.Collections.Concurrent; +using System.Globalization; + +namespace MUI.Web.Localization; + +/// How far along a locale is, and therefore whether a reader may be sent to it. +/// +/// A locale is offered when its locked strings are translated and reviewed, and not before. +/// The handoff's order of work says it plainly — nothing ships to a reader before the glossary +/// exists — because the failure mode here is not an untranslated button. It is a provenance word +/// that has drifted: "measured", "declared", "not measured", "uncounted" and "unreachable" are five +/// different claims, and a translation engine treats them as stylistic variants of *unavailable*. +/// A reader who cannot tell a game that answered from one that did not has been told something +/// false by the one site whose whole argument is that it never guesses. +/// +public enum LocaleStatus +{ + /// Planned for year one, with nothing translated yet. Never offered. + Planned, + + /// + /// Translated, and therefore offered. + /// + /// + /// + /// There used to be two tiers here and there is no longer a difference worth drawing. A + /// MachineTranslated locale was reachable but never offered: no Accept-Language + /// answer, no hreflang alternate, no default. The idea was that a person should read the + /// glossary before the site sent anybody to a language — but the notice that told readers about + /// the distinction was removed as unnecessary, and what was left was a promise the site made to + /// itself and to nobody else, at the cost of the four languages being undiscoverable. + /// + /// + /// The gate that matters survives and is now the only one: 's locked + /// strings must exist in a locale before it is offered, and a test walks every offered locale to + /// prove they do. That is a fact about the bundle rather than a claim about who read it, which + /// is the kind of gate the rest of this codebase keeps. + /// + /// + Shipped, + + /// + /// A locale that exists to be tested against and is never offered to anybody. + /// + /// + /// Two of them, for two different jobs. The pseudolocale exercises the machinery — routing, + /// fallback, plural selection, and the 1.4x width budget the nav is reviewed against — without + /// anybody claiming it is a language. The Russian canary exists because Chinese cannot fail + /// an agreement bug: it has no grammatical gender, no plural inflection and no case, so a + /// string architecture that is wrong for every inflected language passes review against it. + /// Russian needs four forms of "measured" and three plural categories, so a missing one fails a + /// build instead of reaching a reader. + /// + TestOnly, +} + +/// One interface language, named as its own readers name it. +/// The BCP-47 tag, which is also the path segment. +/// +/// The language's name in itself — Deutsch, Русский, 中文 — never in English and never a flag. +/// +/// Whether a reader may be sent here. +public sealed record Locale(string Tag, string Endonym, LocaleStatus Status) +{ + /// Whether a reader may be sent here by default. + public bool IsOffered => Status is LocaleStatus.Shipped; + + /// Whether a reader who has explicitly chosen this one may be kept in it. + /// + /// Wider than by exactly the review locales, which exist to be tested + /// against and are never offered to anybody. A reader who picks one from the switcher stays in + /// it, which is the difference between offering a locale and honouring a choice. + /// + public bool IsChoosable => Status is LocaleStatus.Shipped or LocaleStatus.TestOnly; + +} + +/// +/// The interface languages, and the boundary around them. +/// +/// +/// +/// Every locale here is left-to-right, and that is a decision rather than an omission. A text +/// run has a direction and a layout has a direction, and only the first is in scope: dir on a +/// <bdi> makes an Arabic game name flow right-to-left inside the cell it occupies and +/// moves nothing else, which is why an Arabic game in an English page is correct permanently. An +/// entire Arabic interface still reading left-to-right is not — the page's reading path +/// fights the text's — so Arabic and Hebrew are render-only: indexed and displayed perfectly, +/// not offered as interface languages. Publishing that boundary is more in keeping with this site's +/// voice than quietly shipping a half-correct Arabic UI, and revisiting it means mirroring and +/// translation together, as one project. +/// +/// +/// Chinese is first because it is plausibly the largest non-English audience for a MU* directory and +/// it stress-tests every typographic finding at once. Chinese and Japanese are separate locales and +/// not one CJK bucket: Unicode unified thousands of Han characters across them and the correct drawn +/// form differs, so lang is what selects between two font families. A reader of either +/// notices the wrong one immediately, and no measurement catches it — the two renderings are exactly +/// the same width. +/// +/// +public static class Locales +{ + /// The language the site is written in, and the one every other falls back to. + public const string SourceTag = "en"; + + /// Where a reader's choice is kept. + /// + /// Read by the server while the page is composed and never by a script, like the theme cookie + /// beside it — this site runs none, and the switcher is a form for that reason. + /// + public const string CookieName = "mui_locale"; + + /// The endpoint the switcher posts to. + public const string Path = "/locale"; + + public const string Field = "locale"; + + public const string ReturnField = "return"; + + /// + /// The seven of year one, plus the two that only a test ever visits. + /// + /// + /// Ordered as the switcher lists them: the source language first, then the rest by the order + /// they were committed to. A list ordered by "most speakers" would be an editorial claim about + /// whose language matters, which is not a measurement and not ours to publish. + /// + public static IReadOnlyList All { get; } = + [ + new("en", "English", LocaleStatus.Shipped), + // Machine-translated and labelled as such on every page. Reachable so the pipeline can be + // exercised against real scripts and real word lengths; not offered, because no person has + new("zh-Hans", "中文", LocaleStatus.Shipped), + new("ja", "日本語", LocaleStatus.Shipped), + new("de", "Deutsch", LocaleStatus.Shipped), + new("nl", "Nederlands", LocaleStatus.Shipped), + + new("ru", "Русский", LocaleStatus.Planned), + new("th", "ไทย", LocaleStatus.Planned), + new("hi", "हिन्दी", LocaleStatus.Planned), + + // Never offered. See LocaleStatus.TestOnly for why these two and not one. + new("qps-ploc", "Pseudo (QA)", LocaleStatus.TestOnly), + new("ru-x-canary", "Русский (CI canary)", LocaleStatus.TestOnly), + ]; + + /// + /// The scripts this site renders correctly and does not offer an interface in. + /// + /// + /// Stated as data rather than left implicit, so the boundary is publishable and testable rather + /// than a thing somebody has to remember. A game whose name is in one of these is indexed, + /// searched, isolated with <bdi> and tagged with its own lang exactly like + /// every other game. + /// + public static IReadOnlyList RenderOnlyScripts { get; } = ["ar", "he"]; + + /// The locales a reader may actually be sent to. + public static IReadOnlyList Offered { get; } = [.. All.Where(l => l.IsOffered)]; + + /// + /// The locales a switcher lists, which in a review build includes the ones that are not + /// languages. + /// + /// + /// + /// The gate stays, and this is not a hole in it. Nothing reaches a reader before its + /// locked strings are translated — is that rule and it is unchanged. What + /// this adds is a way to look at the control: with English the only shipped locale the + /// switcher had nothing to switch between, so it drew nothing at all, and a control nobody can + /// see is a control nobody can review. + /// + /// + /// What it offers in a review build is the pseudolocale and the CI canary, and neither claims to + /// be a translation: one is accented English and the other is machine output that is missing + /// plural forms on purpose. Both are exactly what somebody reviewing the switcher, the routing + /// and the 1.4x width budget wants to click. + /// + /// + /// is a parameter and never a flag on this class. It was a + /// static one that composition wrote on every host start — and a test process starts many hosts, + /// in Development and in Production, so the switcher's contents came from whichever host had + /// started last rather than from the host answering the request. It is asked of the request + /// instead, by : that reaches the environment through + /// the request's own services, so a component with no request behind it — every headless + /// component test here — answers false without needing a web host to be told so. + /// + /// + public static IReadOnlyList Switchable(bool preview) => + [ + .. All.Where(l => l.IsOffered || (preview && l.Status is LocaleStatus.TestOnly)), + ]; + + /// The source locale, which is never missing and never falls back. + public static Locale Source { get; } = All.Single(l => l.Tag == SourceTag); + + /// A tag as a locale, or null when nothing here answers to it. + /// + /// Case-insensitive, because a path segment is whatever somebody typed and /ZH-HANS/games + /// is the same request as /zh-Hans/games. Unknown tags answer null rather than falling + /// back to English silently — the caller decides whether that is a 404 or a redirect, and those + /// are different answers. + /// + public static Locale? Find(string? tag) => tag is null + ? null + : All.FirstOrDefault(l => string.Equals(l.Tag, tag, StringComparison.OrdinalIgnoreCase)); + + /// Whether a path segment looks like one of ours, without deciding anything else. + public static bool IsLocaleSegment(string segment) => Find(segment) is not null; + + private static readonly ConcurrentDictionary Cultures = + new(StringComparer.OrdinalIgnoreCase); + + /// + /// The CLDR data behind a tag — day names, number formats — or the invariant culture where + /// .NET has never heard of it. + /// + /// + /// + /// Not everything a locale says comes out of our bundle, and the day names are the case in + /// point. "Monday" through "Sunday" were an array in a component here, which made a German + /// page render a German sentence around seven English day names — and no translator could have + /// fixed it, because the words were not in a file they are ever sent. CLDR already carries them + /// for every locale this site names and for every locale it might. + /// + /// + /// qps-ploc and ru-x-canary are ours rather than anybody's and asking .NET for + /// them raises. The invariant culture is the right answer for both: neither is a language, and + /// what they exercise is the message machinery rather than a calendar. + /// + /// + public static CultureInfo CultureOf(string tag) + { + ArgumentNullException.ThrowIfNull(tag); + + return Cultures.GetOrAdd(tag, static t => + { + try + { + return CultureInfo.GetCultureInfo(t); + } + catch (CultureNotFoundException) + { + return CultureInfo.InvariantCulture; + } + }); + } +} diff --git a/src/MUI.Web/Localization/MessagePattern.cs b/src/MUI.Web/Localization/MessagePattern.cs new file mode 100644 index 0000000..413da83 --- /dev/null +++ b/src/MUI.Web/Localization/MessagePattern.cs @@ -0,0 +1,498 @@ +using System.Globalization; + +namespace MUI.Web.Localization; + +/// What an argument does with its value. +public enum ArgumentKind +{ + /// {name} — substituted as it stands. + None, + + Number, + Date, + Time, + + /// {n, plural, …} — cardinal agreement. + Plural, + + /// {n, selectordinal, …} — ordinal agreement: 1st, 2nd, 3rd, 4th. + SelectOrdinal, + + /// {gender, select, …} — keyword match on a string. + Select, +} + +/// One piece of a parsed message. +public abstract record MessagePart; + +/// Text, with every quote already resolved. +public sealed record LiteralPart(string Text) : MessagePart; + +/// A bare # inside a plural branch: the count, less the offset. +public sealed record HashPart : MessagePart; + +/// An argument, and whatever it selects between. +public sealed record ArgumentPart( + string Name, + ArgumentKind Kind, + string? Style, + long Offset, + IReadOnlyDictionary Branches) : MessagePart; + +/// +/// One ICU MessageFormat pattern, parsed once and formattable many times. +/// +/// +/// +/// Parsed rather than interpreted, because parsing is when a message can be refused. A +/// pattern that names a branch keyword no plural category uses, or a select with no +/// other, is broken in a way that only shows up for the one reader whose count happens to +/// reach it. Parsing every bundle at startup — and in a test that walks all of them — turns that +/// into a failure somebody sees before a reader does. +/// +/// +/// The whole of MessageFormat 1.0's grammar: simple arguments, number, date and +/// time with their styles and skeletons, plural and selectordinal with +/// offset: and =value matches, select, nesting to any depth, and ICU's +/// apostrophe quoting in its default DOUBLE_OPTIONAL mode. choice is refused: it is +/// deprecated in ICU itself, its syntax is ambiguous with plural, and every use of it is +/// better written as one of the two selectors above. +/// +/// +/// Not MessageFormat 2.0. MF2 reached stable in CLDR 47 and is where this is going, but its +/// syntax is a different language, no .NET implementation exists, and the translation tools this +/// pipeline has to hand strings to — every one of them — speak MF1. Choosing MF2 today would trade +/// a pipeline that works for a spec with nowhere to send the strings. +/// +/// +public sealed record MessagePattern(IReadOnlyList Parts) +{ + /// Parses a pattern, or throws saying where it stopped making sense. + public static MessagePattern Parse(string pattern) + { + ArgumentNullException.ThrowIfNull(pattern); + + var at = 0; + var parsed = ParseMessage(pattern, ref at, inPlural: false, nested: false); + + if (at < pattern.Length) + { + throw new FormatException($"Unexpected '{pattern[at]}' at {at} in: {pattern}"); + } + + return parsed; + } + + /// Every argument this pattern reads, and what it does with each. + /// + /// Walks nested branches too, so a message can be checked against the values a caller will + /// actually pass without rendering it — which is what lets a test assert that every argument a + /// bundle names is one the site supplies. + /// + public IEnumerable Arguments() + { + foreach (var part in Parts) + { + if (part is not ArgumentPart argument) + { + continue; + } + + yield return argument; + + foreach (var branch in argument.Branches.Values) + { + foreach (var nested in branch.Arguments()) + { + yield return nested; + } + } + } + } + + private static MessagePattern ParseMessage(string s, ref int at, bool inPlural, bool nested) + { + var parts = new List(); + var text = new System.Text.StringBuilder(); + + void Flush() + { + if (text.Length > 0) + { + parts.Add(new LiteralPart(text.ToString())); + text.Clear(); + } + } + + while (at < s.Length) + { + var c = s[at]; + + if (c == '}') + { + if (!nested) + { + throw new FormatException($"Unmatched '}}' at {at} in: {s}"); + } + + break; + } + + if (c == '\'') + { + Quote(s, ref at, inPlural, text); + continue; + } + + if (c == '{') + { + Flush(); + at++; + parts.Add(ParseArgument(s, ref at)); + continue; + } + + if (c == '#' && inPlural) + { + Flush(); + parts.Add(new HashPart()); + at++; + continue; + } + + text.Append(c); + at++; + } + + Flush(); + + return new MessagePattern(parts); + } + + /// + /// ICU's apostrophe rules, in the DOUBLE_OPTIONAL mode ICU uses by default. + /// + /// + /// + /// An apostrophe only starts quoted text when it immediately precedes {, }, + /// |, or # inside a plural. Anywhere else it is what it looks like — a literal + /// apostrophe — which is what makes doesn't safe to write without thinking about it. A + /// doubled apostrophe is always one literal apostrophe. + /// + /// + /// This replaces the doubled-brace escape an earlier version carried, which was ambiguous with + /// the syntax it appeared in: every argument ends ...}} when its last branch closes, and + /// a reader treating those two characters as one literal walked straight past the end of the + /// message. That was a real bug and this is the spec's own answer to it. + /// + /// + private static void Quote(string s, ref int at, bool inPlural, System.Text.StringBuilder text) + { + // '' — one literal apostrophe, quoting or not. + if (at + 1 < s.Length && s[at + 1] == '\'') + { + text.Append('\''); + at += 2; + return; + } + + var starts = at + 1 < s.Length + && (s[at + 1] is '{' or '}' or '|' || (inPlural && s[at + 1] == '#')); + + if (!starts) + { + text.Append('\''); + at++; + return; + } + + at++; + + while (at < s.Length) + { + if (s[at] == '\'') + { + // Inside a quote, '' is still one apostrophe; a lone one closes. + if (at + 1 < s.Length && s[at + 1] == '\'') + { + text.Append('\''); + at += 2; + continue; + } + + at++; + return; + } + + text.Append(s[at]); + at++; + } + + // ICU closes an unterminated quote at the end of the pattern rather than erroring. + } + + private static ArgumentPart ParseArgument(string s, ref int at) + { + Skip(s, ref at); + + var name = Name(s, ref at); + + Skip(s, ref at); + + if (Take(s, ref at, '}')) + { + return new ArgumentPart(name, ArgumentKind.None, null, 0, EmptyBranches); + } + + Expect(s, ref at, ',', name); + Skip(s, ref at); + + var type = Word(s, ref at, "an argument type"); + + Skip(s, ref at); + + var kind = type switch + { + "number" => ArgumentKind.Number, + "date" => ArgumentKind.Date, + "time" => ArgumentKind.Time, + "plural" => ArgumentKind.Plural, + "selectordinal" => ArgumentKind.SelectOrdinal, + "select" => ArgumentKind.Select, + + // Deprecated in ICU, ambiguous with plural, and better written as one. Refusing it is + // the whole reason this parser exists rather than a regular expression. + "choice" => throw new FormatException( + $"'choice' is deprecated in ICU and is not supported — use plural or select: {s}"), + + _ => throw new FormatException($"Unknown argument type '{type}' in: {s}"), + }; + + if (Take(s, ref at, '}')) + { + // {n, number} and {d, date} are legal with no style. + return kind is ArgumentKind.Number or ArgumentKind.Date or ArgumentKind.Time + ? new ArgumentPart(name, kind, null, 0, EmptyBranches) + : throw new FormatException($"'{type}' needs branches in: {s}"); + } + + Expect(s, ref at, ',', type); + + if (kind is ArgumentKind.Number or ArgumentKind.Date or ArgumentKind.Time) + { + return new ArgumentPart(name, kind, Style(s, ref at), 0, EmptyBranches); + } + + var offset = 0L; + + if (kind is not ArgumentKind.Select) + { + Skip(s, ref at); + + if (s.AsSpan(at).StartsWith("offset:", StringComparison.Ordinal)) + { + at += "offset:".Length; + Skip(s, ref at); + + var digits = Word(s, ref at, "an offset"); + + if (!long.TryParse(digits, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out offset)) + { + throw new FormatException($"'{digits}' is not an offset in: {s}"); + } + } + } + + var branches = ParseBranches(s, ref at, kind); + + // Every selector needs a fallback, and ICU says so: without one a value nobody anticipated + // has no rendering at all. Refused here rather than at render time, so the message that is + // missing it fails a build rather than one reader's page. + if (!branches.ContainsKey("other")) + { + throw new FormatException($"'{name}' has no 'other' branch in: {s}"); + } + + return new ArgumentPart(name, kind, null, offset, branches); + } + + private static Dictionary ParseBranches(string s, ref int at, ArgumentKind kind) + { + var branches = new Dictionary(StringComparer.Ordinal); + var plural = kind is ArgumentKind.Plural or ArgumentKind.SelectOrdinal; + + while (true) + { + Skip(s, ref at); + + if (at >= s.Length) + { + throw new FormatException($"Unclosed branch list in: {s}"); + } + + if (Take(s, ref at, '}')) + { + return branches; + } + + var selector = Selector(s, ref at); + + if (plural && !selector.StartsWith('=') && !PluralRules.IsCategory(selector)) + { + // A keyword no category uses is dead: nothing ever selects it, in any locale, and + // it is almost always a typo for one that would have. + throw new FormatException( + $"'{selector}' is not a plural category and not an '=' match in: {s}"); + } + + Skip(s, ref at); + Expect(s, ref at, '{', selector); + + branches[selector] = ParseMessage(s, ref at, plural, nested: true); + + Expect(s, ref at, '}', selector); + } + } + + /// A style, which runs to the argument's closing brace and may contain quotes. + private static string Style(string s, ref int at) + { + var text = new System.Text.StringBuilder(); + + while (at < s.Length && s[at] != '}') + { + if (s[at] == '\'') + { + Quote(s, ref at, inPlural: false, text); + continue; + } + + text.Append(s[at]); + at++; + } + + Expect(s, ref at, '}', "a style"); + + return text.ToString().Trim(); + } + + private static readonly IReadOnlyDictionary EmptyBranches = + new Dictionary(StringComparer.Ordinal); + + private static string Name(string s, ref int at) => Word(s, ref at, "an argument name"); + + /// + /// A branch selector: a category keyword, or = and the number it matches exactly. + /// + /// + /// A bare = is a parse error and not a selector. It was accepted — the = + /// was consumed, nothing had to follow it, and the category check above skips anything starting + /// with one — so {n, plural, = {none} other {#}} stored a branch under a key no number + /// written any way can equal. That is exactly the dead branch this parser exists to refuse + /// rather than to keep quietly, and it is the one shape that got past the check. + /// + /// ICU parses an explicit value as a number, so a leading sign and a fraction are both legal + /// after the = and a word is not: =x is a typo for a keyword, and a keyword is + /// what the branch above it would have checked. + /// + /// + private static string Selector(string s, ref int at) + { + var start = at; + + if (at < s.Length && s[at] == '=') + { + at++; + + if (at < s.Length && s[at] == '-') + { + at++; + } + + var digits = at; + + while (at < s.Length && char.IsAsciiDigit(s[at])) + { + at++; + } + + if (at == digits) + { + throw new FormatException($"Expected a number after '=' at {digits} in: {s}"); + } + + if (at < s.Length && s[at] == '.') + { + at++; + + var fraction = at; + + while (at < s.Length && char.IsAsciiDigit(s[at])) + { + at++; + } + + if (at == fraction) + { + throw new FormatException($"Expected a fraction after '.' at {fraction} in: {s}"); + } + } + + return s[start..at]; + } + + while (at < s.Length && (char.IsLetterOrDigit(s[at]) || s[at] is '_' or '-' or '.')) + { + at++; + } + + return at > start + ? s[start..at] + : throw new FormatException($"Expected a selector at {start} in: {s}"); + } + + private static string Word(string s, ref int at, string what) + { + Skip(s, ref at); + + var start = at; + + while (at < s.Length && (char.IsLetterOrDigit(s[at]) || s[at] is '_' or '-' or '.' or '+')) + { + at++; + } + + return at > start + ? s[start..at] + : throw new FormatException($"Expected {what} at {start} in: {s}"); + } + + private static void Skip(string s, ref int at) + { + while (at < s.Length && char.IsWhiteSpace(s[at])) + { + at++; + } + } + + private static bool Take(string s, ref int at, char c) + { + Skip(s, ref at); + + if (at < s.Length && s[at] == c) + { + at++; + return true; + } + + return false; + } + + private static void Expect(string s, ref int at, char c, string after) + { + if (!Take(s, ref at, c)) + { + throw new FormatException($"Expected '{c}' after '{after}' at {at} in: {s}"); + } + } +} diff --git a/src/MUI.Web/Localization/Messages.cs b/src/MUI.Web/Localization/Messages.cs new file mode 100644 index 0000000..6ab2c6b --- /dev/null +++ b/src/MUI.Web/Localization/Messages.cs @@ -0,0 +1,2110 @@ +using System.Globalization; + +using System.Resources; + +namespace MUI.Web.Localization; + +/// +/// Every string the chrome says, keyed by context, in ICU MessageFormat. +/// +/// +/// +/// Stored in resx and rendered by ICU. The two halves solve different problems and the usual +/// .NET arrangement only has one of them. resx is where a translation belongs: the SDK compiles +/// Messages.<culture>.resx into a satellite assembly on its own, every +/// translation-management tool reads and writes the format, and a translator receives a file their +/// software opens rather than a C# dictionary they must not break. What resx cannot do is +/// agreement — {0} substitutes and nothing more — which is why the values are ICU patterns. +/// +/// +/// One message per fact, never a sentence assembled from parts. The strings this replaces +/// were concatenations — a number glued to an English fragment in English word order — and there is +/// nowhere in a concatenation for a translator to intervene without editing markup. Russian needs +/// three plural forms and Arabic six, Chinese needs a measure word, and several languages put the +/// unit before the number. +/// +/// +/// The ids are granular past the point English needs. That is the whole of S7: "measured" is +/// one word here and four in Russian, chosen by what it describes, so provenance.count.measured +/// and provenance.game.measured are separate ids carrying identical English. Collapsing them +/// because the source language cannot tell them apart is exactly how a translation ends up +/// ungrammatical in three places out of four. +/// +/// +/// What is not here. Game names, hostnames, codebase strings, version numbers, protocol +/// acronyms and connect-screen output never enter this file. They are the machine voice, they carry +/// translate="no" in the markup so a browser's own translator obeys too, and translating +/// PennMUSH 1.8.8p0 destroys evidence rather than localizing anything. +/// +/// +public static class Messages +{ + /// + /// The source bundle, compiled in. + /// + /// + /// The same strings as Resources/Messages.resx, and a test walks both to keep them + /// that way. It is here as well because the English is the fallback for every locale and + /// every surface — including the ones rendered with no host behind them — and a fallback that + /// can fail to load is not one. resx is where a translation lives; this is where the + /// source text lives, and the pair is checked rather than trusted. + /// + private static readonly Dictionary English = new(StringComparer.Ordinal) + { + // ── counts, which is where the concatenations were ─────────────────────────────────── + ["facet.count"] = "{count, plural, one {# game} other {# games}}", + ["facet.value.include"] = "{value}, {count, plural, one {# game} other {# games}}, only", + ["facet.value.exclude"] = "{value}, {count, plural, one {# game} other {# games}}, excluded", + ["facet.value.choose"] = "{value}, {count, plural, one {# game} other {# games}}", + ["facet.any"] = "any {facet}, {count, plural, one {# game} other {# games}}", + // Not "every fact measured": the catalogue publishes declared and derived facts too, and + // shows them as such (rule 1 — measured beats declared, and *both are shown*). The summary + // line of a listing that labels a declared count "declared" four rows below cannot claim + // the opposite about the same rows. What is true of every fact is the labelling. + ["listing.total"] = "{count, plural, =0 {No games listed here.}" + + " one {# game, each fact carrying how it was obtained.}" + + " other {# games, each fact carrying how it was obtained.}}", + ["chart.basis"] = "{days, plural, one {# day} other {# days}} measured · {probes, plural, one {# probe} other {# probes}}", + // The day count selects a plural form as the age ladder's does (age.short.days), rather than + // gluing a bare {days} to a literal "d". English does not inflect the abbreviation and + // German does — 1 Tag, 2 Tage — so a translator handed "{days}d" has one slot for two + // forms, and the German satellite duly shipped an English "d" inside a German sentence. + // The unit belongs inside the branch, which is the only place a language can vary it. + ["window.samples"] = "{days, plural, one {#d} other {#d}} · {count, plural, one {# count} other {# counts}}", + ["capabilities.agree"] = "{disagreeing, plural, =0 {None of the {total} disagree.} one {# of {total} disagrees with what the game declares.} other {# of {total} disagree with what the game declares.}}", + + // ── find a game, where the count is the whole point of the page ─────────────────────── + // A sentence and not a bare number. The panel draws "19" at forty pixels for the eye; what + // reaches a screen reader has to say what the nineteen are, because a number announced on + // its own is the one thing on this page nobody can act on. + ["find.matching"] = "{count, plural, =0 {No games match every answer.} one {# game matches every answer.} other {# games match every answer.}}", + ["find.basis"] = "of {listed, plural, one {# listed game} other {# listed games}} · {answers, plural, =0 {no answers given} one {# answer given} other {# answers given}}", + ["find.show"] = "{count, plural, one {Show the one game} other {Show these # games}}", + ["find.drop"] = "drop \"{answer}\" — {count, plural, one {# game} other {# games}}", + ["find.clear"] = "clear answer to: {question}", + + // The page's own copy. Every word of it is here rather than in the markup, including the six + // questions: this is the one surface written in a reader's language rather than in the + // catalogue's, so it is the surface with the most to translate and the least that a + // machine translator could be trusted with. + ["find.title"] = "Find a game", + ["find.kicker"] = "matching all answers", + ["find.noun"] = "{count, plural, one {game} other {games}}", + ["find.clearAll"] = "clear all answers", + ["find.startAgain"] = "start again", + ["find.more"] = "{count, plural, one {# more} other {# more}}", + ["find.answersGiven"] = "answers given", + ["find.wholeListing"] = "the whole listing", + ["find.refused"] = "that query was refused", + ["find.name.label"] = "a name, if you have one", + ["find.name.placeholder"] = "name, or part of one", + ["find.name.submit"] = "Search by name", + + // The six questions, and the answer that un-asks each one. + ["find.q.band"] = "Is anyone playing right now?", + ["find.q.genre"] = "What do you want to play?", + ["find.q.lineage"] = "What kind of game?", + ["find.q.language"] = "In which language?", + ["find.q.client"] = "Anything your client needs?", + ["find.q.dark"] = "Include games that have gone dark?", + ["find.any.band"] = "doesn't matter", + ["find.any.genre"] = "any genre", + ["find.any.lineage"] = "any kind", + ["find.any.language"] = "any language", + ["find.any.client"] = "doesn't matter", + ["find.dark.no"] = "no, only live games", + ["find.dark.yes"] = "yes, show me those too", + ["find.dark.chip"] = "games that have gone dark", + + // ── the client question's options ───────────────────────────────────────────────────── + // One id per capability, each carrying the whole label rather than a gloss to be glued to an + // acronym. The acronym is machine voice and the three words beside it are not, and a + // language that puts the gloss first has nowhere to say so if the two are concatenated. + // `other` is the one that takes the token as an argument, because it is the row for a + // capability this list has never heard of. + ["find.protocol.tls"] = "TLS — encrypted, handshake completed by us", + ["find.protocol.mssp"] = "MSSP — server self-description", + ["find.protocol.mccp"] = "MCCP — compressed output", + ["find.protocol.mxp"] = "MXP — clickable links", + ["find.protocol.gmcp"] = "GMCP — structured client data", + ["find.protocol.msdp"] = "MSDP — structured client data", + ["find.protocol.charset"] = "CHARSET — encoding negotiation", + ["find.protocol.utf8"] = "UTF-8 — non-Latin text renders", + ["find.protocol.ttype"] = "TTYPE — client tells its type", + ["find.protocol.atcp"] = "ATCP — structured client data", + ["find.protocol.msp"] = "MSP — sound triggers", + ["find.protocol.eor"] = "EOR — prompt marking", + ["find.protocol.other"] = "{token} — measured in the handshake", + + // ── the locked provenance words, one id per context ─────────────────────────────────── + ["provenance.count.measured"] = "measured", + ["provenance.game.measured"] = "measured", + ["provenance.capability.measured"] = "measured", + ["provenance.screen.measured"] = "measured", + ["kicker.measured"] = "measured", + ["provenance.count.declared"] = "declared", + ["provenance.game.declared"] = "declared", + ["provenance.capability.declared"] = "declared", + ["kicker.declared"] = "declared", + ["provenance.derived"] = "derived", + ["kicker.derived"] = "derived", + + // ── the four kinds of absence ───────────────────────────────────────────────────────── + ["state.notMeasured"] = "not measured", + ["state.uncounted"] = "uncounted", + ["state.unreachable"] = "unreachable", + ["state.notCounted"] = "not counted", + + // ── the listing's own absences, which are not the game page's ──────────────────────────── + // `state.notCounted` is the glossary's word for a probe that answered without a number, and + // the listing was printing it for a window with no measurement in it at all — three cases + // wearing one word, and the one it wore names a cause. This says the absence and stops. + ["listing.count.none"] = "no count", + ["listing.plain.fromHere"] = "from here", + ["listing.plain.archived"] = "archived", + ["listing.plain.claimed"] = "claimed", + ["random.empty.title"] = "Nothing to pick from", + ["random.empty.body"] = "No game matches that filter. Try {listing}, or {archive}.", + ["random.empty.listing"] = "the whole listing", + ["random.empty.archive"] = "include the archive", + + // ── the words the product rests on ──────────────────────────────────────────────────── + ["term.connected"] = "connected", + ["term.unclaimed"] = "unclaimed", + ["term.claimedByOwner"] = "claimed by its owner", + ["term.stillProbed"] = "still probed", + ["term.typical"] = "typical", + ["term.peak"] = "peak", + + // ── the accessibility promises ──────────────────────────────────────────────────────── + ["a11y.readAsText"] = "read as text", + ["a11y.plainText"] = "plain text", + ["a11y.skipToContent"] = "skip to content", + ["a11y.asciiBanner"] = "ASCII banner: the connect screen of {game}.", + + + // ── site chrome ─────────────────────────────────────────────────────────────────────── + ["nav.catalogues"] = "Catalogues", + ["nav.account"] = "This site and your account", + ["nav.browse"] = "browse", + ["nav.learn"] = "learn", + ["nav.thisSite"] = "this site", + ["nav.menu"] = "menu", + ["nav.games"] = "games", + ["nav.find"] = "find", + ["nav.random"] = "random", + ["nav.archive"] = "archive", + ["nav.reference"] = "reference", + ["nav.ecosystem"] = "ecosystem", + ["nav.rankings"] = "rankings", + ["nav.about"] = "about", + ["nav.submit"] = "submit", + ["nav.submitGame"] = "submit a game", + ["nav.signIn"] = "sign in", + ["nav.yourGames"] = "your games", + ["theme.label"] = "theme", + ["theme.auto"] = "auto", + ["theme.light"] = "light", + ["theme.dark"] = "dark", + ["banner.demo.lead"] = "Demo data.", + ["banner.demo"] = "No database is configured, so this is a fixture. Nothing here was measured.", + ["footer.allGames"] = "all games", + ["footer.archive"] = "archive", + ["footer.declaredByGame"] = "declared by the game", + ["footer.whatChanged"] = "what changed", + + // ── home ────────────────────────────────────────────────────────────────────────────── + ["home.title"] = "A directory of the MU* hobby", + // Same correction as listing.total, on the sentence that makes the claim to a first-time + // reader. The front page cannot say every fact was measured while the row below it wears a + // "declared" chip; what holds of every fact is that it says which of the two it is. + ["home.lede"] = "Every fact carries how it was obtained and how old it is: measured by our " + + "crawler, or declared by the game and marked as such.", + ["home.search.label"] = "Search games by name, theme, codebase or host", + ["home.search.placeholder"] = "search by name, theme, codebase or host", + ["home.search.submit"] = "search", + ["tile.gamesKnown"] = "games known", + ["tile.connectedNow"] = "connected now", + ["tile.answeringUncounted"] = "answering, uncounted", + ["tile.archived"] = "archived", + ["feed.newlyDiscovered"] = "newly discovered", + ["feed.wentDark"] = "went dark — still probed", + ["feed.cameBack"] = "came back", + ["feed.nothingNew"] = "Nothing new.", + ["feed.nothingDark"] = "Nothing went dark.", + ["feed.nothingBack"] = "Nothing came back. We keep knocking.", + ["feed.live"] = "live", + + // ── the listing ─────────────────────────────────────────────────────────────────────── + ["games.title"] = "Games", + ["listing.sortedBy"] = "sorted by {order}", + ["listing.random"] = "random", + ["listing.columns"] = "connected · reached", + ["listing.fromHere"] = "from here", + ["listing.empty.head"] = "Nothing matched.", + ["listing.empty.hint"] = "Try fewer words, or drop a filter.", + ["listing.clearFilters"] = "clear filters", + ["listing.aboutCodebase"] = "about {codebase}", + ["listing.never"] = "never", + ["listing.claimed"] = "claimed by its owner", + ["listing.unknownCodebase"] = "Unknown Codebase", + ["listing.unknownCodebase.title"] = "we could not identify the codebase this game runs", + ["listing.moreProtocols"] = "and {count, plural, one {# more} other {# more}}: {names}", + + // ── the order switch ────────────────────────────────────────────────────────────────── + ["switch.order"] = "Order", + ["switch.window"] = "Window", + ["switch.now"] = "now", + ["switch.typical"] = "typical", + ["switch.peak"] = "peak", + ["switch.name"] = "name", + ["switch.reached"] = "reached", + ["window.7"] = "7 days", + ["window.30"] = "30 days", + ["window.90"] = "90 days", + + // ── the filter panel ────────────────────────────────────────────────────────────────── + ["filters.search.label"] = "Search games", + ["filters.search.placeholder"] = "search games", + ["filters.summary"] = "filters", + ["filters.showing"] = "showing", + ["filters.clearAll"] = "clear all", + ["filters.stopFiltering"] = "— stop filtering by this", + ["facet.anyValue"] = "any", + ["facet.more"] = "more filters ({count})", + ["facet.moreValues"] = "{count, plural, one {# more} other {# more}}", + ["facet.alsoShow"] = "also show", + ["facet.alsoShow.note"] = "Off by default. Neither is a judgement about the game.", + ["facet.archived"] = "archived", + ["facet.adult"] = "adult", + ["facet.archived.state"] = "archived games, {shown, select, true {shown} other {hidden}}", + ["facet.adult.state"] = "games declaring adult content, {shown, select, true {shown} other {hidden}}", + ["facet.countsNote"] = "Counts are games we measured, never estimates.", + ["facet.key.summary"] = "what the badges and the blanks mean", + ["facet.key.blank"] = "A blank is a gap in our measurement, not a no. Each facet spells its own: not identified, not declared, nothing negotiated.", + ["facet.key.zero"] = "A measured zero is a count. An unknown count is not a zero and never sorts as one.", + ["facet.key.openEnded"] = "Open-ended facets list their {count} commonest values. The rest are reachable by search and by URL.", + ["facet.presence.note"] = "Unticked means not measured — not that the game lacks it.", + + // ── facet groups ────────────────────────────────────────────────────────────────────── + ["facet.group.band"] = "activity", + ["facet.group.seen"] = "last seen", + ["facet.group.protocol"] = "protocols offered", + ["facet.group.tls"] = "encrypted", + ["facet.group.charset"] = "encoding", + ["facet.group.codebase"] = "codebase", + ["facet.group.version"] = "version", + ["facet.group.lineage"] = "lineage", + ["facet.group.family"] = "family", + ["facet.group.genre"] = "genre", + ["facet.group.language"] = "language", + + // ── facet values ────────────────────────────────────────────────────────────────────── + ["facet.band.playersNow"] = "connected now", + ["facet.band.activeThisWeek"] = "active this week", + // **Not "uncounted", which this band is not.** `state.uncounted` is a locked glossary term + // meaning "the game answered and we could not read a count", and this rung holds that game + // *and* a game we measured at nought in every hour of the week — opposite facts, one of + // which is a measurement we took (rules 2 and 4). The word was borrowed here before there + // was a facet that meant it, and it now names a control two groups further down the same + // panel that returns a different set. This says what the band actually is: the threshold, + // and no cause. + ["facet.band.quiet"] = "quiet — no count above 0", + ["facet.band.dark"] = "dark — not reached in a month", + ["facet.band.archived"] = "archived", + ["facet.seen.day"] = "in the last 24 hours", + ["facet.seen.week"] = "in the last 7 days", + ["facet.seen.month"] = "in the last 30 days", + ["facet.seen.older"] = "longer ago", + ["facet.seen.never"] = "never reached", + ["facet.unknown.charset"] = "nothing negotiated", + ["facet.unknown.codebase"] = "not identified", + ["facet.unknown.other"] = "not declared", + ["facet.tls.yes"] = "connected over TLS", + ["facet.excluded"] = "not {value}", + ["facet.known.charset"] = "something negotiated", + ["facet.known.codebase"] = "identified at all", + ["facet.known.other"] = "declared at all", + + // ── evidence, and what each word means ──────────────────────────────────────────────── + ["evidence.measured.meaning"] = "we watched this happen", + ["evidence.declared.meaning"] = "the game says so, and we did not check", + ["evidence.derived.meaning"] = "we grouped what the game told us", + + // ── sort orders ─────────────────────────────────────────────────────────────────────── + ["sort.name"] = "name", + ["sort.players"] = "connected now", + ["sort.reached"] = "last reached", + ["sort.medianWeek"] = "typically on · 7 days", + ["sort.medianMonth"] = "typically on · 30 days", + ["sort.medianQuarter"] = "typically on · 90 days", + ["sort.peakWeek"] = "most on at once · 7 days", + ["sort.peakMonth"] = "most on at once · 30 days", + ["sort.peakQuarter"] = "most on at once · 90 days", + ["sort.group.row"] = "on the row now", + ["sort.group.typical"] = "typical", + ["sort.group.peak"] = "peak", + ["sort.unranked.players"] = "Unknown count", + ["sort.unranked.reached"] = "never once reached — not reached long ago", + ["sort.unranked.median"] = "fewer than {minimum} counts in the window, or none at all — not a typical count of zero", + ["sort.unranked.window"] = "nothing we could count in the window — not a game nobody was on", + // {days} selects a plural form here too — same reason as window.samples above. + ["sort.window.median"] = "median {value} · {days, plural, one {#d} other {#d}}" + + " · {count, plural, one {# count} other {# counts}}", + ["sort.window.peak"] = "most {value} at once · {days, plural, one {#d} other {#d}}" + + " · {count, plural, one {# count} other {# counts}}", + + // ── the game page's own headings ────────────────────────────────────────────────────── + ["game.connectScreen"] = "Connect screen", + ["game.connectionsByHour"] = "Connections by hour", + ["game.howMany"] = "How many, over time", + ["game.reachable"] = "Reachable", + ["game.whatChanged"] = "What changed", + ["game.capabilities"] = "Capabilities", + ["game.declaredByGame"] = "Declared by the game", + ["game.referrals"] = "Referrals", + ["game.unclaimed"] = "Unclaimed — everything here was measured.", + ["game.claimed"] = "Claimed by its owner — measured facts below are still ours.", + ["game.claim"] = "Claim this game", + ["game.answeringSince"] = "answering since {date}", + ["game.readAsTextRows"] = "read as text — {count, plural, one {# row} other {# rows}}", + ["capability.column"] = "capability", + ["capability.age"] = "age", + ["capability.offered"] = "offered", + ["capability.silent"] = "silent", + ["capability.absent"] = "absent", + ["capability.denied"] = "denied", + ["capability.claimed"] = "claimed", + ["capability.disagrees"] = "disagrees", + ["capability.whereTheyDisagree"] = "where they disagree ({count})", + + // ── the week of hours, said in words ────────────────────────────────────────────────── + // The heatmap's sentence, its per-day alternative, and the label on every cell. Three + // states and never two: an hour we counted — a measured zero included — an hour that + // answered and produced no count, and an hour nobody has a measurement for. The third + // names no cause in any language, because a probe that failed and an hour we never dialled + // write the same nothing, and a translation that reached for "offline" or "not reachable" + // would file our crawl schedule as a fact about somebody's game. + // + // Every count is an argument rather than a spelled-out word. "Two hours … have" was a + // number, a noun and a verb agreed in English word order, with nowhere in it for a + // translator to stand. + ["activity.cell.counted"] = "{day} {time} — {count, plural, =0 {0 players, measured} one {# player on average} other {# players on average}}", + ["activity.cell.notCounted"] = "{day} {time} — probed, no count could be read", + ["activity.cell.notMeasured"] = "{day} {time} — no measurement in this hour", + + ["activity.none"] = "We have not measured this game's activity yet.", + ["activity.noCount"] = "No hour of the week has produced a player count.", + + // A measured zero everywhere is a measurement, and a strong one. It must not read as an + // absence of data in any language. + ["activity.allZero"] = "Measured every hour and nobody has been on in any of them.", + + // Two ids apiece, because "on Monday" and "across the week" land in different places in + // different languages, and a fragment glued into a sentence is one nobody can move. + ["activity.gap.day"] = "{count, plural, one {# hour on {day} has no measurement yet.} other {# hours on {day} have no measurement yet.}}", + ["activity.gap.week"] = "{count, plural, one {# hour across the week has no measurement yet.} other {# hours across the week have no measurement yet.}}", + ["activity.uncounted.day"] = "{count, plural, one {# hour on {day} answered but produced no count.} other {# hours on {day} answered but produced no count.}}", + ["activity.uncounted.week"] = "{count, plural, one {# hour across the week answered but produced no count.} other {# hours across the week answered but produced no count.}}", + + ["activity.busiest.everyDay"] = "Busiest every day, {window}.", + ["activity.busiest.everyDay.part"] = "Busiest every day, {part}, {window}.", + ["activity.busiest.days"] = "Busiest {days} {window}.", + ["activity.busiest.days.part"] = "Busiest {days} {part}, {window}.", + + ["activity.quiet"] = "Reliably quiet {who}, {window}.", + ["activity.quiet.part"] = "Reliably quiet {who} in the {part}, {window}.", + ["activity.quiet.everyDay"] = "every day", + ["activity.quiet.everyMeasuredDay"] = "every day we could measure", + ["activity.quiet.weekdays"] = "on weekdays", + ["activity.quiet.onDays"] = "on {days}", + + // The same four names in two registers: the busy band takes the plural — "evenings, + // 17:00–21:59" — and the quiet one the singular, "in the evening". English builds the + // second from the first by adding an s, which is a fact about English and not about nouns. + ["activity.part.morning"] = "morning", + ["activity.part.afternoon"] = "afternoon", + ["activity.part.evening"] = "evening", + ["activity.part.smallHours"] = "small hours", + ["activity.parts.morning"] = "mornings", + ["activity.parts.afternoon"] = "afternoons", + ["activity.parts.evening"] = "evenings", + ["activity.parts.smallHours"] = "small hours", + + // A run of days, folded rather than joined on a comma: the separator and the final + // conjunction both belong to a language, and Chinese uses neither of ours. + ["activity.days.list"] = "{list}, {next}", + ["activity.days.pair"] = "{first} and {second}", + + ["activity.sparse.kicker"] = "not enough measurements yet", + ["activity.sparse.none"] = "No hour of the week has a measurement yet.", + ["activity.sparse.uncounted"] = "{count, plural, one {# hour answered and produced no count.} other {# hours answered and produced no count.}}", + ["activity.sparse.wait"] = "The grid appears once every day of the week has one.", + ["activity.sparse.days"] = "{days, plural, one {Measured on # of the seven days so far; the grid appears once every day has an hour in it.} other {Measured on # of the seven days so far; the grid appears once every day has an hour in it.}}", + ["activity.sample.zero"] = "{count, plural, one {# hour measured, all of it at nobody on.} other {# hours measured, all of them at nobody on.}}", + ["activity.sample.peak"] = "{count, plural, one {# hour measured, the busiest {peak} on {day} at {time} UTC.} other {# hours measured, the busiest {peak} on {day} at {time} UTC.}}", + ["activity.sample.more"] = "{count, plural, one {# more hour answered and produced no count.} other {# more hours answered and produced no count.}}", + + ["activity.day.line"] = "{day} — {facts}", + ["activity.day.facts"] = "{first}, {second}", + ["activity.day.allZero"] = "measured at zero all day", + ["activity.day.peak"] = "peak {count} at {time}", + ["activity.day.nobodyOn"] = "nobody on {window}", + ["activity.day.noCount"] = "no count in any hour", + ["activity.day.notMeasured"] = "{count, plural, one {# hour not measured} other {# hours not measured}}", + ["activity.day.notCounted"] = "{count, plural, one {# hour probed but uncountable} other {# hours probed but uncountable}}", + + // The panel's own chrome: the headings of the text alternative, the legend beside the + // drawing, and the key the plain surface prints in place of both. The "not measured" + // column reuses state.notMeasured rather than saying it a second way — the whole panel + // has one word for that hour or it has none. + ["activity.column.day"] = "day", + ["activity.column.quietest"] = "quietest", + ["activity.column.busiest"] = "busiest", + ["activity.column.at"] = "at", + ["activity.column.noCount"] = "no count", + ["activity.caption"] = "Players on by day, in UTC. {window}.", + ["activity.times"] = "times in UTC · {window}", + ["activity.rollingAverage"] = "{weeks, plural, one {#-week rolling average} other {#-week rolling average}}", + ["activity.legend.counted"] = "counted, including a measured zero", + ["activity.legend.notCounted"] = "probed, no count could be read", + ["activity.legend.notMeasured"] = "no measurement in that hour", + ["activity.plain.heading"] = "When people are on (UTC)", + ["activity.key.counted"] = "counted", + ["activity.key.counted.meaning"] = "we got in and read a number, including a measured zero", + ["activity.key.uncounted.meaning"] = "we got in and no number could be read", + ["activity.key.notMeasured.meaning"] = "we have no measurement for that hour", + + // ── the switcher's own chrome, which has to read in the locale being left ───────────── + ["locale.label"] = "language", + ["locale.submit"] = "change language", + + // ═════════════════════════════════════════════════════════════════════════════════════ + // STATIC PAGE COPY — /about, /submit, /account/sign-in + // + // The three pages that were prose in a C# file rather than strings in a bundle, and so were + // still English when the site was asked for German. Nothing here is reworded on the way in: + // the English is byte-for-byte what the page already said, and only its home changed. The + // about page in particular *states* the rules this repository is written from, and a rule + // paraphrased while being moved is a rule quietly rewritten. + // + // Every lead/body pair is two ids for the reason AboutPoint is two fields: the graphical + // page sets the lead in bold and the plain page cannot, so the emphasis is presentational + // and the sentence is not. A translator gets two sentences to move rather than one string + // with markup in the middle of it. + // ═════════════════════════════════════════════════════════════════════════════════════ + + // ── about: the page, and what a fact here is ────────────────────────────────────────── + ["about.title"] = "About mu*index", + ["about.lede"] = "Every game here was measured by a machine that connected to it, and every " + + "value says where it came from and when. This page covers what that proves, what we " + + "get wrong, whose directories we read, and how to make the crawler stop.", + + ["about.measures.heading"] = "What a fact here is", + ["about.measures.declared.lead"] = "Measured beats declared, and both are shown.", + ["about.measures.declared.body"] = "A game's MSSP report is the game describing itself. The " + + "telnet handshake is what we watched it do. Both appear on its page, labelled with how " + + "and when. Where they disagree, we show the disagreement.", + ["about.measures.count.lead"] = "A player count says where it came from.", + ["about.measures.count.body"] = "Either a WHO or DOING read at the connect screen, which we " + + "counted, or the game's own MSSP PLAYERS field, which it published. Never merged.", + ["about.measures.unknown.lead"] = "An answer we cannot read is unknown, never zero.", + ["about.measures.unknown.body"] = "Servers customise their WHO headers freely, and past a " + + "point our parser cannot read one. That is uncountable, its own state. A measured zero " + + "— we got in, nobody was there — is a count, and prints as one.", + ["about.measures.reachable.lead"] = "Reachable, never uptime.", + ["about.measures.reachable.body"] = "We open a socket from one host at intervals. A game we " + + "cannot route to is unreachable and perfectly alive. Nothing here claims a game's " + + "uptime, because nothing here measured it.", + ["about.measures.hour.lead"] = "An hour is counted, uncountable, or not measured.", + ["about.measures.hour.body"] = "The activity grid has three states. The third is empty and " + + "names no cause: an hour we could not reach and an hour we never probed are the same " + + "absence, and neither is that server's downtime.", + + // ── about: what we know we get wrong ────────────────────────────────────────────────── + ["about.limits.heading"] = "What we know we get wrong", + ["about.limits.grace.lead"] = "Archive grace is measured from the day we found you.", + ["about.limits.grace.body"] = "A game that stops answering leaves the default listing after " + + "its grace period: a quarter of the reachable time we probed, floored at 60 days and " + + "capped at 365. A game running since 1995 starts at the floor on the day we discover " + + "it. We import nothing to fill in the years before we arrived.", + ["about.limits.created.lead"] = "We do not credit MSSP CREATED toward that grace.", + ["about.limits.created.body"] = "It is one hand-typed line in a config file, so crediting it " + + "would make the archive threshold gameable. It is shown as a declaration and buys " + + "nothing.", + ["about.limits.claim.lead"] = "Claiming a game earns the ceiling.", + ["about.limits.claim.body"] = "Proving server access is worth the full year of grace, " + + "however long we have been watching.", + ["about.limits.oneHost.lead"] = "Everything here is one host, looking at intervals.", + ["about.limits.oneHost.body"] = "A percentage of reachable time is a fraction of the window " + + "we observed, never of one we did not. No graphic here fills in the rest.", + ["about.limits.deletion.lead"] = "Nothing is ever deleted.", + ["about.limits.deletion.body"] = "Archiving takes a game out of the default listing, the " + + "rankings and the active-today figure, and nothing else. Its page, URL, history and " + + "address survive, it keeps being probed, and one successful probe puts it back.", + + // ── about: what this site will not do ───────────────────────────────────────────────── + ["about.never.heading"] = "What this site will not do", + ["about.never.votes.lead"] = "No votes, stars, ratings or recommendations.", + ["about.never.votes.body"] = "Rankings are computed from measured data only. A directory " + + "ranked by who can mobilise the most clicks describes the campaigning, not the hobby, " + + "and that is what killed the incumbents.", + ["about.never.forums.lead"] = "No forums, reviews, wikis, comments or player profiles.", + ["about.never.forums.body"] = "Orientation material — what a MUSH is, which codebase suits " + + "collaborative roleplay — is written, signed and versioned like the rest of the site.", + ["about.never.names.lead"] = "Player names are never persisted.", + ["about.never.names.body"] = "A WHO reply is parsed in memory for a count and the shape of " + + "the header. The names are not written down; aggregates use a salted hash with a " + + "rotating salt.", + ["about.never.population.lead"] = "No absolute population figure is published.", + ["about.never.population.body"] = "Per-codebase and per-protocol shares ship: a ratio over " + + "the measured set survives the games we cannot count. \"How many people play MU*\" " + + "does not, because that number would not survive being quoted.", + + // ── about: the crawler, and how to make it stop ─────────────────────────────────────── + // Four arguments rather than four concatenations, and none of them is decoration: the + // permitted command list, the MSSP variable and the DNS label and value are all read off the + // objects that consume them, so a page advertising a switch wired to nothing is impossible + // to write. A sentence assembled around them in English word order would have nowhere for a + // translator to put the verb. + ["about.crawler.heading"] = "The crawler, and how to make it stop", + ["about.crawler.probe.lead"] = "A probe is one connection that never logs in.", + ["about.crawler.probe.body"] = "It opens a socket, negotiates telnet options, reads the " + + "connect screen, asks for MSSP by negotiating option 70, sends {commands}, and " + + "disconnects. No character, no login, nothing changed on the far side. A timeout " + + "bounds the session so a wedged probe cannot sit on a connection slot.", + ["about.crawler.delay.lead"] = "CRAWL DELAY wins.", + ["about.crawler.delay.body"] = "A game that states a preferred minimum gap in its MSSP " + + "report gets it, over our own schedule in both directions: 720 hours means monthly, " + + "not weekly. A dark game is still tried for ever at the longer interval, which is how " + + "it re-lists itself when it comes back.", + ["about.crawler.referral.lead"] = "A referred address is verified, never trusted.", + ["about.crawler.referral.body"] = "MSSP lets a game name other games. Every name is resolved " + + "before anything is dialled, and refused unless every address it resolves to is " + + "globally routable. A mixed answer refuses the whole target. Our refusal is filed as " + + "ours and never appears in a game's record as downtime.", + ["about.crawler.screens.lead"] = "Connect screens are shown because they are sent to " + + "everybody.", + ["about.crawler.screens.body"] = "A server paints its connect screen, unauthenticated, to " + + "every anonymous connection. We display it as evidence and label it. Ask and it comes " + + "down.", + ["about.crawler.stop.lead"] = "Say stop, and we stop — three ways.", + ["about.crawler.stop.body"] = "Publish {variable} 1 in your MSSP report, and the probe that " + + "reads it is the last one. Or publish a TXT record at {label}.your.host reading " + + "\"{value}\", which needs no MSSP support and no account here. Or write to a person. " + + "All three are honoured within one crawl cycle, recorded with the date and what we " + + "read, and enforced on the submission form too.", + ["about.crawler.scope.lead"] = "The MSSP field stops that listener; the record stops the " + + "host.", + ["about.crawler.scope.body"] = "MSSP is published by the port that answered, so it speaks " + + "for that port — MU* hosting routinely runs unrelated games on one domain, and one " + + "must not silence its neighbour. A TXT record covers every port unless it names one, " + + "as \"{value}=4201\". Anything there we cannot read as a port list means the whole " + + "host, so \"{value}=all\" works.", + ["about.crawler.dns.lead"] = "The DNS route is the one you can undo without asking us.", + ["about.crawler.dns.body"] = "A TXT record is readable without connecting to a server that " + + "told us not to, so we re-read it before every dial. Delete it and we dial again " + + "within a week. An MSSP field cannot be re-read without doing the thing you asked us " + + "to stop, so MSSP opt-outs and written requests stand until you say otherwise. That " + + "TXT lookup is all an opted-out address gets: it touches your nameserver, never your " + + "game.", + ["about.crawler.stopping.lead"] = "Stopping is not deleting, and it is not downtime.", + ["about.crawler.stopping.body"] = "A game that opts out keeps its page, its address and " + + "everything we measured before it asked. Only new data stops: the activity grid stops " + + "gaining hours and names no cause, because our decision to stop knocking is a fact " + + "about us. It is recorded on the crawl that did not happen, and in the register of " + + "who asked.", + ["about.crawler.unlist.lead"] = "If stopping is not enough, the listing can go too.", + ["about.crawler.unlist.body"] = "Once we have stopped on every address your game answers " + + "on, your dashboard offers one more thing: take it out of the listing, the rankings " + + "and the daily figure. The page and every address it has ever had still answer, and " + + "nothing is deleted — it stops being somewhere a reader arrives by browsing. It needs " + + "a verified claim, because it is a decision about your game and we record who made " + + "it. And a probe undoes it: take your opt-out back, and the next dial that gets an " + + "answer puts you back in the listing without asking us twice.", + + // ── about: who is knocking ──────────────────────────────────────────────────────────── + // Two whole sentences and not one with a branch, because the unannounced case is a + // paragraph explaining a library gap and the announced one is a line. The name is an + // argument in both: it is read off ProbeOptions, so a deployment that configures its own + // gets a page that names it. + ["about.identity.announced"] = "The crawler names itself {name} when a server asks what it " + + "is.", + ["about.identity.unannounced"] = "The crawler is configured to call itself {name} but cannot " + + "yet say so. Its telnet library gives a client no way to set the terminal type, so " + + "your logs see that library's default, and NEW-ENVIRON is answered from the crawler " + + "host's environment. Both are gaps in the library and ours to fix there. Until then, " + + "recognise a probe by its shape: one connection, no login, a short read-only command " + + "set, gone.", + ["about.identity.crawler"] = "crawler", + ["about.identity.contact"] = "contact", + ["about.identity.crawler.line"] = "Crawler: {name}", + ["about.identity.contact.line"] = "Contact: {url}", + ["about.identity.placeholder"] = "— placeholder; this deployment set no contact address", + ["about.identity.placeholder.plain"] = "No contact address is configured, so the one above " + + "is a placeholder and answers nobody.", + + // ── about: where the list of games came from ────────────────────────────────────────── + // The directories' own names and addresses are machine voice and are nowhere in this file. + // What each one gave us, and whether we read it at all, is ours to say and so is here. + ["about.sources.heading"] = "Where the list of games came from", + ["about.sources.addresses.lead"] = "We take addresses. Nothing else.", + ["about.sources.addresses.body"] = "A backfill takes a host and a port. No player counts, no " + + "reachability history, no descriptions, no fields, and no note of which site an " + + "address came from.", + ["about.sources.less.lead"] = "Deliberately less than those sites can give.", + ["about.sources.less.body"] = "Several hold years of dated player counts. Importing that " + + "would fill the heatmaps of the games somebody else was already watching, and rest " + + "this site's central claim on another party's prober.", + ["about.sources.origin.lead"] = "A game's origin is not one fact.", + ["about.sources.origin.body"] = "Any game worth listing appears in several of these " + + "directories, so \"imported from\" would name whichever fetch ran first. That a game " + + "exists is public information; where we read it adds nothing and is the part of " + + "somebody else's work with the least claim to be ours.", + ["about.sources.etiquette.lead"] = "Reading somebody's site is still reading somebody's " + + "site.", + ["about.sources.etiquette.body"] = "We ask for a bulk export or a documented endpoint before " + + "scraping, read robots.txt first, and rate-limit scrapes hard. A source that needs its " + + "maintainer's say-so is not fetched until a person can state they were asked.", + + // Two states and never one word for both: a directory we chose not to fetch is a different + // fact from one we could not, and the badge is the only place a reader meets the difference. + ["about.source.read"] = "read — addresses only", + ["about.source.withheld"] = "not read — awaiting permission", + + ["about.source.tintinMssp.note"] = "One page, one request. Published by a crawler that " + + "connects to each game and prints what it read.", + ["about.source.tintinMsdp.note"] = "The same crawler's MSDP listing. Nearly a subset of its " + + "MSSP sibling, read for the few addresses it reaches that the other does not.", + ["about.source.mudConnector.note"] = "Publishes its whole catalogue on one page, so reading " + + "it costs a single request. Our largest source of addresses, and of no measurements.", + ["about.source.mudStats.note"] = "One index page and one page per world, so a scrape rather " + + "than an export. On 30 July 2026 we fetched 143 of their pages, fifteen seconds apart " + + "and honouring robots.txt, but before anyone had written to them. That should not " + + "have happened. The gate now takes a person willing to state the maintainer was " + + "asked.", + ["about.source.mudVerse.note"] = "Implemented, tested, never run. The strongest source here " + + "on every axis except permission, and nothing will be fetched until somebody has " + + "written to them.", + + // ── about: licence ──────────────────────────────────────────────────────────────────── + ["about.licence.heading"] = "Licence", + ["about.licence.code.lead"] = "The code is MIT.", + ["about.licence.code.body"] = "The site, the crawler and the parsers are open source under " + + "the MIT licence.", + ["about.licence.open.lead"] = "The licence for the data is an open question.", + ["about.licence.open.body"] = "A separate decision from the code's, and not yet taken. Treat " + + "the terms below as this deployment's current answer, not the project's settled " + + "position. A rival directory taking the whole catalogue is a success condition here, " + + "so whatever is settled will not stand in the way of one.", + ["about.licence.codeLabel"] = "code", + ["about.licence.dataLabel"] = "data, as this deployment serves it", + ["about.licence.creditLabel"] = "credit as", + ["about.licence.code.line"] = "Code: {licence}", + ["about.licence.data.line"] = "Data: {licence}", + ["about.licence.credit.line"] = "Credit as: {credit}", + ["about.licence.deployment"] = "(what this deployment serves. The project's own answer is " + + "still open.)", + + // ── submit a game ───────────────────────────────────────────────────────────────────── + // A host, a port, and nothing else. The form has no name box and no description box, so + // every word on this page is ours rather than a submitter's, and all of it belongs here. + ["submit.title"] = "Submit a game", + ["submit.lede"] = "Tell us where a game is. A host and a port is the whole form; everything " + + "else on this site is measured by our own crawler.", + ["submit.host.label"] = "Host", + ["submit.port.label"] = "Port", + ["submit.host.hint"] = "mud.example.org, or paste mud.example.org:4201 and leave the port " + + "empty", + ["submit.button"] = "Submit", + ["submit.noCatalogue"] = "Submitting needs a database, and this site is running on the demo " + + "fixture. There is no crawl registry to write into, so the form is absent rather than " + + "quietly doing nothing.", + ["submit.notHere"] = "Not here", + ["submit.what.heading"] = "What happens to an address", + ["submit.what.resolve"] = "We resolve the address before dialling it, and refuse anything " + + "that resolves off the public internet. That is a decision about our own socket, " + + "never a fact about a game.", + ["submit.what.optOut"] = "If whoever runs that host has asked us not to crawl it, we will " + + "not take the address, whoever submits it. A stranger cannot put your game back on " + + "this site.", + ["submit.what.schedule"] = "If it answers, we read what the server says for itself and keep " + + "reading it on its own schedule, for ever. An address only has to be given once.", + ["submit.what.claim"] = "Nothing appears on the site until somebody proves they run it. " + + "Claiming takes a passkey and one line published on the game itself.", + ["submit.what.duplicate"] = "An address we already have collapses onto the existing entry. " + + "Sending it twice makes no second listing and brings no probe forward.", + + // The answers. Every one takes {address} as an argument rather than opening with it, because + // a language that puts the subject elsewhere has nowhere to say so if the address is glued + // to the front of an English sentence. The word for an address we could not read is its own + // id: it is a noun phrase standing where a hostname would, and it inflects. + ["submit.answer.thatAddress"] = "that address", + ["submit.accepted.heading"] = "In the registry.", + ["submit.accepted.sentence"] = "{address} will be dialled on the next crawl cycle, then on " + + "its own schedule for ever. It appears here once somebody proves they run it — come " + + "back to this form with the same address and it will hand you the link.", + ["submit.unclaimed.heading"] = "We have it, unclaimed.", + ["submit.unclaimed.sentence"] = "{address} is one we already measure. It stays off the site " + + "until somebody proves they run it. If that is you, this is the way in.", + ["submit.known.heading"] = "We already have that one.", + ["submit.known.sentence"] = "{address} is a game we already measure. Nothing was created and " + + "nothing was changed.", + ["submit.knownAddress.heading"] = "We already have that address.", + ["submit.knownAddress.sentence"] = "{address} is already known to us. Nothing was created " + + "and nothing was changed.", + ["submit.queued.heading"] = "Already waiting.", + ["submit.queued.sentence"] = "{address} is in the crawl registry and has not answered yet. " + + "Sending it again does not bring it forward: a target keeps its own schedule, so " + + "nobody can hurry us at somebody else's server.", + ["submit.malformed.heading"] = "Not an address we can dial.", + ["submit.malformed.sentence"] = "A host needs a dot or a colon in it, and a port is a number " + + "between 1 and 65535. Fill in both boxes, or paste mud.example.org:4201 into the " + + "first.", + ["submit.undialable.heading"] = "We cannot dial that.", + ["submit.undialable.sentence"] = "Three things produce this answer for {address}: the name " + + "may not resolve, it may resolve off the public internet, or whoever runs that host " + + "may have asked us to stay away. We deliberately do not say which, because answering " + + "that for a stranger maps a network from outside it. Nothing was recorded about the " + + "address; the decision was ours and it is filed as ours.", + ["submit.tooMany.heading"] = "Enough for now.", + ["submit.tooMany.sentence"] = "This form is rate-limited by sender, and you have hit the " + + "bound. Come back in an hour. Nothing was lost — anything we took is already in the " + + "registry.", + ["submit.link.claim"] = "claim this game", + + // ── signing in, which is a passkey and nothing else ─────────────────────────────────── + ["account.signIn.title"] = "Sign in", + ["account.signIn.preview"] = "Sign in with a passkey to claim a game you run. There is no " + + "password to lose and none to steal.", + ["account.signIn.noDatabase"] = "Claiming needs a database, and this site is running on the " + + "demo fixture. There is nothing to sign in to.", + ["account.signIn.passkey.lead"] = "Sign-in is a passkey.", + ["account.signIn.passkey.body"] = "Your device or password manager holds the private key; we " + + "hold only the public half. No password, no email.", + ["account.signIn.button"] = "Sign in with a passkey", + ["account.signIn.script"] = "The one page here that needs JavaScript. Passkeys cannot work " + + "without it.", + ["account.register.heading"] = "No account yet?", + ["account.register.lede"] = "You need one only to claim a game you run. Pick a name to be " + + "known by — a label beside your claim, not a real name.", + ["account.register.name.label"] = "Name", + ["account.register.name.placeholder"] = "e.g. corvid-admin", + ["account.register.button"] = "Create an account with a passkey", + ["account.store.heading"] = "What we store", + ["account.store.name"] = "The name you chose.", + ["account.store.keys"] = "The public key of each passkey you register, and what your device " + + "called it.", + ["account.store.claims"] = "Which games you have claimed, and when.", + ["account.store.note"] = "No email address, no password, no IP log tied to your account. " + + "Lose every passkey and you can publish a fresh claim token on your game and start " + + "again: the game is the proof, not the account.", + + // ── dates, ages and provenance — the two shapes on nearly every page ────────────────── + // Appended as one block on purpose: three other surfaces are appending to this file at the + // same time, and a marked section at the end is a merge that adds rather than one that + // collides. + // + // The words come from CLDR and the order comes from here. A month name is + // CultureInfo's — see Locales.CultureOf, and see the day names in the heatmap, + // which are the same job — but the *arrangement* of day, month and year is not something a + // .NET format string can express for a language it is never sent. Japanese writes + // 2026年7月30日 and German puts a point after the day; both are one edit to this pattern and + // neither is reachable through ToString("d MMM yyyy"). + ["date.absolute"] = "{day} {month} {year}", + + // UTC is named rather than implied, and it is not a word to translate. Every time on this + // site is UTC because a crawler's clock is the only one it has, and a reader in another zone + // who is not told cannot tell whether 14:02 is theirs. The 24-hour spelling is the site's + // and not the locale's for the same reason: one zone, one clock, one shape. + ["date.stamp"] = "{date} {time} UTC", + + // ── the age ladder, in three registers ──────────────────────────────────────────────── + // Three families over the same seven rungs, and the English is identical in two of them. + // That is the point. A bare duration is a duration; "how long ago did we last confirm this + // value" and "how long has this game been unreached" are two different questions, and a + // language that answers them with one phrasing can still choose to — while one that needs + // "vor 2 Wo." for the first and "seit 2 Wo." for the second has somewhere to say so. + // + // Every rung is a real ICU plural even where English has one form, because the branch a + // language actually needs is not knowable from the source text. `#` prints the number. + ["age.short.now"] = "now", + ["age.short.minutes"] = "{count, plural, one {#m} other {#m}}", + ["age.short.hours"] = "{count, plural, one {#h} other {#h}}", + ["age.short.days"] = "{count, plural, one {#d} other {#d}}", + ["age.short.weeks"] = "{count, plural, one {#w} other {#w}}", + ["age.short.months"] = "{count, plural, one {#mo} other {#mo}}", + ["age.short.years"] = "{count, plural, one {#y} other {#y}}", + + // How long ago we last confirmed a value. The freshest rung is a word rather than a + // duration — "now ago" was a real bug, and giving the rung its own id is what makes it + // unwritable rather than merely fixed. + ["age.ago.now"] = "just now", + ["age.ago.minutes"] = "{count, plural, one {#m ago} other {#m ago}}", + ["age.ago.hours"] = "{count, plural, one {#h ago} other {#h ago}}", + ["age.ago.days"] = "{count, plural, one {#d ago} other {#d ago}}", + ["age.ago.weeks"] = "{count, plural, one {#w ago} other {#w ago}}", + ["age.ago.months"] = "{count, plural, one {#mo ago} other {#mo ago}}", + ["age.ago.years"] = "{count, plural, one {#y ago} other {#y ago}}", + + // How long since the game was last reached. Identical English, different question — and + // never "offline" or "down" in any language: we measured a socket from one vantage point + // and a game with a routing problem to our host is unreachable and perfectly alive. + ["age.dark.now"] = "just now", + ["age.dark.minutes"] = "{count, plural, one {#m ago} other {#m ago}}", + ["age.dark.hours"] = "{count, plural, one {#h ago} other {#h ago}}", + ["age.dark.days"] = "{count, plural, one {#d ago} other {#d ago}}", + ["age.dark.weeks"] = "{count, plural, one {#w ago} other {#w ago}}", + ["age.dark.months"] = "{count, plural, one {#mo ago} other {#mo ago}}", + ["age.dark.years"] = "{count, plural, one {#y ago} other {#y ago}}", + + // The element's own two joins: the hover title, and the absolute a screen reader + // hears after the visible age. Both are an age and an instant in one string, and which one + // comes first is a language's decision rather than a comma in a template. + ["time.title"] = "{age}, {stamp}", + ["time.spoken"] = ", {stamp}", + + // ── the provenance chip's tooltip ───────────────────────────────────────────────────── + // The value and the source token are machine voice and pass through untranslated; every + // word around them is ours. "last confirmed" is a fact about our crawl and not about the + // game, and it must stay one in every language — it is the date we last saw the value, not + // a date the game did anything. + ["chip.title"] = "{value} — {how} via {source}, last confirmed {date}", + ["chip.title.stale"] = "{value} — {how} via {source}, last confirmed {date} (past its expected refresh)", + + // The same chip in plain text, where there is no hover to put it in. + ["chip.plain"] = "({how}, {age})", + ["chip.plain.stale"] = "({how}, {age}, stale)", + + // Three registers of the measured/declared line already exist above; this is the fourth + // subject, and it is a separate id for the reason all the others are. + ["provenance.game.ownerDeclared"] = "owner-declared", + + // ── how a value reached us, one id per source ───────────────────────────────────────── + // A display name, not an enum member. The chip's tooltip printed FieldSource's own + // ToString and told readers a value came "via Mssp" — an acronym mis-cased, in a sentence + // no translator could reach, because the words were never in a file they are sent. MSSP, + // GMCP, WHO, INFO and I3 are protocol names and stay exactly as they are in every locale; + // the handshake, the connect screen, the owner and this project's staff are ours to say. + ["source.staff"] = "staff", + ["source.handshake"] = "the telnet handshake", + ["source.owner"] = "the owner", + ["source.who"] = "WHO", + ["source.i3"] = "I3", + ["source.mssp"] = "MSSP", + ["source.info"] = "INFO", + ["source.i3Mudlist"] = "the I3 mudlist", + ["source.banner"] = "the connect screen", + + // THE CATALOGUE SURFACES — /ecosystem, /rankings, /archive, and /find's footer. + // + // Every sentence on these three pages is made of its own qualifications, which is why + // none of them is assembled from parts. "Of the 7 games listed, 5 told us what they run" + // is a sentence about our measurement rather than about the hobby, and a translator handed + // "Of the", a number, "games listed" and another number has nowhere to stand: the clause + // order, the agreement and which number is the denominator are all decisions the source + // language made silently. Each id below is therefore a whole claim with its numbers as + // arguments, and the count and the set come before the percentage in every one of them. + // ═════════════════════════════════════════════════════════════════════════════════════ + + // ── the ecosystem dashboard: shares, never totals, and never a share without its set ── + // + // §15.7 withholds the absolute "how many people play MU*" figure, and no message here has + // an argument that could carry one — the page's own guard, restated in the shape of the + // bundle. What every id does carry is its denominator, in the same string as the + // percentage, because the percentage is the part that travels when somebody quotes it. + ["ecosystem.title"] = "The ecosystem", + ["ecosystem.noTotals"] = "Shares, never totals. We do not publish a figure for how many people play MU*: a ratio over the games we measured survives the ones we cannot reach, and a headcount does not.", + + // The three counts at the top. {value} is the numeral as the page draws it — the markup + // is an argument rather than part of the message, so a language that puts the number last + // may, and a translator never meets a tag. + ["ecosystem.listed"] = "{count, plural, one {{value} game listed} other {{value} games listed}}", + ["ecosystem.handshakes"] = "{count, plural, one {{value} game whose handshake we completed} other {{value} games whose handshake we completed}}", + ["ecosystem.msspReports"] = "{count, plural, one {{value} game whose MSSP report we hold} other {{value} games whose MSSP report we hold}}", + ["ecosystem.oldestHandshake"] = "Oldest handshake here: confirmed {age} ago.", + + // A share, in the one order this site states one: the count, the set it was counted in, + // and only then the percentage. An empty denominator is not nought per cent — 0 of 0 is + // nothing measured, in the same way an unknown count is not a zero. + ["ecosystem.share"] = "{count, number} of {total, number} ({fraction, number, ::percent .0})", + ["ecosystem.share.nothing"] = "{count, number} of {total, number} — nothing measured yet", + // The percentage alone, for the one cell whose denominator is its column head. + ["ecosystem.share.percent"] = "{fraction, number, ::percent .0}", + + ["ecosystem.codebases.title"] = "Codebases", + // Both numbers, because one of them read as the other: "Share of the 144 listed games that + // told us what they run" put the identified count where the size of the catalogue belongs, + // and a reader came away believing the site listed 144 games rather than 418. + ["ecosystem.codebases.basis"] = "Of the {listed, plural, one {# game} other {# games}} listed, {identified, number} told us what they run, and every share below is over those {identified, number}. A codebase we could not read is left out of the denominator, never counted as something else.", + ["ecosystem.codebases.none"] = "No listed game has told us its codebase yet.", + ["ecosystem.soleUse"] = "{share} run a codebase no other listed game runs — one game each, which is a name rather than a share. They are inside the denominator above and folded out of the bars, not dropped:", + + ["ecosystem.lineages.title"] = "Lineages", + ["ecosystem.lineages.basis"] = "The same games, grouped by the tradition their server descends from — our reading of the codebase, not anything a game published. No game reports \"MUSH\": MSSP has no such value, and most of the MUSH world publishes no MSSP at all, so this is the only way the question can be asked.", + ["ecosystem.lineages.none"] = "No listed game runs a codebase we place in a lineage yet.", + // Our abstention, kept out of everyone else's bar and said in the same breath as the + // denominator it stays inside. {family} is the MSSP value itself and is machine voice. + ["ecosystem.lineages.notClassified"] = "{count, plural, one {# of those games runs a codebase} other {# of those games run codebases}} we do not place in any lineage — several say as much themselves, publishing {family}. They are inside the denominator above and in nobody's share.", + + ["ecosystem.protocols.title"] = "Protocols", + ["ecosystem.protocols.floor"] = "Read every measured figure below as a floor. We ask for MSSP by name, so silence there is an answer. Nothing else here is requested, and a server may support a protocol without ever offering it.", + ["ecosystem.mssp.instrument"] = "{instrument} is the one row below that is not a floor: we ask every server for it by name, so the games that did not offer it were asked and declined. It is also the only one with no declared figure, because every game whose report we hold supports it by demonstration and a count of the ones that also listed it would measure a habit against that.", + ["ecosystem.mssp.gap"] = "We hold {reports, number} reports and {offered, plural, one {# game} other {# games}} offer MSSP today: the other {gap, number} stopped publishing one after we read it, and a report is not thrown away because it stopped being reissued.", + ["ecosystem.protocols.caption"] = "Protocol adoption. Measured is what a server offered in a completed handshake; declared is what its MSSP claims. Two sets of games, so two denominators.", + ["ecosystem.column.protocol"] = "protocol", + ["ecosystem.column.measured"] = "measured — of {basis}", + ["ecosystem.column.declared"] = "declared — of {basis}", + + // Four whole sentences rather than a share with clauses bolted on. The English builds them + // by concatenation and the concatenation is the defect: "· 6 games neither offered nor + // asked" is a fragment in English word order that no translator can reorder. + ["ecosystem.measured.never"] = "not measured — never observed", + ["ecosystem.measured.declined"] = "{share} · {declined, plural, one {# game} other {# games}} declined when asked", + ["ecosystem.measured.unasked"] = "{share} · {unobserved, plural, one {# game} other {# games}} neither offered nor asked", + ["ecosystem.measured.declinedAndUnasked"] = "{share} · {declined, plural, one {# game} other {# games}} declined when asked · {unobserved, plural, one {# game} other {# games}} neither offered nor asked", + ["ecosystem.declared.none"] = "not asked — every report here is the answer", + + ["ecosystem.curve.title"] = "Adoption over time", + ["ecosystem.curve.caveat"] = "Each point is a share over the games we had measured that day, so this line moves for two reasons: a game changing what it offers, and the set of games we can measure changing around it. Only the first is adoption. The transition count below is the part that is purely games changing their minds.", + ["ecosystem.curve.caption"] = "Measured share of each protocol, oldest reading first", + ["ecosystem.curve.then"] = "then", + ["ecosystem.curve.now"] = "now", + // Its own id rather than state.notMeasured: that one is an hour of the week and this is a + // protocol share, and the two take different forms in a language that inflects. + ["ecosystem.curve.notMeasured"] = "not measured", + ["ecosystem.snapshot.title"] = "A snapshot, not a curve", + ["ecosystem.snapshot"] = "A snapshot of what we can measure now. An adoption curve plots games changing their minds, and we record a change when it happens, so the curve becomes drawable once enough have been recorded. Plotting when we first reached each game would measure the crawl, not the hobby.", + ["ecosystem.transitions"] = "{count, plural, one {# capability change} other {# capability changes}} recorded so far — the material a curve is drawn from.", + ["ecosystem.transitions.none"] = "No measured capability has changed yet, so there is nothing to plot.", + + // The plain surface's own line, which has no table head to hang the two denominators on. + ["ecosystem.plain.denominators"] = "Measured is of {measured}; declared is of {declared}. Two sets of games, so two denominators.", + ["ecosystem.plain.counts"] = "{listed} · {handshakes} · {mssp}.", + ["ecosystem.plain.oldestHandshake"] = "The oldest handshake in this picture was last confirmed {age} ago.", + ["ecosystem.plain.lineages"] = "The same games, grouped by the tradition their server descends from. This is {evidence} — {meaning} — and not anything a game published: no game reports \"MUSH\", because MSSP has no such value and most of the MUSH world publishes no MSSP at all.", + ["ecosystem.plain.measured"] = "measured: {value}", + ["ecosystem.plain.declared"] = "declared: {value}", + + // ── the rankings: computed from measured data only, and it says so first ────────────── + // + // §2 rules out the vote permanently rather than pending a feature, so the claim has to + // survive translation exactly: no votes, no stars, no ratings, and nothing here ranking + // quality. A locale that softened that into "our favourites" would be publishing the one + // thing this page exists to refuse. + ["rankings.title"] = "Rankings", + ["rankings.noVote"] = "Computed from measured data only. No votes, stars or ratings, ever. Nothing here ranks quality. We have not measured it.", + ["rankings.busiest.title"] = "Busiest, by measured concurrent players", + ["rankings.window.label"] = "Ranking window", + // One id for all three windows and the fallback alike. A ranking window and a sort window + // are different jobs, so this is not window.7 under another name. + ["rankings.span"] = "{days, plural, one {# day} other {# days}}", + + // The basis, as three whole sentences the caller joins rather than one string built from + // clauses. "0 of 519 games listed produced the 24 counted samples a median needs, on at + // least 4 days of the window" was arithmetic where a sentence would do. + ["rankings.basis.median"] = "Median of the player counts we measured over the last {days, plural, one {# day} other {# days}}.", + ["rankings.basis.none"] = "No game yet has the {samples, number} samples across {days, plural, one {# day} other {# days}} that a median needs.", + ["rankings.basis.eligible"] = "{eligible, plural, one {# game} other {# games}} of {listed, number} have the {samples, number} samples across {days, plural, one {# day} other {# days}} it needs.", + // Rule 4, on the surface where a zero is most likely to be read as an absence. + ["rankings.basis.zero"] = "A measured zero counts; an unreadable count does not.", + ["rankings.spanChoice"] = "A week says who is busy now; a quarter says who has been busy. They are different questions and a game can lead one and not the other. Days are whole days, UTC.", + ["rankings.busiest.empty"] = "No listed game has enough counted samples to rank yet — a statement about how long we have been measuring, not about how busy anybody is.", + ["rankings.busiest.caption"] = "Games ranked by the median of the player counts we measured over the last {days, plural, one {# day} other {# days}}. Games on the same median share a place; nothing here breaks the tie.", + ["rankings.column.place"] = "#", + ["rankings.column.game"] = "game", + ["rankings.column.median"] = "median", + ["rankings.column.peak"] = "peak", + ["rankings.column.samples"] = "counted samples", + ["rankings.column.days"] = "days measured", + + ["rankings.spells.title"] = "Longest unbroken reachable spell", + // Reachable, never uptime — schema, API, code and copy, and here most of all, because the + // sentence exists to say which of the two was measured. + ["rankings.spells.basis"] = "Every probe since the date given found the game reachable. Reachable, not up: we measure a socket from one host, and a game we cannot route to is perfectly alive. A spell cannot be longer than we have been watching, so the date is the fact and the duration follows.", + ["rankings.spells.empty"] = "No listed game is in an unbroken reachable spell right now.", + ["rankings.spells.caption"] = "Games whose every probe since the date given found them reachable. Games reachable since the same date share a place; nothing here breaks the tie.", + ["rankings.column.since"] = "reachable since", + ["rankings.column.duration"] = "that is", + // §7.5 in one sentence: out of these two tables and out of nothing else. + ["rankings.archivedNote"] = "Archived games are out of both tables and nothing else; one successful probe puts them back.", + + ["rankings.plain.busiest"] = "Busiest — median measured players, last {days, plural, one {# day} other {# days}}", + ["rankings.plain.windows"] = "windows:", + ["rankings.plain.thisOne"] = "this one", + ["rankings.plain.row"] = "median {median, number} · peak {peak, number} · {samples, plural, one {# counted sample} other {# counted samples}} over {days, number} of {window, number} days", + ["rankings.plain.spellRow"] = "reachable on every probe since {date} · {duration}", + + // ── the archive: removed from the default listing, and from nothing else ────────────── + // + // §7.5. Nothing here is deleted, closed, dead or defunct: the game's page, URL, history + // and change feed are untouched, it goes on being probed for ever, and one successful + // probe puts it back the same day. "Archived" is a library catalogue's word for a + // periodical that ceased publication, and the sentences below say the whole of that so a + // translator is never left to pick a word from the tone alone. + ["archive.title"] = "The archive", + ["archive.lede"] = "Games that have stopped answering. Nothing was deleted. Still probed weekly, and one successful probe puts a game back in the listing the same day.", + ["archive.search.legend"] = "search the archive", + ["archive.search.label"] = "Search archived games", + ["archive.search.placeholder"] = "name, codebase or description", + ["archive.search.submit"] = "show", + ["archive.count"] = "{count, plural, =0 {No archived games} one {# archived game} other {# archived games}}", + ["archive.badge"] = "archived", + ["archive.lastReachable"] = "last reachable", + ["archive.knownLive"] = "known live", + ["archive.darkFor"] = "({age} ago)", + ["archive.empty"] = "Nothing matched.", + + // Never reached is not reached long ago, and no reachable time measured is not a measured + // zero. Both are gaps in our record and neither may name a cause. + ["archive.neverReachable"] = "never, in anything we measured", + ["archive.noReachableTime"] = "no reachable time measured", + ["archive.darkFor.unknown"] = "unknown", + ["archive.knownLive.years"] = "{years, number, ::.#} years", + ["archive.knownLive.days"] = "{days, plural, one {# day} other {# days}}", + ["archive.run"] = "{from} – {to} · {span}", + ["archive.run.months"] = "{count, plural, one {# month} other {# months}}", + ["archive.run.years"] = "{count, plural, one {# year} other {# years}}", + + ["archive.plain.matching"] = "{count, plural, one {# game} other {# games}} matching \"{query}\"", + ["archive.plain.count"] = "{count, plural, one {# game} other {# games}}", + ["archive.plain.lastReachable"] = "Last reachable:", + ["archive.plain.knownLive"] = "Known live:", + ["archive.plain.knownLiveValue"] = "{value} of measured reachable time", + ["archive.plain.run"] = "Run:", + ["archive.plain.codebase"] = "Codebase:", + + // ── /find's footer, which the rebuild left in English ──────────────────────────────── + // + // The other two links here are footer.allGames and a11y.plainText, already said elsewhere + // in exactly this job. This one had nothing to reuse: nav.random is a nav item reading + // "random", and a footer link naming what it fetches is a different phrase. + ["footer.randomGame"] = "random game", + + // THE TREND CHART, THE REACHABILITY STRIP AND THE REST OF THE GAME PAGE + // ═════════════════════════════════════════════════════════════════════════════════════ + // + // The last surface that answered in English whatever the reader asked for, and the largest: + // ninety per-day tooltips on the trend chart and ninety more on the strip beside it were two + // hundred strings of one shape, built by gluing a date to a fragment. + // + // **A day has the same three states an hour has, and they are three ids here.** A day we + // counted — a measured zero included — a day probed all through that produced no count, and + // a day with no measurement at all. The third names no cause in any language: a failed probe + // writes no presence row, so an empty day covers a probe we could not complete and a day we + // never dialled alike, and a translation reaching for "offline" or "not reachable" would file + // our crawl schedule as a fact about somebody's game. `trend.day.notMeasured` and + // `trend.day.notCounted` are deliberately far apart in wording so no locale can quietly + // render both as *unavailable*, and a test asserts they stay different in every locale. + // + // **Dates are `{d, date, …}` arguments, never a month name written here.** The style is part + // of the pattern, so a translator who needs `2026/05/21` writes `{d, date, short}` in their + // own copy and gets it — which is the whole reason the date went through the formatter + // rather than through a `ToString` at the call site. + + // ── the trend chart's per-day title, one per column ─────────────────────────────────── + // Four shapes, and the reader gets exactly one. A flat day — every probe returning the same + // number — is its own id rather than a range with the same number twice, because "24–24" is + // arithmetic where a measurement was asked for. + ["trend.day.counted"] = "{d, date, medium} — {typical} on average, {low}–{high} across {probes, plural, one {# probe} other {# probes}}", + ["trend.day.flat"] = "{d, date, medium} — {count, plural, =0 {0 players} one {# player} other {# players}}, every one of {probes, plural, one {# probe} other {# probes}}", + ["trend.day.notCounted"] = "{d, date, medium} — probed, no count could be read", + ["trend.day.notMeasured"] = "{d, date, medium} — no measurement", + + // ── the sentence above the chart ────────────────────────────────────────────────────── + // It was five fragments: two numbers spelled into English, a hand-written "N of M days" and + // a trend word chosen by an English rule and glued on the end. The direction is its own + // sentence rather than a word substituted into this one, so a language that opens with it + // can. + ["trend.summary"] = "Typically {typical} on, peaking at {peak}, over {counted} of {days, plural, one {# day} other {# days}}.", + ["trend.direction.steady"] = "Steady across the range.", + ["trend.direction.up"] = "Up about {change, number, percent} from the start of the range to the end.", + ["trend.direction.down"] = "Down about {change, number, percent} from the start of the range to the end.", + + // Both of the "nothing to draw" sentences, and neither of them gives a cause. + ["trend.none.probed"] = "Probed in this range, and no player count could be read from any of it.", + ["trend.none.notMeasured"] = "No measurement in this range.", + ["trend.empty"] = "Nothing counted in this range.", + + // ── the week lines, which are the text alternative and the plain surface both ───────── + ["trend.week.span"] = "{from, date, d MMM}–{to, date, d MMM}", + ["trend.week.oneDay"] = "{d, date, d MMM}", + ["trend.week.counted"] = "{span}: typically {typical}, peak {peak}, {days, plural, one {# day} other {# days}} counted", + ["trend.week.notCounted"] = "{span}: probed, no count could be read", + ["trend.week.notMeasured"] = "{span}: not measured", + ["trend.week.uncounted"] = "{count, plural, one {# day probed without a count} other {# days probed without a count}}", + ["trend.week.unmeasured"] = "{count, plural, one {# day not measured} other {# days not measured}}", + ["trend.week.and"] = "{line}, {clause}", + + // ── the chart's own chrome ──────────────────────────────────────────────────────────── + ["trend.range"] = "days in UTC · {from, date, medium} – {to, date, medium}", + ["trend.ceiling"] = "{value} at the top", + ["trend.counted"] = "{counted} of {days, plural, one {# day} other {# days}} counted", + ["trend.axis.month"] = "{d, date, MMM}", + ["trend.axis.monthYear"] = "{d, date, MMM yyyy}", + + // The legend. "not measured" gets the whole clause rather than the two words plus a dash and + // a fragment, because what follows the dash is a fact about the drawing and moves in a + // sentence that puts the verb last. + ["trend.legend.mean"] = "mean of the counts we read that day", + ["trend.legend.peak"] = "up to the busiest count that day", + ["trend.legend.band"] = "lowest to highest count that day", + ["trend.legend.notCounted"] = "probed, no count could be read", + ["trend.legend.notMeasured.bar"] = "not measured — no bar at all", + ["trend.legend.notMeasured.line"] = "not measured — a break in the line", + + // ── seeking and switching, which are links because the range is in the address ──────── + ["trend.spans.label"] = "Trend range", + ["trend.shapes.label"] = "Trend shape", + ["trend.preset"] = "{days, plural, one {# day} other {# days}}", + ["trend.earlier"] = "← earlier", + ["trend.later"] = "later →", + ["trend.shape.line"] = "line", + ["trend.shape.bar"] = "bars", + ["trend.plain.heading"] = "How many, over time", + ["trend.plain.range"] = "{from, date, medium} – {to, date, medium}, UTC", + ["trend.plain.earlier"] = "earlier", + ["trend.plain.note"] = "a week is summarised over the days in it we counted; a week with none says so", + + // ── the reachability strip ──────────────────────────────────────────────────────────── + // *Reachable*, never *up*. We measured a socket from one vantage point, and a game with a + // routing problem to our host is unreachable and perfectly alive (spec §5.8). Every locale + // has to keep that distinction, which is why the word is a locked id in four registers + // rather than one string reused in four places: the stat label, the legend swatch, the noun + // inside a spell, and the day's own tooltip decline differently in most languages. + ["reach.kicker"] = "reachable · last {days, plural, one {# day} other {# days}}", + ["reach.stat.reachable"] = "reachable", + ["reach.stat.longestOutage"] = "longest outage", + ["reach.stat.lastCause"] = "last cause", + ["reach.noOutage"] = "none in the window", + ["reach.noCause"] = "nothing recorded", + ["reach.scale.ago"] = "{days, plural, one {# day ago} other {# days ago}}", + ["reach.scale.today"] = "today", + ["reach.legend.reachable"] = "reachable", + ["reach.legend.degraded"] = "degraded — answered, could not finish", + ["reach.legend.unreachable"] = "unreachable", + ["reach.legend.notMeasured"] = "not measured", + + // The state as a noun, for the middle of a spell — "3 days unreachable". Separate from the + // legend above, which is a caption, and from the day tooltips below, which are sentences. + ["reach.word.reachable"] = "reachable", + ["reach.word.degraded"] = "degraded", + ["reach.word.unreachable"] = "unreachable", + ["reach.word.notMeasured"] = "not measured", + + // Ninety of these are drawn per game. The fourth is the one that matters: a day before we + // knew the game existed is not a day it was down, and it says so about us rather than about + // them. + ["reach.day.reachable"] = "{d, date, d MMM} — reachable all day", + ["reach.day.degraded"] = "{d, date, d MMM} — degraded ({cause}): answered, could not finish", + ["reach.day.unreachable"] = "{d, date, d MMM} — unreachable ({cause})", + ["reach.day.notMeasured"] = "{d, date, d MMM} — not measured; we were not watching this game yet", + + // The sentence the strip illustrates. The percentage's denominator is observed time and not + // the window, so there are two ids and not one with a substituted noun: a game found an hour + // ago must not read "Reachable 100.0% of the last 90 days" off a single probe. + ["reach.none"] = "{days, plural, one {Not yet measured over the last # day.} other {Not yet measured over the last # days.}}", + ["reach.fraction.window"] = "Reachable {percent} of the last {days, plural, one {# day} other {# days}}.", + ["reach.fraction.measured"] = "Reachable {percent} of the {days, plural, one {# day} other {# days}} we have measured.", + ["reach.fraction.unknown"] = "{days, plural, one {Reachability over the last # day is not yet measured.} other {Reachability over the last # days is not yet measured.}}", + ["reach.unreachable.noneInWindow"] = "No day in the window was unreachable.", + ["reach.unreachable.noneMeasured"] = "No day we measured was unreachable.", + ["reach.unreachable.days"] = "{count, plural, one {# day unreachable.} other {# days unreachable.}}", + ["reach.degraded.days"] = "{count, plural, one {# day degraded — we got in and could not finish.} other {# days degraded — we got in and could not finish.}}", + ["reach.longestOutage"] = "Longest outage {duration}.", + ["reach.longestOutage.cause"] = "Longest outage {duration} ({cause}).", + ["reach.predate"] = "{count, plural, one {# day predates anything we measured.} other {# days predate anything we measured.}}", + ["reach.spell.range"] = "{from, date, d MMM} – {to, date, d MMM}", + ["reach.spell.oneDay"] = "{d, date, d MMM}", + ["reach.spell"] = "{range}: {count, plural, one {# day} other {# days}} {word}", + ["reach.spell.cause"] = "{range}: {count, plural, one {# day} other {# days}} {word} ({cause})", + ["reach.plain.heading"] = "Reachable", + ["reach.plain.window"] = "{days, plural, one {last # day} other {last # days}}", + ["reach.plain.fraction"] = "Reachable: {percent} of the {days, plural, one {last # day} other {last # days}}", + ["reach.plain.longestOutage"] = "Longest outage: {duration}", + + // ── why a dial did not complete, in a person's words ────────────────────────────────── + // Ours to say and theirs to be measured about: a cause is what our socket saw, so it names + // the event and never a judgement of the game. + ["cause.dns"] = "dns did not resolve", + ["cause.refused"] = "connection refused", + ["cause.tls"] = "tls failed", + ["cause.timeout"] = "timed out", + ["cause.handshakeStalled"] = "handshake stalled", + ["cause.none"] = "no cause recorded", + + // ── the ANSI capture's frame ────────────────────────────────────────────────────────── + // The chrome around somebody else's screen, never the screen: what is inside the is + // what the server sent and is not a string in any bundle. "read as" rather than "encoded + // in", because that is the honest verb — it is what we decoded the bytes with, and a game + // that declares one encoding and sends another makes those two different sentences. + ["ansi.suppressed"] = "The owner asked us not to republish this game's connect screen.", + ["ansi.absent"] = "No connect screen has been captured from this game.", + ["ansi.tooSmall"] = "{count, plural, one {Only # row came back — too little to show.} other {Only # rows came back — too little to show.}}", + ["ansi.asSent"] = "as sent by the server", + ["ansi.size"] = "{columns}×{rows}", + ["ansi.size.doubleWidth"] = "{columns}×{rows}, double-width", + ["ansi.depth.colour"] = "16-colour SGR", + ["ansi.depth.plain"] = "no colour", + ["ansi.readAs"] = "read as {charset}", + ["ansi.captured"] = "captured", + ["ansi.frozen"] = "frozen — the last screen we saw", + ["ansi.alt.named"] = "ASCII art: the connect screen of {game}. Its text is under \"read as text\", below.", + ["ansi.alt"] = "ASCII art: this game's connect screen. Its text is under \"read as text\", below.", + ["ansi.plain.rows"] = "{count, plural, one {connect screen: # line, text only} other {connect screen: # lines, text only}}", + ["ansi.plain.rows.readAs"] = "{count, plural, one {connect screen: # line, text only, read as {charset}} other {connect screen: # lines, text only, read as {charset}}}", + + // ── the capability matrix's disagreements, said in prose ────────────────────────────── + ["capability.disagree.declaredNotOffered"] = "the game declares it, and the server has never offered it in a handshake.", + ["capability.disagree.offeredNotDeclared"] = "the server offers it, and the game's own record says it does not.", + ["capability.disagree.note"] = "Usually a stale hand-typed field, not a lie. Shown because a client should not rely on what the two disagree about.", + + // ── the rest of the game page ───────────────────────────────────────────────────────── + ["game.notFound"] = "Not found", + ["game.notFound.hint"] = "No game at this address. Check the spelling.", + ["game.endpoint.lastAnswered"] = "last answered", + ["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:", + ["game.referral.since"] = "since {date}", + ["game.referral.dropped"] = "no longer listed, last seen", + + // The archive plate. "Still probed" is the promise §7.5 makes and the reason the page is + // still here at all, so it is said rather than implied by the page continuing to exist. + ["game.archived.lastAnswered"] = "Last answered {date}, {ago} ago.", + ["game.archived.stillProbed"] = "Still probed weekly; this page updates the day it answers.", + ["game.archived.knownLive"] = "{span} known live", + + // The two ways out of the listing that are not archiving. They say different things because + // the decisions are different: an exclusion is OURS and has to be arguable, so it prints the + // argument; an unlisting is THEIRS, so it prints no reason at all. + ["game.excluded"] = "Not in the listing, the rankings or the daily figure: we do not think this address is a game somebody can play. Everything below is what it told us, unchanged.", + ["game.excluded.reason"] = "Our reason: {why}", + ["game.unlisted"] = "Not in the listing, the rankings or the daily figure, at the request of the people who run it. Everything below is preserved as it was, this page and every address it has ever had go on answering, and we are not dialling it.", + + // ── the plain surface's own headings for this page ──────────────────────────────────── + ["game.plain.playersNow"] = "Players now: {count}", + // The graphical caption pluralises this and the plain one did not, so one surface said "1 of + // 6 disagrees" and its own mirror said "1 of 6 disagree" about the same six rows. + ["game.plain.capabilities"] = "Capabilities ({disagreeing, plural, one {# of {total} disagrees} other {# of {total} disagree}})", + ["game.plain.declared"] = "Declared by the game", + ["game.plain.connectScreen"] = "Connect screen", + ["game.plain.whatChanged"] = "What changed", + ["game.plain.measured"] = "measured", + ["game.plain.declared.column"] = "declared", + ["game.plain.disagree"] = "** disagree", + + // ══ appended block: the listing row's own labels and the last three chips ══════════════ + // + // The two column heads, repeated per row for assistive technology only. The visible head is + // said once over the column and stays `aria-hidden`, because a reader who can see it does + // not need it five hundred times; a reader who cannot see it has no column at all, and a + // bare "12" and "2h ago" name neither the count nor the age. Two ids rather than splitting + // `listing.columns` on its separator: a locale is free to order or punctuate that head + // however it reads best, and a split would make the row's labels a function of its + // typography. + ["listing.row.connected"] = "connected", + ["listing.row.reached"] = "reached", + + // The chips the active-filter row builds itself rather than reading off a facet group. + // They are the last three that were still English on a translated page: two are questions + // the query asks that no facet answers, and "included" is the only chip value that is a + // state of ours rather than a token a game gave us. + ["facet.group.search"] = "search", + ["facet.group.archived"] = "archived", + ["facet.group.adult"] = "adult", + ["facet.value.included"] = "included", + + // ── added by the accessibility review, second pass ──────────────────────────────────── + // Kept in one block at the end because several agents are appending to this file at once. + + // The game page's live figure when there is no count to put in it — rule 2's third state on + // the hero rather than on a heatmap cell. + // + // `PlayersNow` is null for an hour nobody measured, for a probe that answered with nothing + // countable, and for a count older than the window this figure covers: one null over three + // situations the page cannot tell apart. It said `state.notCounted`, which the glossary + // reserves for an unreadable count — so a game we have never once reached was described as + // one we probed and failed to count, which is our silence published as their measurement. + // What is left is the absence itself, naming no cause. + ["game.count.none"] = "no count", + ["game.count.none.why"] = "no current count, and nothing here says why", + ["game.plain.playersNoCount"] = "Players now: no count (nothing here says why)", + + // ══ appended: the last surfaces that were still English on a localized page ═══════════ + // + // Three gaps, found after the sweep that walked visible text and title/aria-label/ + // placeholder — none of which reaches a element or a mirror. + // + // ── the crawler strip, whose sentence was English around a localized age ────────────── + // The age used to be the only localized part, so a German reader met "crawler live · last + // probe 4m" — one German fragment inside an English line, on the one strip whose whole job + // is to let a reader discount every number above it. The age comes in as an argument + // because the ladder that builds it already localizes, and because a language that puts + // the age first has nowhere to say so if the two are concatenated. + ["crawler.live"] = "crawler live · last probe {age}", + ["crawler.quiet"] = "crawler quiet · last probe {age}", + ["crawler.noProbe"] = "no probe has finished here yet", + + // Three counters, each agreeing with its own number. English inflects none of them and + // several languages inflect all three, which is exactly the case a concatenation cannot be + // translated out of. + ["crawler.cycle.nothingDue"] = "nothing due this cycle", + ["crawler.cycle"] = "{considered, plural, one {# due} other {# due}}" + + " · {answered, plural, one {# answered} other {# answered}}" + + " · {failed, plural, one {# failed} other {# failed}}", + ["crawler.registry"] = "{targets, plural, one {# address in the registry} other {# addresses in the registry}}" + + ", {due, plural, one {# due now} other {# due now}}", + + // ── what this site says about itself where it is not this site ──────────────────────── + // The , the meta description and the Open Graph tags. A German page advertised + // itself in English to a reader, a search engine and every link preview — the three places + // a reader has least ability to check what they were told, and the one surface the demo + // banner cannot follow a link into. + // + // The wordmark is not here. "mu*index" is the site's name, machine voice like a hostname or + // a codebase string, so it arrives as {site} rather than as text a translator could edit. + ["preview.documentTitle"] = "{page} — {site}", + ["preview.site"] = "A directory of the MU* hobby — MUSHes, MUDs, MUCKs, MOOs — where every " + + "fact carries how it was obtained and how old it is.", + ["preview.demo"] = "Demo data — nothing here was measured. {description}", + ["preview.cardAlt"] = "{site} — measured, not asserted", + ["preview.cardAlt.named"] = "{title} on {site}", + + // One id per page, title and description apart: a title is a noun phrase and a description + // is a sentence, and a language that declines the first differently from the second has + // nowhere to stand if they share an id. + ["preview.title.games"] = "Games", + ["preview.title.archive"] = "The archive", + ["preview.title.rankings"] = "Rankings", + ["preview.title.ecosystem"] = "The ecosystem", + ["preview.title.reference"] = "Reference", + ["preview.title.about"] = "About", + ["preview.title.notFound"] = "Not found", + ["preview.title.random"] = "Random game", + ["preview.title.account"] = "Your games", + ["preview.title.claim"] = "Claim {game}", + + ["preview.desc.games"] = "Every MU* we have reached, faceted on what we measured: codebase, " + + "the protocols a server offered in the handshake, TLS, charset, language, and when we " + + "last got in.", + ["preview.desc.archive"] = "The games that went dark, kept. Each keeps its page, history and " + + "URL, is still probed weekly, and returns to the listing on one successful connection.", + ["preview.desc.rankings"] = "Busiest, most reachable, longest running — computed from " + + "measurements only. No votes, stars or ratings anywhere on this site.", + ["preview.desc.ecosystem"] = "Codebase share and protocol adoption across the games we " + + "measure, with what servers offer set beside what they declare. Shares, never totals.", + ["preview.desc.reference"] = "Hand-written pages on the codebases, clients and protocols of " + + "the MU* hobby, cross-linked to counts taken from the crawl.", + ["preview.desc.about"] = "How this catalogue is built: what the crawler does, what it refuses " + + "to do, and how to make it stop.", + ["preview.desc.notFound"] = "No game at this address. Nothing here is ever deleted, so a game " + + "that once lived at this URL still does — check the spelling.", + ["preview.desc.random"] = "One game from the catalogue, chosen at random and never the same " + + "one twice.", + ["preview.desc.account"] = "The listings you have claimed, and what a claim lets you change.", + ["preview.desc.claim"] = "Prove you run this game by publishing a token where only its " + + "operator could put it.", + + // A game's own preview. The name, the host and the port stay machine voice; everything the + // site says *about* them is here. The unknown count is a sentence and never a zero (rule 4), + // and the archived plate says "still probed" because that is §7.5's promise. + ["preview.game.archived"] = "Archived — last reachable {age}, and still probed", + ["preview.game.archived.undated"] = "Archived, and still probed", + ["preview.game.countUnknown"] = "Player count unknown — the game answers, and publishes no " + + "number we can read", + ["preview.game.count"] = "{count, plural, one {# player} other {# players}}, {how} {age}", + + // ── the plain mirror's feed headings and home counts ────────────────────────────────── + // Uppercased at the call site like every other plain heading, so the id carries the words + // and not the casing — a locale whose script has no case gets the words unharmed. + // The empty states are not here: feed.nothingNew and its two siblings already exist and are + // already translated, and the graphical cards and this mirror say the same sentence. + ["feed.plain.newlyDiscovered"] = "Newly discovered", + ["feed.plain.wentDark"] = "Went dark", + ["feed.plain.cameBack"] = "Came back", + + // The four figures the front page tiles carry, as whole sentences rather than a number + // glued to a tile label — the mirror has no tiles to put a label beside. English inflects + // only the first; the other three carry both branches anyway, because a language that + // inflects them has nowhere else to say so. + ["home.plain.known"] = "{count, plural, one {# game known} other {# games known}}", + ["home.plain.connectedNow"] = "{count, plural, one {# connected now (measured)} other {# connected now (measured)}}", + ["home.plain.uncounted"] = "{count, plural, one {# answering, uncounted} other {# answering, uncounted}}", + ["home.plain.archived"] = "{count, plural, one {# archived, still probed} other {# archived, still probed}}", + + // ── THE REFERENCE SECTION'S CHROME ──────────────────────────────────────────────────── + // ═════════════════════════════════════════════════════════════════════════════════════ + // + // Appended as one marked block on purpose: another surface is appending to this file at the + // same time, and a block at the end is a merge that adds rather than one that collides. + // + // **The articles are not here.** The Markdown under content/reference is a set of documents + // somebody owns, translated as documents and by their own route; a bundle holds strings the + // site says, and an article is not one. What is here is the furniture round them — and it + // says nothing about what language an article is in, because that is the content layer's + // fact to state and it is changing. + // + // **Every acronym stays out of the bundle.** MSSP, GMCP, TTYPE, CHARSET and the codebase and + // client names arrive as arguments or sit in the markup. This is the surface most *about* + // machine voice and so the one where a locale is likeliest to reach for a word — and a + // translated TTYPE is destroyed evidence rather than a localized string. + ["reference.title"] = "Reference", + ["reference.lede"] = "What the codebases are, what the clients do, and what the protocols " + + "mean. Written by hand and kept in the repository beside the crawler — not a wiki, and " + + "there is nothing on this page to edit. Every {number} here is a different thing: it " + + "comes from the catalogue and is recomputed each time you load the page.", + + // The emphasised word, placed by the message rather than wrapped round a fragment of it: a + // language that stresses a different word in that clause has somewhere to move the emphasis + // to, and the bundle still holds no markup. Sentences.Place walks it. + ["reference.lede.number"] = "number", + ["reference.plain.lede"] = "Hand-written, single-author, and versioned in git. The prose here " + + "is ours; every number beside it was measured by the crawler and is recomputed on each " + + "request. This is not a wiki, and there is no way to edit it from this page.", + + // The four section headings, and the four kind words beside a page's title. One English word + // does both jobs for three of them — a heading over a list, and a label naming what one page + // is — and inflected languages routinely need different forms, so they are eight ids. + ["reference.section.orientation"] = "Start here", + ["reference.section.codebase"] = "Codebases", + ["reference.section.client"] = "Clients", + ["reference.section.protocol"] = "Protocols", + ["reference.kind.orientation"] = "orientation", + ["reference.kind.codebase"] = "codebase", + ["reference.kind.client"] = "client", + ["reference.kind.protocol"] = "protocol", + + // A gap in a hand-written section is work nobody has done, and the page says so rather than + // implying something was taken away — §7.5 in the one place a reader could reasonably read a + // deletion into a 404. + ["reference.notFound.title"] = "Not found", + ["reference.notFound.body"] = "No reference page here. This section is hand-written, so a gap " + + "is work nobody has done rather than something that was removed — {index}.", + ["reference.notFound.index"] = "see what there is", + + // ── a codebase page's measured half ─────────────────────────────────────────────────── + // The zero is a sentence and never a bare 0 (rule 4): none identified is a statement about + // this crawler's reach, and reads as a statement about the codebase unless it is spelled out. + ["reference.codebase.heading"] = "Games running it", + ["reference.codebase.none"] = "We have not identified any yet. That is a fact about what this " + + "crawler has measured, not about what exists — a game we have not reached, or whose " + + "codebase we could not read, is not counted here.", + ["reference.codebase.listed"] = "{count, plural, one {# listed} other {# listed}}", + ["reference.codebase.archived"] = "{count, plural, one {# archived} other {# archived}}", + + // Rule 1, in three words, and its own id in every register it appears in. It must not soften + // into "verified" or "from our data": what it says is that nothing on this line was taken + // from anybody's self-description. + ["reference.measuredNeverAsserted"] = "measured, never asserted", + ["reference.codebase.note"] = "Counted from the catalogue on this request, over the same " + + "filter the link above carries — so this number and that listing are one query and " + + "cannot drift apart.", + + // The protocol list is machine voice and arrives whole, joined by the markup. A locale + // orders the sentence round it and never touches what is inside. + ["reference.codebase.offered"] = "offered in their handshakes: {protocols}", + + // ── a protocol page's measured half ─────────────────────────────────────────────────── + ["reference.protocol.heading"] = "Measured adoption", + ["reference.protocol.none"] = "Nothing measured yet.", + ["reference.protocol.share"] = "of {listed, plural, one {# listed game} other {# listed games}} — {percent}", + + // **The remainder is not a measurement and the page has to say so.** A locale that shortened + // this to "the rest do not support it" would file our own unread handshakes as a fact about + // somebody's game, which is rule 5 exactly. The sentence names both halves of what the + // remainder mixes, and a translation has to keep them two. + ["reference.protocol.remainder"] = "The games not counted here are not games without the " + + "protocol. A game is counted when we observed its server offering the option in a " + + "handshake; the rest are servers that did not offer it to us and servers whose " + + "handshake we have not read, and we cannot tell you which.", + ["reference.protocol.caption"] = "Games observed offering {protocol} in a handshake, by the " + + "codebase we identified them as running.", + ["reference.protocol.column.codebase"] = "codebase", + ["reference.protocol.column.offered"] = "offered it", + ["reference.protocol.column.identified"] = "identified", + ["reference.seeAlso"] = "See also", + + // ── the client capability matrix, which is documentation rather than measurement ─────── + // Every cell here is somebody's documentation read by us, and the table is a sibling of the + // game pages' measured matrix rather than a copy of it. The caveat carries that difference + // and takes the unknown word as an argument, so the sentence and the cells cannot disagree + // about which word they are quoting. + ["reference.capabilities.heading"] = "Capabilities", + ["reference.capabilities.established"] = "{count, plural, one {# of {total} established from the project's own documentation} other {# of {total} established from the project's own documentation}}", + ["reference.capabilities.caveat"] = "Read off each project's own documentation, not measured " + + "by us — a client has no handshake for us to observe. \"{unknown}\" means we looked and " + + "did not establish it. It never means no.", + ["reference.capabilities.caption"] = "Client capabilities, each read off the project's own " + + "documentation. Unknown means we did not establish it, and never that the client lacks " + + "it.", + ["reference.capabilities.column.documented"] = "documented", + ["reference.capabilities.column.source"] = "source", + ["reference.capabilities.noSource"] = "we did not find one", + + // Three words for three states, and the third is the one that matters. An unknown is what we + // looked for and did not establish; a locale that rendered it as the no beside it would turn + // our reading into the project's absence. They are ids of their own rather than the game + // pages' capability words, which answer a different question — offered on a wire. + ["reference.capability.yes"] = "yes", + ["reference.capability.no"] = "no", + ["reference.capability.unknown"] = "unknown", + + // ── the plain mirror's own wording ──────────────────────────────────────────────────── + // Where the plain surface says the same sentence as the page it mirrors it shares the id + // above; these are the lines it words differently because it has no panel round them. + ["reference.plain.runsOn"] = "Runs on: {platforms}", + ["reference.plain.codebase.heading"] = "Games we have identified as running this codebase", + ["reference.plain.codebase.none"] = "None yet. That is a statement about what we have " + + "measured, not about what exists — a game we have not reached, or whose codebase we " + + "could not read, is not counted here.", + ["reference.plain.codebase.counts"] = "{listed, plural, one {# listed} other {# listed}}, {archived, plural, one {# archived} other {# archived}}", + ["reference.plain.codebase.offered"] = "Measured in their handshakes: {protocols}", + ["reference.plain.codebase.nothingOffered"] = "Nothing was offered in any handshake we have " + + "read from them.", + ["reference.plain.protocol.share"] = "{offering} of {listed, plural, one {# listed game} other {# listed games}} were observed offering it ({percent})", + ["reference.plain.protocol.byCodebase"] = "By codebase, of the games we identified", + ["reference.plain.protocol.row"] = "{offering} of {identified} offered it", + ["reference.plain.capabilities.unknown"] = "{count, plural, one {# of {total} rows is unknown} other {# of {total} rows are unknown}}: we did not find the project's own documentation saying either way. A short honest table beats a long guessed one.", + + // ══ APPENDED BLOCK: the owner dashboard and the claim flow ════════════════════════════ + // The last two page surfaces that were still English whatever language they were asked + // for. Kept as one block at the end of the dictionary so that a parallel append merges + // additively rather than interleaving. + // + // These surfaces address a game's operator in the second person — "your games", "a game + // you run" — and the English says "you" deliberately. A translator should render them in + // whatever second person their language uses for one person being addressed directly; the + // catalogue surfaces are impersonal and these are not. Do not neutralise them. + // + // Nothing here may blur the two provenances. An owner's answer is a DECLARATION stored + // beside what the crawler measured; a claim is a fact about our records and never a + // measurement of the game; an opt-out is honoured and is never a deletion. Where a + // sentence carries that distinction it is called out on the id. + + // ── the dashboard's frame ───────────────────────────────────────────────────────────── + // account.title is its own id rather than preview.title.account's: a heading is the first + // line of a document and a title is a noun phrase in a browser tab, and the languages that + // decline the two differently have nowhere to stand if they share one. + ["account.title"] = "Your games", + ["account.noDatabase"] = "Accounts need a database behind them, and this site is running on " + + "the demo fixture.", + ["account.signInButton"] = "Sign in", + ["account.signedInAs"] = "Signed in as {name}.", + ["account.signOut"] = "Sign out", + + // The empty state's sentence places its own link and its own quoted control, so the word + // order belongs to the language rather than to the markup. {claimControl} names the button + // on a game's page; it is a separate id from game.claim because that one is the control's + // own label and this one is prose quoting it — a language that capitalises or declines a + // quoted control differently has nowhere else to say so. + ["account.empty.body"] = "You have not claimed anything yet. Find your game in {listing} " + + "and press {claimControl} on its page.", + ["account.empty.listing"] = "the listing", + ["account.empty.claimControl"] = "claim this game", + + // ── the one banner a POST comes back with ───────────────────────────────────────────── + // Resigning is not deleting: §7.5 keeps the record, and §8.4 lets the same person prove + // control again. A translation that renders this as "removed" or "deleted" contradicts the + // rule the sentence exists to state. + ["account.resigned.lead"] = "Given up.", + ["account.resigned.body"] = "The record of it is kept, and you can prove control again any " + + "time by publishing a fresh token.", + ["account.saved.lead"] = "Saved.", + + // One sentence per write. The game's name is its own bytes and arrives as an argument, so + // a language that puts the subject elsewhere can move it; {game} is never translated. + // These say what WE did — stopped republishing, stopped dialling, took out of the listing — + // and never that anything about the game was measured or removed. + ["account.saved.thatGame"] = "That game", + ["account.saved.fields"] = "{game}'s page now shows it as owner-declared.", + ["account.saved.screenHidden"] = "We have stopped republishing {game}'s connect screen. The " + + "page says so plainly rather than leaving a hole.", + ["account.saved.screenShown"] = "{game}'s connect screen is on its page again.", + ["account.saved.crawlStopped"] = "We have stopped dialling {game}, on every address we have " + + "for it. Its page keeps everything measured before you asked.", + ["account.saved.crawlResumed"] = "We are dialling {game} again, from its next turn in the " + + "schedule.", + ["account.saved.unlisted"] = "{game} is out of the listing, the rankings and the daily " + + "figure. Its page and every URL it has ever had go on answering.", + ["account.saved.relisted"] = "{game} is back in the listing. One probe that answers is all " + + "it needs to be measured again.", + + // Refused out loud (§8.5). {field} is a registry field name — machine voice, an argument. + // The second sentence is the site's whole claim and may not soften: nobody edits a + // measurement, and that includes us. + ["account.refused.lead"] = "{field} was not changed.", + ["account.refused.tooLong"] = "These are one-line answers; {max} characters is the most we " + + "store.", + ["account.refused.measured"] = "That field is measured. A claim lets you add what MSSP has " + + "no room for; it never lets anybody edit what we observed, and that includes us.", + + // ── a claimed game's block ──────────────────────────────────────────────────────────── + // "verified {date}" is a fact about OUR record — the date we read this owner's token — and + // not a measurement of the game. Two ids rather than a suffix glued on, because the beacon + // note lands in a different place in most languages. + ["account.claimed.heading"] = "Claimed", + ["account.claim.verified"] = "verified {date}", + ["account.claim.verifiedAndSeen"] = "verified {date}, token last seen {seen}", + ["account.claim.mssp"] = "check your MSSP", + ["account.claim.coOwners"] = "{count, plural," + + " one {Also owned by {names} — who verified a token of their own.}" + + " other {Also owned by {names} — each having verified a token of their own.}}", + ["account.coOwner.unnamed"] = "another account", + + // The badge snippet. {unknown} and {archived} are the badge's own bytes rather than words + // to translate: a badge answers one address to everybody, so a German page promising a + // German word would be promising something the image never says. {json} is an acronym and + // machine voice for the same reason every protocol name here is. + ["account.badge.summary"] = "put your player count on your own site", + ["account.badge.carries"] = "The badge carries the count and when we measured it, because a " + + "number with no age is the thing this site exists to replace.", + ["account.badge.states"] = "It says {unknown} rather than nought when we could not count, " + + "and {archived} if the game stops answering.", + ["account.badge.json"] = "There is {json} too, if you would rather draw your own.", + + // ── the audit log ───────────────────────────────────────────────────────────────────── + // The vocabulary is ClaimEventKind's, spelled for a person. beaconMissing reads as an + // observation and never as a warning: a probe not reading the token happens for reasons + // that have nothing to do with the owner, and absence never revokes (§8.4). + ["account.history.summary"] = "history", + ["account.event.issued"] = "token issued", + ["account.event.reissued"] = "token issued again", + ["account.event.verified"] = "verified — we read your token", + ["account.event.beaconSeen"] = "token still published", + ["account.event.beaconMissing"] = "token not read this time", + ["account.event.revoked"] = "claim given up", + ["account.event.expired"] = "token expired unused", + ["account.event.counterClaimed"] = "another account proved control and took the game over", + ["account.event.checkRequested"] = "check requested", + + // Giving up a claim. {word} is the literal an operator types into the box and is never + // translated — a translated confirmation word would be one the form does not accept. + ["account.resign.summary"] = "give up this claim", + ["account.resign.confirm"] = "Type {word} to confirm. Nothing is deleted and you can prove " + + "control again by publishing a fresh token; the game stays claimed if anybody else " + + "owns it.", + ["account.resign.button"] = "Give up {game}", + + // ── waiting on a token, and the passkeys ────────────────────────────────────────────── + ["account.pending.heading"] = "Waiting on a token", + ["account.pending.dates"] = "token issued {issued}, good until {expires}", + ["account.passkeys.heading"] = "Passkeys", + ["account.passkey.unnamed"] = "unnamed", + ["account.passkey.added"] = "added {date}", + ["account.passkey.addedOneDevice"] = "added {date} · on one device only", + ["account.passkey.single"] = "This passkey lives on one device. If you lose it you can " + + "still get back in by publishing a fresh token on your game, but a second passkey is " + + "quicker.", + ["account.passkey.add"] = "Add another passkey", + + // ── claiming a game you run ─────────────────────────────────────────────────────────── + // claim.title is its own id rather than preview.title.claim's, for the reason account.title + // is: a heading and a browser-tab title are not the same noun phrase. + ["claim.noGame"] = "No such game", + ["claim.title"] = "Claim {game}", + ["claim.noDatabase"] = "Claiming needs a database behind it, and this site is running on " + + "the demo fixture.", + ["claim.needAccount"] = "You need an account first. It takes a passkey and a name.", + ["claim.signIn"] = "Sign in or create an account", + ["claim.yourGames"] = "Your games", + + // The game already has owners, and nothing in a probe can tell joining from taking over — + // both publish the identical line. So the choice is made here, in words, before the token + // exists. The owner count agrees inside the message rather than being chosen in C#. + ["claim.hasOwners"] = "{count, plural," + + " one {This game already has an owner who proved control of the server.}" + + " other {This game already has # owners who proved control of the server.}}" + + " You can prove it too — the test is the same either way — but we need to know what " + + "you mean by it, because we cannot tell from the token.", + ["claim.join.button"] = "I run it too — add me as an owner", + ["claim.join.note"] = "Everyone keeps their claim. This is two people running one game.", + ["claim.assume.button"] = "I have taken it over — transfer it to me", + ["claim.assume.note"] = "{count, plural," + + " one {When your token verifies, the existing claim is revoked and the game is yours.}" + + " other {When your token verifies, the existing claims are revoked and the game is yours.}}" + + " They will see why in their own history. Nothing is deleted, and they can prove " + + "control again the same way you are about to.", + + // Verified. Two ids and not one with the channel slotted in: "from the game's MSSP report" + // and "from the connect screen" take different prepositions and different cases in the + // languages that have them, and a single sentence with a {channel} hole has nowhere to + // say so. Both state what WE read, which is a fact about our records. + ["claim.verified.lead"] = "Verified.", + ["claim.verified.viaMssp"] = "We read your token from the game's MSSP report on {date}.", + ["claim.verified.viaScreen"] = "We read your token from the connect screen on {date}.", + ["claim.verified.leaveIt"] = "Leave the token where it is. It doubles as an identity " + + "signal, so this game stays recognisable if it moves host or changes name. Removing " + + "it will not un-claim you.", + + // Publishing the token. Every variable name, file name and prefix below is machine voice + // and arrives as an argument: a translated MSSP variable is one no crawler reads. + ["claim.publish"] = "Publish this token anywhere the game shows it to an anonymous " + + "connection. The next probe picks it up, which proves you can write to that server.", + ["claim.transfer.lead"] = "This is a transfer.", + ["claim.transfer.body"] = "{count, plural," + + " one {When we read this token, the current owner's claim on this game is revoked and it becomes yours.}" + + " other {When we read this token, the current owners' claims on this game are revoked and it becomes yours.}}", + ["claim.either.heading"] = "Either of these will do", + ["claim.mssp.heading"] = "An MSSP variable", + ["claim.mssp.note"] = "In {codebase} that is a line in {file}; every codebase with MSSP has " + + "an equivalent.", + ["claim.mssp.aliases"] = "{aliases} are accepted too.", + ["claim.screen.heading"] = "A line on the connect screen", + ["claim.screen.note"] = "Anywhere in the screen, and colour codes around it are fine.", + ["claim.then.heading"] = "Then", + ["claim.then.body"] = "We check on the ordinary crawl schedule. This token is good until " + + "{date}. Come back any time; nothing needs writing down.", + + // Asking us to look sooner moves the game to the front of the queue; the crawler still + // does the dialling on its own schedule and under CRAWL DELAY. Neither sentence may + // promise a probe, because pressing a button here is our decision and not a measurement. + ["claim.check.button"] = "Look sooner", + ["claim.check.can"] = "Brings your game to the front of the queue. We dial on our own " + + "schedule, so this is sooner rather than now.", + ["claim.check.rationed"] = "Just asked. Try again in a few minutes — it is rationed because " + + "it dials a real server sooner than we would have.", + + // ── what a claim actually grants (the owner panel, §8.5 and §11) ────────────────────── + // Everything on this panel is enrichment. An owner's answer is DECLARED: stored under its + // own field source beside whatever the crawler measured, shown with its age, and it never + // replaces, hides or silences a measurement. A translation that lets "you told us" read as + // "we measured" breaks the one rule the whole site rests on. + ["owner.declare.heading"] = "What only you can tell us about {game}", + ["owner.declare.lede"] = "These are the things MSSP has no field for. They appear on your " + + "game's page as {declared}, with the date you last confirmed them, beside what we " + + "measured — never instead of it. Nothing measured can be edited from here, by you or " + + "by us.", + ["owner.field.declared"] = "declared {age}. Empty this box to withdraw it — the record of " + + "what it said is kept either way.", + ["owner.save"] = "Save what you declared", + + ["owner.override.heading"] = "What {game} reports, and what you would rather we showed", + ["owner.override.lede"] = "Your MSSP is what every crawler reads, and we go on showing it " + + "beside anything you put here — an answer of yours does not hide one of your game's.", + ["owner.override.nothingMeasured"] = "Nothing measured can be edited from here: not a " + + "player count, not a capability, not an hour of reachability.", + ["owner.override.fixItThere"] = "If a line below is wrong in your {file}, fixing it there " + + "fixes it everywhere.", + ["owner.report.value"] = "your game reports {value}, confirmed {age}", + ["owner.report.none"] = "your game reports nothing here", + ["owner.rename.note"] = "Changing the name changes what {game} is listed as and the " + + "address of its page. The old address goes on working for ever — every URL this game " + + "has ever had redirects to its current one — and clearing the box hands the name back " + + "to whatever your MSSP says.", + + ["owner.screen.heading"] = "Your connect screen", + ["owner.screen.suppressed"] = "We are not republishing it. The page says so plainly rather " + + "than leaving a hole, and the crawler goes on reading it — it is how we recognise " + + "your game if it moves.", + ["owner.screen.show"] = "Show it again", + ["owner.screen.shown"] = "We show it because your server sends it to every anonymous " + + "connection. If you would rather we did not, say so and we stop. We will not ask why.", + ["owner.screen.stop"] = "Stop showing our connect screen", + + // §11. An opt-out is honoured, never a deletion — and the empty hours it leaves may not be + // given a cause, because "you asked us to stop" is OUR fact and not a measurement of the + // game (rule 5, rule 2). {ourFact} is placed by the message so a language can put the + // emphasised clause where it belongs. + ["owner.crawl.heading"] = "Being crawled", + ["owner.crawl.stopped"] = "We have stopped. Nothing on {game} is dialled, and the page " + + "keeps everything measured before you asked — the empty hours name no cause, because " + + "{ourFact} is our fact and not a measurement of your game.", + ["owner.crawl.ourFact"] = "you asked us to stop", + ["owner.crawl.resume"] = "Start crawling us again", + ["owner.crawl.standing"] = "This one came from your own server rather than from here — " + + "{routes}. To be crawled again, stop publishing it; we will hear that on the next " + + "cycle.", + ["owner.crawl.route.mssp"] = "your MSSP report publishes {variable}", + ["owner.crawl.route.dns"] = "a {label} TXT record asks us to stop", + ["owner.crawl.route.recorded"] = "a request we recorded", + // Neither state, and shown as neither: rounding this to "stopped" would tell an owner we + // had left them alone while we went on dialling the port that is still open. + ["owner.crawl.partial"] = "We have stopped on {stopped} and are still dialling {dialling}. " + + "That is usually a port added after the opt-out.", + ["owner.crawl.stopAll"] = "Stop on every address too", + ["owner.crawl.dialling"] = "We dial {game} on a schedule and read what any anonymous " + + "connection is shown. If you would rather we did not, say so and we stop — within " + + "one cycle, on every address we have for you, and we will not ask why.", + ["owner.crawl.selfService"] = "Nothing already measured is deleted: your page keeps its " + + "history and its URL, and one probe after you take this back starts it again. You " + + "can also say it without us, in your own config — {mssp} in MSSP, or a {dns} TXT " + + "record — and we honour those whether or not anybody has ever claimed the game here.", + ["owner.crawl.stop"] = "Stop crawling us", + + // Migration 0025's second decision, and a second one rather than a stronger version of the + // first. Nothing is deleted here either: the page answers, every URL it ever had still + // redirects to it, and it stops being somewhere a reader arrives by browsing. + ["owner.listing.heading"] = "Being listed", + ["owner.listing.unlisted"] = "{game} is out of the listing, out of the rankings and out of " + + "the daily figure. Its page and every URL it has ever had go on answering, and " + + "everything measured before you asked is still on it. Nothing was deleted; it is " + + "simply not somewhere a reader arrives by browsing.", + ["owner.listing.relist"] = "Put us back in the listing", + ["owner.listing.probeRelists"] = "One probe that answers does this too. While your opt-out " + + "stands we do not dial, so nothing will — but the day you take it back, the address " + + "comes up within a week and the probe that gets an answer puts you back. You do not " + + "have to ask us twice.", + ["owner.listing.mayUnlist"] = "We have stopped dialling you, and your page is still in the " + + "listing with what we measured before that. If you would rather it were not, say so " + + "and it comes out — of the listing, the rankings and the daily figure.", + ["owner.listing.reversible"] = "Nothing is deleted and nothing breaks: the page answers, " + + "every URL it has ever had still redirects to it, and anyone you send there sees it. " + + "It stops being somewhere a reader can arrive by browsing. Reversible from here, and " + + "by any probe that answers after you take your opt-out back.", + ["owner.listing.unlist"] = "Take us out of the listing too", + // ══ END APPENDED BLOCK ════════════════════════════════════════════════════════════════ + + // ══ WHAT WE COULD MEASURE ═════════════════════════════════════════════════════════════ + // The handoff's last panel group: two independent switches over the two reasons a listing + // row carries no number. Every string here describes *our* reach and none of them describes + // a game, which is rule 5 — the note exists because the gesture these controls offer is the + // one most likely to be read as a claim about the games it removes. + ["facet.group.measure"] = "what we could measure", + + // The design's own sentence, with its first clause made true of the control that shipped: + // the panel's rows are tri-state, so hiding is the "−" rather than an untick. The second + // clause is the load-bearing one and is carried verbatim. + ["facet.measure.note"] = "Hiding these takes them out of your listing; it does not mean the " + + "game is empty.", + + // Named for what we did and never for what the game is. "could not count" is a fact about + // our parsers meeting a dialect; "no players" would be that same fact filed in somebody + // else's public record. + ["facet.group.uncounted"] = "could not count", + ["facet.group.unreachable"] = "could not reach", + + // What the chip says when a reader has dropped one of the two. Deliberately not "not + // uncounted": a double negative reads as an assertion about the games, and this is an + // assertion about the listing. Two ids carrying one English sentence, because they are two + // different facts and a language that inflects will not spell them the same way. + ["facet.excluded.uncounted"] = "hidden from this listing", + ["facet.excluded.unreachable"] = "hidden from this listing", + + // The plain surface's own key to its left column. It drew "only these" and "anything but + // these" with one star until these two switches arrived, whose ordinary gesture is the + // second — so the surface with the least else to go on was the one that could not show the + // third state at all. + ["facet.plain.marks"] = "In the left column, * is a value this listing is filtered to and - " + + "is one it is filtered against. Both are choices in the query, not facts about a game.", + // ══ END WHAT WE COULD MEASURE ═════════════════════════════════════════════════════════ + }; + + /// + /// The bundles, by tag. + /// + /// + /// + /// Only English is complete, and that is the honest state of this work rather than a gap in + /// it. The order of work is explicit: the glossary is written and human-translated before a + /// reader is sent anywhere, and no locale is offered until it is. What ships here is the + /// machinery, exercised end to end by the two test-only bundles below. + /// + /// + /// qps-ploc is a pseudolocale — accented and expanded English. It is not a language and + /// nobody claims it is one; it exists so routing, fallback, plural selection and the nav's 1.4x + /// width budget are all exercised by something real. ru-x-canary is machine-translated, + /// never shipped, and deliberately incomplete: it is what makes a missing plural form fail a + /// build instead of reaching a reader. + /// + /// + private static readonly Dictionary> TestBundles = + new(StringComparer.OrdinalIgnoreCase) + { + // Every message, mechanically transformed. Generated rather than typed so it cannot + // fall behind the source bundle it is derived from. + ["qps-ploc"] = English.ToDictionary(e => e.Key, e => Pseudo(e.Value), StringComparer.Ordinal), + + // Three plural categories, and one message that is missing its `few` and `many` branches + // on purpose. That omission is what the completeness test turns into a build failure. + ["ru-x-canary"] = new(StringComparer.Ordinal) + { + ["facet.count"] = "{count, plural, one {# игра} few {# игры} many {# игр} other {# игры}}", + ["listing.total"] = "{count, plural, one {# игра} other {# игр}}, каждый факт измерен.", + ["provenance.count.measured"] = "измерена", + ["provenance.game.measured"] = "измерено", + ["provenance.capability.measured"] = "измерены", + ["kicker.measured"] = "ИЗМЕРЕНО", + }, + }; + + /// + /// One message, rendered for a locale — or the English, where that locale has no approved one. + /// + /// + /// The fallback is silent to the reader and loud to the build. A reader meeting one English + /// phrase inside a German sentence learns something true: this particular claim has not been + /// translated yet. What must never happen is the other thing — a smoothed-over approximation of + /// a locked string, which teaches them something false and gives them no way to tell. + /// + public static string For(string tag, string id, IReadOnlyDictionary? args = null) + { + ArgumentNullException.ThrowIfNull(tag); + ArgumentNullException.ThrowIfNull(id); + + var pattern = Pattern(tag, id) + ?? throw new KeyNotFoundException($"No message '{id}' in any bundle, including English."); + + return IcuMessage.Format(pattern, tag, args); + } + + /// + /// The same, with the arguments named inline rather than built into a dictionary first. + /// + /// + /// One helper, because there were eleven. Every component that renders more than one + /// message had grown its own private wrapper turning a tuple array into an ordinal dictionary + /// and calling — the same six lines, copied, and twice inside one file. + /// StringComparer.Ordinal is the part that mattered and the part a twelfth copy would + /// eventually get wrong: an argument name is a token in a pattern, matched exactly, and a + /// dictionary that folded case would answer a lookup the parser never asked for. + /// + public static string Say(string tag, string id, params (string Key, object? Value)[] args) + { + ArgumentNullException.ThrowIfNull(args); + + return For(tag, id, args.ToDictionary(a => a.Key, a => a.Value, StringComparer.Ordinal)); + } + + /// A count and its noun, agreeing — the commonest call by a long way. + public static string Count(string tag, int count) => + For(tag, "facet.count", new Dictionary { ["count"] = count }); + + /// The raw pattern a locale would use, English included, or null. + public static string? Pattern(string tag, string id) + { + ArgumentNullException.ThrowIfNull(tag); + ArgumentNullException.ThrowIfNull(id); + + return Own(tag, id) ?? English.GetValueOrDefault(id); + } + + /// Whether a locale carries its own text for an id, rather than falling back. + public static bool HasOwn(string tag, string id) => Own(tag, id) is not null; + + /// + /// What a locale itself says for an id — from its resx, or from a test bundle — or null. + /// + /// + /// ResourceNotFound is the load-bearing check. answers a + /// missing key with the key itself rather than with null, so a lookup that trusted the string it + /// got back would render facet.count to a reader and call it a translation. + /// + private static string? Own(string tag, string id) + { + if (TestBundles.TryGetValue(tag, out var bundle)) + { + return bundle.GetValueOrDefault(id); + } + + // The source language reads its own compiled-in copy: it is the fallback for every other + // locale, and a fallback that depends on a satellite assembly having loaded is not one. + if (string.Equals(tag, Locales.SourceTag, StringComparison.OrdinalIgnoreCase)) + { + return English.GetValueOrDefault(id); + } + + if (Culture(tag) is not { } culture) + { + return null; + } + + // The resource set for this culture *alone*, with tryParents off — which is the whole point. + // GetString walks up to the neutral resources, so it answers the English for a locale that + // has translated nothing, and a caller asking "does this locale carry its own words for this + // id" would be told yes for every id in the site. The fallback is deliberate elsewhere and + // wrong here. + // + // A ResourceManager rather than IStringLocalizer, and that is not a rejection of the pattern + // — IStringLocalizer *is* a ResourceManager with the culture read off the ambient thread. + // This lookup is static and is called from Razor markup, from the plain-text renderer and + // from headless component tests alike, and it is handed the locale rather than inferring + // one; the DI wrapper would mean it could not answer at all without a host behind it, which + // is most of where it is called from. AddMuiLocalization still registers the injected form + // for anything that wants it. + try + { + return Resources.GetResourceSet(culture, createIfNotExists: true, tryParents: false) + ?.GetString(id); + } + catch (MissingManifestResourceException) + { + return null; + } + } + + /// + /// The satellite assemblies, keyed off the marker type so the base name cannot drift. + /// + /// + /// One per culture, compiled by the SDK from Resources/Messages.<culture>.resx with + /// no <EmbeddedResource> entries in the project file. A culture with no satellite + /// answers null here rather than throwing, which is the fallback path. + /// + private static readonly ResourceManager Resources = new(typeof(Web.Resources.Messages)); + + private static CultureInfo? Culture(string tag) + { + try + { + return CultureInfo.GetCultureInfo(tag); + } + catch (CultureNotFoundException) + { + return null; + } + } + + /// The source text for an id, which is what a resx has to agree with. + public static string? Source(string id) => English.GetValueOrDefault(id); + + + + /// Every id the site says, in the order the source bundle declares them. + public static IReadOnlyList Ids { get; } = [.. English.Keys]; + + /// The ids a locale has not translated yet. + /// + /// The release checklist's own question, answerable from code rather than from a spreadsheet. + /// A locale may not be moved to while any locked id + /// is in this list — which is the rule the completeness test enforces. + /// + public static IReadOnlyList MissingFor(string tag) => + [.. Ids.Where(id => !HasOwn(tag, id))]; + + /// + /// Accented, expanded English — a language nobody speaks, which is the point. + /// + /// + /// Two jobs. The accents prove a string came through the pipeline rather than being hard-coded + /// in a template, and the padding gives every string the 1.4x width the handoff says to review + /// a locale at — German and Russian run 30–40% longer on short UI nouns, and the nav bar was + /// tightened to fit English exactly. The ICU syntax is stepped over rather than transformed: + /// mangling a branch keyword would make the message unparseable and prove nothing. + /// + private static string Pseudo(string pattern) + { + var b = new System.Text.StringBuilder(pattern.Length * 2); + var depth = 0; + + foreach (var c in pattern) + { + if (c == '{') { depth++; b.Append(c); continue; } + if (c == '}') { depth--; b.Append(c); continue; } + + // Inside braces the text is syntax — argument names and branch keywords — and accenting + // it would make the message unparseable, which proves nothing. + b.Append(depth > 0 ? c : Accent(c)); + } + + // The padding goes outside the braces so no argument name is touched. + return "⟦" + b + "⟧"; + } + + private static char Accent(char c) => c switch + { + 'a' => 'á', 'e' => 'é', 'i' => 'í', 'o' => 'ó', 'u' => 'ú', 'n' => 'ñ', 'c' => 'ç', + 'A' => 'Á', 'E' => 'É', 'I' => 'Í', 'O' => 'Ó', 'U' => 'Ú', 'N' => 'Ñ', 'C' => 'Ç', + _ => c, + }; +} diff --git a/src/MUI.Web/Localization/PluralOperands.cs b/src/MUI.Web/Localization/PluralOperands.cs new file mode 100644 index 0000000..ebaf00c --- /dev/null +++ b/src/MUI.Web/Localization/PluralOperands.cs @@ -0,0 +1,106 @@ +using System.Globalization; + +namespace MUI.Web.Localization; + +/// +/// The six values a CLDR plural rule is written in terms of. +/// +/// The absolute value of the number. +/// Its integer digits. +/// How many fraction digits are visible, trailing zeros included. +/// How many are visible with trailing zeros removed. +/// The visible fraction digits as an integer, trailing zeros included. +/// The same with trailing zeros removed. +/// The compact-decimal exponent, which is zero for everything this site formats. +/// +/// +/// Visible is the load-bearing word, and it is why this type exists rather than an int. +/// In English 1 is one and 1.0 is other — "1.0 stars" is correct and +/// "1.0 star" is not — and the two are the same quantity. A rule cannot tell them apart from the +/// value alone; it needs to know how the number was written. That is what v and +/// f carry, and it is the single most commonly missed thing in a hand-rolled plural +/// implementation. +/// +/// +/// Every count this site pluralises is an integer, so v is zero throughout and none of this +/// changes an answer today. It is here because a plural implementation that only works for integers +/// is one that silently gives the wrong form the first time somebody formats a rate or an average, +/// and because the rules below are transcribed from CLDR in the operands CLDR states them in — a +/// transcription into a different vocabulary is a transcription that cannot be checked. +/// +/// +/// These are absolute values and this type cannot print a number. CLDR takes the absolute +/// value to choose a category, never to display one, so a Format here would drop the sign of +/// every number it was handed — and it did, along with the group separator, which put "1234 games" +/// in a sentence beside "1,234" in the column. Rendering belongs to , which +/// still holds the signed value the caller passed. +/// +/// +public readonly record struct PluralOperands( + decimal N, + long I, + int V, + int W, + long F, + long T, + int E) +{ + /// The operands of an integer, where every fractional one is zero. + public static PluralOperands Of(long value) + { + var n = Math.Abs(value); + + return new PluralOperands(n, n, 0, 0, 0, 0, 0); + } + + /// + /// The operands of a number as it will actually be written. + /// + /// The quantity. + /// + /// How many fraction digits the rendered string shows, or null to read them off the value. + /// + /// + /// The parameter is what makes 1 and 1.0 different: a caller formatting to two + /// decimal places has to say so, because by the time the number reaches here as a + /// the trailing zeros it will be printed with are a fact about the format + /// string and not about the value. + /// + public static PluralOperands Of(decimal value, int? visibleFractionDigits = null) + { + var n = Math.Abs(value); + var i = (long)decimal.Truncate(n); + + // decimal keeps its own scale, so 1.50m already knows it has two fraction digits. That is + // the right default: a caller who did not say otherwise gets the digits the value carries. + var scale = (byte)((decimal.GetBits(n)[3] >> 16) & 0x7f); + var v = visibleFractionDigits ?? scale; + + var fractional = n - i; + var f = v == 0 ? 0L : (long)decimal.Round(fractional * Pow10(v), 0, MidpointRounding.AwayFromZero); + + // w and t are v and f with trailing zeros taken off: 1.50 has v=2, f=50, w=1, t=5. + var w = v; + var t = f; + + while (w > 0 && t % 10 == 0) + { + t /= 10; + w--; + } + + return new PluralOperands(n, i, v, w, f, t, E: 0); + } + + private static decimal Pow10(int power) + { + var result = 1m; + + for (var at = 0; at < power; at++) + { + result *= 10m; + } + + return result; + } +} diff --git a/src/MUI.Web/Localization/PluralRules.cs b/src/MUI.Web/Localization/PluralRules.cs new file mode 100644 index 0000000..be62fe8 --- /dev/null +++ b/src/MUI.Web/Localization/PluralRules.cs @@ -0,0 +1,402 @@ +namespace MUI.Web.Localization; + +/// The CLDR plural categories. +/// +/// Six of them exist across all languages; no single language uses more than five, and most use one +/// or two. is the one every language has and the only one a message must +/// declare. +/// +public enum PluralCategory +{ + Zero, + One, + Two, + Few, + Many, + Other, +} + +/// Cardinal counts one thing; ordinal ranks it. +/// +/// They are different rule sets and getting them from one table is a bug rather than a shortcut. In +/// English the cardinal rule has two forms — 1 game, 2 games — and the ordinal rule has +/// four — 1st, 2nd, 3rd, 4th — and neither can produce the other's answers. +/// +public enum PluralKind +{ + Cardinal, + Ordinal, +} + +/// +/// Which plural form a number takes, per locale, per kind. +/// +/// +/// +/// Transcribed from CLDR 46's plural charts, in the operands CLDR states them in. Each rule +/// below is written the way the chart writes it — i = 1 and v = 0 rather than +/// count == 1 — so it can be checked against the source line by line. A transcription into a +/// different vocabulary is a transcription nobody can verify. +/// +/// +/// Hand-written rather than taken from a library, and that is a judgement rather than a +/// preference. The .NET options are thin: ICU4N is an alpha pinned to ICU 60, whose CLDR data +/// predates several of the rules below, and the MessageFormat ports are 0.1.x forks of an abandoned +/// project. Neither is a dependency worth the credibility of a site whose entire product is being +/// right about what it knows. The cost is that this table has to be maintained against CLDR when a +/// locale is added, which is why exists and is asserted against the +/// locales the site actually commits to. +/// +/// +/// An unlisted language answers . That is correct for a +/// language with one form and safe for one whose rule is not written here — the message still +/// renders, in the form every language has. What stops that being a silent wrong answer is the test +/// that walks every offered locale and refuses one this table does not cover. +/// +/// +public static class PluralRules +{ + /// The CLDR release these rules were transcribed from. + public const string CldrVersion = "46"; + + /// + /// The languages this table states a rule for, cardinal or ordinal. + /// + /// + /// The gate on adding a locale: a tag whose language is not here falls back to other for + /// every count, which is right for Chinese and wrong for German. Asserted in the tests against + /// . + /// + public static IReadOnlyList LocalesCovered { get; } = + [ + "en", "de", "nl", "sv", "da", "no", "fi", "et", "el", "it", "es", "fr", "pt", + "ru", "uk", "be", "pl", "cs", "sk", "hi", "th", "vi", "id", "ms", "ja", "ko", "zh", + "tr", "he", "ar", "qps", + ]; + + /// The category takes. + public static PluralCategory Of(string tag, long count, PluralKind kind = PluralKind.Cardinal) => + Of(tag, PluralOperands.Of(count), kind); + + /// The category a number written a particular way takes. + public static PluralCategory Of(string tag, PluralOperands o, PluralKind kind = PluralKind.Cardinal) + { + ArgumentNullException.ThrowIfNull(tag); + + var language = Language(tag); + + return kind is PluralKind.Ordinal ? Ordinal(language, o) : Cardinal(language, o); + } + + private static PluralCategory Cardinal(string language, PluralOperands o) => language switch + { + // one: i = 1 and v = 0 + // + // The v = 0 is why this file carries operands at all: "1.0 stars" is other, not one, and + // the two quantities are equal. `qps` is the pseudolocale and is English underneath, so it + // has to select exactly what English selects or it exercises the wrong branch. + "en" or "de" or "nl" or "sv" or "fi" or "et" or "qps" => + o is { I: 1, V: 0 } ? PluralCategory.One : PluralCategory.Other, + + // one: n = 1 + // + // Not the line above, and 1.0 is the whole of the difference. Greek, Norwegian, + // Spanish and Turkish say `one` for it where English says `other`. All four had been given + // English's rule, because on the integers the two agree — and the integers are all anybody + // checks. Turkish had `n = 0..1` besides, which is a real CLDR rule belonging to Akan and + // Punjabi and puts zero in the form Turkish keeps for exactly one thing. + "el" or "no" or "tr" => o.N == 1m ? PluralCategory.One : PluralCategory.Other, + + // one: n = 1 or t != 0 and i = 0,1 + // + // Danish alone, and the only `one` in this table that reaches a quantity which is not 1: + // "0,5 stjerne" rather than "0,5 stjerner". Copied from Swedish, it loses that clause + // silently — the integers, again, agree. + "da" => o.N == 1m || (o.T != 0 && o.I is 0 or 1) + ? PluralCategory.One + : PluralCategory.Other, + + // one: i = 1 and v = 0 (it) + // n = 1 (es) + // i = 0,1 (fr, pt) + // many: e = 0 and i != 0 and i % 1000000 = 0 and v = 0 (all four) + // + // The Romance millions rule — "un millón de juegos", with the preposition the other forms + // do not take. fr and pt carried it; it and es were folded into English's rule above and so + // had no `many` at all, which is a form a translator would have been asked to write and + // never given anywhere to put. + "it" => Millions(o) ? PluralCategory.Many + : o is { I: 1, V: 0 } ? PluralCategory.One + : PluralCategory.Other, + + "es" => Millions(o) ? PluralCategory.Many + : o.N == 1m ? PluralCategory.One + : PluralCategory.Other, + + "fr" or "pt" => Millions(o) ? PluralCategory.Many + : o.I is 0 or 1 ? PluralCategory.One + : PluralCategory.Other, + + // one: v = 0 and i % 10 = 1 and i % 100 != 11 + // few: v = 0 and i % 10 = 2..4 and i % 100 != 12..14 + // many: v = 0 and (i % 10 = 0 or i % 10 = 5..9 or i % 100 = 11..14) + // other: everything with a visible fraction + // + // 11 and 12 end in 1 and 2 and take neither `one` nor `few`. A rule written from the first + // three examples anybody tries is wrong for both. + "ru" or "uk" => o.V != 0 ? PluralCategory.Other + : (o.I % 10, o.I % 100) switch + { + (1, not 11) => PluralCategory.One, + (2 or 3 or 4, not (12 or 13 or 14)) => PluralCategory.Few, + _ => PluralCategory.Many, + }, + + // one: n % 10 = 1 and n % 100 != 11 + // few: n % 10 = 2..4 and n % 100 != 12..14 + // many: n % 10 = 0 or n % 10 = 5..9 or n % 100 = 11..14 + // + // Belarusian states Russian's shape on `n` rather than on `i` with `v = 0`, so 1.0 is + // `one` here and `other` there. It had been folded in with Russian above: right for every + // integer, wrong for every number written with a decimal place. + "be" => !Whole(o) ? PluralCategory.Other + : (o.I % 10, o.I % 100) switch + { + (1, not 11) => PluralCategory.One, + (2 or 3 or 4, not (12 or 13 or 14)) => PluralCategory.Few, + _ => PluralCategory.Many, + }, + + // one: i = 1 and v = 0 + // few: v = 0 and i % 10 = 2..4 and i % 100 != 12..14 + // many: v = 0 and i != 1 and (i % 10 = 0..1 or i % 10 = 5..9 or i % 100 = 12..14) + "pl" => o.V != 0 ? PluralCategory.Other + : o.I == 1 ? PluralCategory.One + : (o.I % 10, o.I % 100) switch + { + (2 or 3 or 4, not (12 or 13 or 14)) => PluralCategory.Few, + _ => PluralCategory.Many, + }, + + // one: i = 1 and v = 0 + // few: i = 2..4 and v = 0 + // many: v != 0 + "cs" or "sk" => o.V != 0 ? PluralCategory.Many + : o.I switch + { + 1 => PluralCategory.One, + 2 or 3 or 4 => PluralCategory.Few, + _ => PluralCategory.Other, + }, + + // one: i = 0 or n = 1 + "hi" => o.I == 0 || o.N == 1m ? PluralCategory.One : PluralCategory.Other, + + // one: i = 1 and v = 0 or i = 0 and v != 0 + // two: i = 2 and v = 0 + // + // The `many` this rule used to carry for multiples of ten was withdrawn from CLDR before + // 46, and so was every one of Hebrew's ordinals. A table still stating them selects a + // branch nobody was ever asked to translate — which the `other` fallback cannot catch, + // because the branch is present and simply wrong. + "he" => o switch + { + { I: 1, V: 0 } or { I: 0, V: not 0 } => PluralCategory.One, + { I: 2, V: 0 } => PluralCategory.Two, + _ => PluralCategory.Other, + }, + + // zero: n = 0 one: n = 1 two: n = 2 + // few: n % 100 = 3..10 many: n % 100 = 11..99 + // + // Six categories, which is the count the review cites — and Arabic is render-only here, so + // this rule exists for correctness of the table rather than for a locale that ships. + "ar" => o.N switch + { + 0 => PluralCategory.Zero, + 1 => PluralCategory.One, + 2 => PluralCategory.Two, + _ when !Whole(o) => PluralCategory.Other, + _ => (o.I % 100) switch + { + >= 3 and <= 10 => PluralCategory.Few, + >= 11 and <= 99 => PluralCategory.Many, + _ => PluralCategory.Other, + }, + }, + + // No plural inflection at all. This is exactly why Chinese cannot be the locale a string + // architecture is validated against: it agrees with any shape, including a wrong one. + "zh" or "ja" or "ko" or "th" or "vi" or "id" or "ms" => PluralCategory.Other, + + _ => PluralCategory.Other, + }; + + private static PluralCategory Ordinal(string language, PluralOperands o) => language switch + { + // one: n % 10 = 1 and n % 100 != 11 (1st, 21st, but 11th) + // two: n % 10 = 2 and n % 100 != 12 (2nd, 22nd, but 12th) + // few: n % 10 = 3 and n % 100 != 13 (3rd, 23rd, but 13th) + "en" or "qps" => !Whole(o) ? PluralCategory.Other + : (o.I % 10, o.I % 100) switch + { + (1, not 11) => PluralCategory.One, + (2, not 12) => PluralCategory.Two, + (3, not 13) => PluralCategory.Few, + _ => PluralCategory.Other, + }, + + // one: n % 10 = 1,2 and n % 100 != 11,12 (1:a and 2:a, then 3:e — and 11:e, 12:e) + "sv" => !Whole(o) ? PluralCategory.Other + : (o.I % 10, o.I % 100) switch + { + (1 or 2, not (11 or 12)) => PluralCategory.One, + _ => PluralCategory.Other, + }, + + // one: n = 1 (1er / 1re, then 2e, 3e …) + "fr" => o.N == 1m ? PluralCategory.One : PluralCategory.Other, + + // few: n % 10 = 3 and n % 100 != 13 + "uk" => !Whole(o) ? PluralCategory.Other + : (o.I % 10, o.I % 100) switch + { + (3, not 13) => PluralCategory.Few, + _ => PluralCategory.Other, + }, + + // few: n % 10 = 2,3 and n % 100 != 12,13 + "be" => !Whole(o) ? PluralCategory.Other + : (o.I % 10, o.I % 100) switch + { + (2 or 3, not (12 or 13)) => PluralCategory.Few, + _ => PluralCategory.Other, + }, + + // many: n = 11,8,80,800 + "it" => Whole(o) && o.I is 11 or 8 or 80 or 800 ? PluralCategory.Many : PluralCategory.Other, + + // one: n = 1 two: n = 2,3 few: n = 4 many: n = 6 + "hi" => !Whole(o) ? PluralCategory.Other + : o.I switch + { + 1 => PluralCategory.One, + 2 or 3 => PluralCategory.Two, + 4 => PluralCategory.Few, + 6 => PluralCategory.Many, + _ => PluralCategory.Other, + }, + + // one: n = 1 + "vi" or "ms" => o.N == 1m ? PluralCategory.One : PluralCategory.Other, + + // Every other language this site knows about has one ordinal form: German, Spanish, Danish, + // Norwegian, Greek, Dutch, Finnish, Estonian, Polish, Czech, Slovak, Russian, Portuguese, + // Thai, Indonesian, Chinese, Japanese, Korean, Turkish, Arabic — and Hebrew, whose six-way + // ordinal table CLDR withdrew before 46. + _ => PluralCategory.Other, + }; + + /// The Romance millions rule, which CLDR states identically for all four languages. + /// + /// many: e = 0 and i != 0 and i % 1000000 = 0 and v = 0. e is the compact-decimal + /// exponent and is zero for everything this site formats, so the clause CLDR adds for compact + /// notation cannot be reached from here. + /// + private static bool Millions(PluralOperands o) => + o is { E: 0, V: 0, I: not 0 } && o.I % 1_000_000 == 0; + + /// Whether the number is a whole one, which is all a CLDR range can ever match. + /// + /// n % 100 = 3..10 is a range over integers: 3.5 is not in it, however its integer part + /// reads. Testing i in its place gives every fraction the category of the whole number + /// below it — the wrong form for a rate and for an average alike, and invisible in a table + /// exercised only with counts. + /// + private static bool Whole(PluralOperands o) => o.N == o.I; + + /// + /// Every category a locale can produce, which is what a message has to cover. + /// + /// + /// The assertion the Russian canary is for. A message declaring only one and other + /// is complete in English and silently wrong in Russian, where a count of two takes a form + /// neither branch supplies — and a wrong plural does not read as a typo to a native speaker, it + /// reads as illiterate. + /// + public static IReadOnlyList CategoriesOf( + string tag, PluralKind kind = PluralKind.Cardinal) + { + ArgumentNullException.ThrowIfNull(tag); + + // Derived by exercising the rule rather than by keeping a second table beside it, so the + // two cannot disagree. The probes cover every boundary the rules above test — the teens, + // the tens, the millions, and a fraction, which is the case that separates `one` from + // `other` in English. + var seen = new List(); + + foreach (var probe in Probes) + { + var category = Of(tag, probe, kind); + + if (!seen.Contains(category)) + { + seen.Add(category); + } + } + + return seen; + } + + /// Numbers that between them reach every branch of every rule above. + private static IReadOnlyList Probes { get; } = + [ + .. new long[] + { + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 20, 21, 22, 23, 24, 25, + 80, 100, 101, 102, 103, 111, 112, 113, 800, 1000, 1_000_000, 2_000_000, + }.Select(n => PluralOperands.Of(n)), + + // A visible fraction, which is `other` in English and in Russian and `many` in Czech. + PluralOperands.Of(1.0m, visibleFractionDigits: 1), + PluralOperands.Of(1.5m, visibleFractionDigits: 1), + ]; + + /// The keyword a message branch is spelled with. + public static string Keyword(PluralCategory category) => category switch + { + PluralCategory.Zero => "zero", + PluralCategory.One => "one", + PluralCategory.Two => "two", + PluralCategory.Few => "few", + PluralCategory.Many => "many", + _ => "other", + }; + + /// Whether a word is one of the six category keywords. + public static bool IsCategory(string word) => + word is "zero" or "one" or "two" or "few" or "many" or "other"; + + /// Whether this table states a rule for a tag's language at all. + public static bool Covers(string tag) + { + ArgumentNullException.ThrowIfNull(tag); + + return LocalesCovered.Contains(Language(tag), StringComparer.Ordinal); + } + + /// + /// The language subtag, which is what a plural rule is keyed on. + /// + /// + /// zh-Hans and zh-Hant pluralise identically, and so do ru and the CI + /// canary's ru-x-canary — the script and the private-use subtag change which glyphs are + /// drawn and which bundle is read, never how a number agrees. + /// + private static string Language(string tag) + { + var dash = tag.IndexOf('-', StringComparison.Ordinal); + + return (dash < 0 ? tag : tag[..dash]).ToLowerInvariant(); + } +} diff --git a/src/MUI.Web/Localization/Sentences.cs b/src/MUI.Web/Localization/Sentences.cs new file mode 100644 index 0000000..a55cf20 --- /dev/null +++ b/src/MUI.Web/Localization/Sentences.cs @@ -0,0 +1,100 @@ +namespace MUI.Web.Localization; + +/// +/// A sentence whose word order belongs to the locale and whose markup belongs to the site. +/// +/// +/// +/// Some sentences have a link or an emphasised word inside them. Gluing English round an anchor +/// gives a language that wants the link first, or a different preposition before it, nowhere to say +/// so; formatting the anchor into the string and trusting the result through a +/// MarkupString would make every bundle a place a tag could be put. So the message places a +/// marker, this walks it, and what a translator writes is text either way. +/// +/// +/// The markers are private-use code points, which no translation contains by accident and no script +/// the site offers uses. They are assigned in the order the slots are passed and never appear in +/// what a reader is shown: a marker that survives into the output is a slot the message did not +/// place, and the run for it is simply absent — which is visible rather than silent. +/// +/// +/// This was two copies before it was one file. The random-game empty state placed two links this +/// way and the reference section needed the same thing for its own two sentences, at which point the +/// marker constant, the walk and the "translator writes no markup" argument were about to exist +/// twice. +/// +/// +public static class Sentences +{ + /// The first marker. One code point per slot, upwards from here. + private const char Marker = '\uE000'; + + /// + /// The most slots one sentence may place. Well past anything readable, and a bound rather than + /// an open range so a stray private-use character in a translation cannot be read as a slot. + /// + private const int MaxSlots = 8; + + /// One piece of a sentence: plain text, or the text of a named slot. + /// What the reader sees, already in their language. + /// + /// The argument name the message placed, or null for the prose between the slots. The caller + /// switches on this to decide what markup goes round the run. + /// + public sealed record Run(string Text, string? Slot); + + /// + /// A message, split into the runs its markup renders — the slots in the order the + /// message puts them, which is the locale's order and not this call's. + /// + public static IReadOnlyList Place(string tag, string id, params (string Name, string Text)[] slots) + { + ArgumentNullException.ThrowIfNull(slots); + ArgumentOutOfRangeException.ThrowIfGreaterThan(slots.Length, MaxSlots); + + var sentence = Messages.For(tag, id, slots + .Select((slot, i) => (slot.Name, Value: (object?)((char)(Marker + i)).ToString())) + .ToDictionary(s => s.Name, s => s.Value, StringComparer.Ordinal)); + + var runs = new List(); + var run = new System.Text.StringBuilder(); + + foreach (var c in sentence) + { + var slot = c - Marker; + + if (slot < 0 || slot >= slots.Length) + { + run.Append(c); + continue; + } + + if (run.Length > 0) + { + runs.Add(new Run(run.ToString(), null)); + run.Clear(); + } + + runs.Add(new Run(slots[slot].Text, slots[slot].Name)); + } + + if (run.Length > 0) + { + runs.Add(new Run(run.ToString(), null)); + } + + return runs; + } + + /// + /// The same sentence with the slots substituted rather than marked — for the plain surface, + /// which has no markup to put round them and must still say every word the page does. + /// + public static string Flat(string tag, string id, params (string Name, string Text)[] slots) + { + ArgumentNullException.ThrowIfNull(slots); + + return Messages.For(tag, id, slots.ToDictionary( + s => s.Name, s => (object?)s.Text, StringComparer.Ordinal)); + } +} diff --git a/src/MUI.Web/MUI.Web.csproj b/src/MUI.Web/MUI.Web.csproj index 2da38cb..4320d0f 100644 --- a/src/MUI.Web/MUI.Web.csproj +++ b/src/MUI.Web/MUI.Web.csproj @@ -30,6 +30,15 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + {count, plural, one {# Spiel} other {# Spiele}} + + + {value}, {count, plural, one {# Spiel} other {# Spiele}}, nur + + + {value}, {count, plural, one {# Spiel} other {# Spiele}}, ausgeschlossen + + + {value}, {count, plural, one {# Spiel} other {# Spiele}} + + + {facet}: beliebig, {count, plural, one {# Spiel} other {# Spiele}} + + + {count, plural, =0 {Keine Spiele eingetragen.} one {# Spiel, jede Tatsache nennt ihre Herkunft.} other {# Spiele, jede Tatsache nennt ihre Herkunft.}} + + + {days, plural, one {# Tag} other {# Tage}} gemessen · {probes, plural, one {# Abfrage} other {# Abfragen}} + + + {days, plural, one {# Tag} other {# Tage}} · {count, plural, one {# Zählung} other {# Zählungen}} + + + {disagreeing, plural, =0 {Keine der {total} widerspricht.} one {# von {total} widerspricht dem, was das Spiel angibt.} other {# von {total} widersprechen dem, was das Spiel angibt.}} + + + {count, plural, =0 {Keine Spiele passen zu allen Antworten.} one {# Spiel passt zu allen Antworten.} other {# Spiele passen zu allen Antworten.}} + + + von {listed, plural, one {# eingetragenem Spiel} other {# eingetragenen Spielen}} · {answers, plural, =0 {keine Antworten gegeben} one {# Antwort gegeben} other {# Antworten gegeben}} + + + {count, plural, one {Das eine Spiel zeigen} other {Diese # Spiele zeigen}} + + + „{answer}“ entfernen — {count, plural, one {# Spiel} other {# Spiele}} + + + Antwort zurücksetzen: {question} + + + Ein Spiel finden + + + passend zu allen Antworten + + + {count, plural, one {Spiel} other {Spiele}} + + + alle Antworten zurücksetzen + + + neu beginnen + + + {count, plural, one {# weiteres} other {# weitere}} + + + gegebene Antworten + + + das gesamte Verzeichnis + + + diese Anfrage wurde abgelehnt + + + ein Name, falls Sie einen haben + + + Name oder Namensteil + + + Nach Namen suchen + + + Spielt gerade jemand? + + + Was möchten Sie spielen? + + + Welche Art von Spiel? + + + In welcher Sprache? + + + Braucht Ihr Client etwas? + + + Verstummte Spiele einbeziehen? + + + egal + + + beliebiges Genre + + + beliebige Art + + + beliebige Sprache + + + egal + + + nein, nur aktive Spiele + + + ja, auch diese zeigen + + + verstummte Spiele + + + TLS — verschlüsselt, Handshake von uns abgeschlossen + + + MSSP — Selbstauskunft des Servers + + + MCCP — komprimierte Ausgabe + + + MXP — anklickbare Links + + + GMCP — strukturierte Client-Daten + + + MSDP — strukturierte Client-Daten + + + CHARSET — Aushandlung der Kodierung + + + UTF-8 — nichtlateinischer Text wird dargestellt + + + TTYPE — Client nennt seinen Typ + + + ATCP — strukturierte Client-Daten + + + MSP — Sound-Auslöser + + + EOR — Prompt-Markierung + + + {token} — im Handshake gemessen + + + gemessen + + + gemessen + + + gemessen + + + gemessen + + + gemessen + + + angegeben + + + angegeben + + + angegeben + + + angegeben + + + abgeleitet + + + abgeleitet + + + nicht gemessen + + + ungezählt + + + nicht erreichbar + + + nicht gezählt + + + keine Zählung + + + von hier aus + + + archiviert + + + beansprucht + + + Nichts zur Auswahl + + + Kein Spiel passt zu diesem Filter. {listing} versuchen oder {archive}. + + + das gesamte Verzeichnis + + + das Archiv einbeziehen + + + Verbindungen + + + nicht beansprucht + + + vom Betreiber beansprucht + + + wird weiter abgefragt + + + typisch + + + Spitze + + + als Text lesen + + + Textfassung + + + zum Inhalt springen + + + ASCII-Banner: der Verbindungsbildschirm von {game}. + + + Kataloge + + + Diese Website und Ihr Konto + + + stöbern + + + lernen + + + diese Website + + + Menü + + + Spiele + + + finden + + + zufällig + + + Archiv + + + Referenz + + + Ökosystem + + + Ranglisten + + + über uns + + + eintragen + + + Spiel eintragen + + + anmelden + + + Ihre Spiele + + + Design + + + automatisch + + + hell + + + dunkel + + + Demodaten. + + + Es ist keine Datenbank konfiguriert, daher sind dies Beispieldaten. Nichts davon wurde gemessen. + + + alle Spiele + + + Archiv + + + vom Spiel angegeben + + + was sich geändert hat + + + Ein Verzeichnis des MU*-Hobbys + + + Jede Tatsache nennt ihre Herkunft und ihr Alter: von unserem Crawler gemessen oder vom Spiel angegeben und als solche gekennzeichnet. + + + Spiele nach Name, Thema, Codebase oder Host suchen + + + nach Name, Thema, Codebase oder Host suchen + + + suchen + + + bekannte Spiele + + + jetzt verbunden + + + antwortend, ungezählt + + + archiviert + + + neu entdeckt + + + verstummt — wird weiter abgefragt + + + wieder da + + + Nichts Neues. + + + Nichts ist verstummt. + + + Nichts ist zurückgekehrt. Wir klopfen weiter. + + + live + + + Spiele + + + sortiert nach {order} + + + zufällig + + + Verbindungen · erreicht + + + von hier aus + + + Keine Treffer. + + + Weniger Wörter versuchen oder einen Filter entfernen. + + + Filter zurücksetzen + + + über {codebase} + + + nie + + + vom Betreiber beansprucht + + + Unbekannte Codebase + + + wir konnten die Codebase dieses Spiels nicht identifizieren + + + und {count, plural, one {# weiteres} other {# weitere}}: {names} + + + Sortierung + + + Zeitraum + + + jetzt + + + typisch + + + Spitze + + + Name + + + erreicht + + + 7 Tage + + + 30 Tage + + + 90 Tage + + + Spiele suchen + + + Spiele suchen + + + Filter + + + angezeigt + + + alle zurücksetzen + + + — nicht mehr danach filtern + + + beliebig + + + weitere Filter ({count}) + + + {count, plural, one {# weiterer} other {# weitere}} + + + zusätzlich zeigen + + + Standardmäßig aus. Keines von beiden ist ein Urteil über das Spiel. + + + archiviert + + + Erwachseneninhalte + + + archivierte Spiele, {shown, select, true {angezeigt} other {verborgen}} + + + Spiele, die Erwachseneninhalte angeben, {shown, select, true {angezeigt} other {verborgen}} + + + Zahlen sind gemessene Spiele, nie Schätzungen. + + + was die Kennzeichen und die Leerstellen bedeuten + + + Eine Leerstelle ist eine Lücke in unserer Messung, kein Nein. Jede Facette benennt ihre eigene: nicht identifiziert, nicht angegeben, nichts ausgehandelt. + + + Eine gemessene Null ist eine Zählung. Eine unbekannte Zählung ist keine Null und wird nie als solche sortiert. + + + Offene Facetten listen ihre {count} häufigsten Werte. Der Rest ist über die Suche und über die URL erreichbar. + + + Nicht angehakt heißt nicht gemessen — nicht, dass es dem Spiel fehlt. + + + Aktivität + + + zuletzt gesehen + + + angebotene Protokolle + + + verschlüsselt + + + Kodierung + + + Codebase + + + Version + + + Abstammung + + + Familie + + + Genre + + + Sprache + + + jetzt verbunden + + + diese Woche aktiv + + + ruhig — nie mehr als 0 gezählt + + + verstummt — seit einem Monat nicht erreicht + + + archiviert + + + in den letzten 24 Stunden + + + in den letzten 7 Tagen + + + in den letzten 30 Tagen + + + länger her + + + nie erreicht + + + nichts ausgehandelt + + + nicht identifiziert + + + nicht angegeben + + + über TLS verbunden + + + nicht {value} + + + etwas ausgehandelt + + + überhaupt identifiziert + + + überhaupt angegeben + + + wir haben das selbst beobachtet + + + das Spiel sagt es, und wir haben es nicht geprüft + + + wir haben gruppiert, was das Spiel uns mitgeteilt hat + + + Name + + + jetzt verbunden + + + zuletzt erreicht + + + typisch verbunden · 7 Tage + + + typisch verbunden · 30 Tage + + + typisch verbunden · 90 Tage + + + meiste gleichzeitig · 7 Tage + + + meiste gleichzeitig · 30 Tage + + + meiste gleichzeitig · 90 Tage + + + jetzt in der Zeile + + + typisch + + + Spitze + + + Unbekannte Zählung + + + noch nie erreicht — nicht etwa vor langer Zeit erreicht + + + weniger als {minimum} Zählungen im Zeitraum oder gar keine — nicht etwa eine typische Zählung von null + + + nichts, was wir im Zeitraum zählen konnten — nicht etwa ein Spiel, mit dem niemand verbunden war + + + Median {value} · {days, plural, one {# Tag} other {# Tage}} · {count, plural, one {# Zählung} other {# Zählungen}} + + + meiste {value} gleichzeitig · {days, plural, one {# Tag} other {# Tage}} · {count, plural, one {# Zählung} other {# Zählungen}} + + + Verbindungsbildschirm + + + Verbindungen nach Stunde + + + Wie viele, im Zeitverlauf + + + Erreichbar + + + Was sich geändert hat + + + Fähigkeiten + + + Vom Spiel angegeben + + + Verweise + + + Nicht beansprucht — alles hier wurde gemessen. + + + Vom Betreiber beansprucht — die gemessenen Tatsachen unten sind weiterhin unsere. + + + Dieses Spiel beanspruchen + + + antwortet seit {date} + + + als Text lesen — {count, plural, one {# Zeile} other {# Zeilen}} + + + Fähigkeit + + + Alter + + + angeboten + + + stumm + + + fehlend + + + verweigert + + + behauptet + + + widerspricht + + + wo sie widersprechen ({count}) + + + {day} {time} — {count, plural, =0 {0 Spieler, gemessen} one {durchschnittlich # Spieler} other {durchschnittlich # Spieler}} + + + {day} {time} — abgefragt, keine Zählung ablesbar + + + {day} {time} — keine Messung in dieser Stunde + + + Wir haben die Aktivität dieses Spiels noch nicht gemessen. + + + Keine Stunde der Woche hat eine Zählung ergeben. + + + Jede Stunde gemessen, und in keiner war jemand verbunden. + + + {count, plural, one {Für # Stunde am {day} liegt noch keine Messung vor.} other {Für # Stunden am {day} liegt noch keine Messung vor.}} + + + {count, plural, one {Für # Stunde der Woche liegt noch keine Messung vor.} other {Für # Stunden der Woche liegt noch keine Messung vor.}} + + + {count, plural, one {# Stunde am {day} hat geantwortet, aber keine Zählung ergeben.} other {# Stunden am {day} haben geantwortet, aber keine Zählung ergeben.}} + + + {count, plural, one {# Stunde der Woche hat geantwortet, aber keine Zählung ergeben.} other {# Stunden der Woche haben geantwortet, aber keine Zählung ergeben.}} + + + Jeden Tag am belebtesten, {window}. + + + Jeden Tag am belebtesten, {part}, {window}. + + + {days} am belebtesten, {window}. + + + {days} am belebtesten, {part}, {window}. + + + Verlässlich ruhig {who}, {window}. + + + Verlässlich ruhig {who} {part}, {window}. + + + jeden Tag + + + an jedem gemessenen Tag + + + an Wochentagen + + + an {days} + + + morgens + + + nachmittags + + + abends + + + nachts + + + morgens + + + nachmittags + + + abends + + + nachts + + + {list}, {next} + + + {first} und {second} + + + noch nicht genug Messungen + + + Für keine Stunde der Woche liegt bisher eine Messung vor. + + + {count, plural, one {# Stunde hat geantwortet und keine Zählung ergeben.} other {# Stunden haben geantwortet und keine Zählung ergeben.}} + + + Das Raster erscheint, sobald für jeden Wochentag eine Messung vorliegt. + + + {days, plural, one {Bisher an # von sieben Tagen gemessen; das Raster erscheint, sobald jeder Tag eine gemessene Stunde hat.} other {Bisher an # von sieben Tagen gemessen; das Raster erscheint, sobald jeder Tag eine gemessene Stunde hat.}} + + + {count, plural, one {# Stunde gemessen, darin war niemand verbunden.} other {# Stunden gemessen, in allen war niemand verbunden.}} + + + {count, plural, one {# Stunde gemessen, die belebteste mit {peak} am {day} um {time} UTC.} other {# Stunden gemessen, die belebteste mit {peak} am {day} um {time} UTC.}} + + + {count, plural, one {# weitere Stunde hat geantwortet und keine Zählung ergeben.} other {# weitere Stunden haben geantwortet und keine Zählung ergeben.}} + + + {day} — {facts} + + + {first}, {second} + + + den ganzen Tag null gemessen + + + Spitze {count} um {time} + + + niemand verbunden {window} + + + in keiner Stunde eine Zählung + + + {count, plural, one {# Stunde nicht gemessen} other {# Stunden nicht gemessen}} + + + {count, plural, one {# Stunde abgefragt, aber nicht zählbar} other {# Stunden abgefragt, aber nicht zählbar}} + + + Tag + + + ruhigste + + + belebteste + + + um + + + keine Zählung + + + Verbundene Spieler nach Tag, in UTC. {window}. + + + Zeiten in UTC · {window} + + + {weeks, plural, one {gleitender Durchschnitt über # Woche} other {gleitender Durchschnitt über # Wochen}} + + + gezählt, auch eine gemessene Null + + + abgefragt, keine Zählung ablesbar + + + keine Messung in dieser Stunde + + + Wann Spieler verbunden sind (UTC) + + + gezählt + + + wir sind hineingekommen und haben eine Zahl abgelesen, auch eine gemessene Null + + + wir sind hineingekommen, aber keine Zahl war ablesbar + + + für diese Stunde liegt uns keine Messung vor + + + Sprache + + + Sprache wechseln + + + Über mu*index + + + Jedes Spiel hier wurde von einer Maschine gemessen, die sich mit ihm verbunden hat, und jeder Wert nennt seine Herkunft und seinen Zeitpunkt. Diese Seite behandelt, was das belegt, was wir falsch machen, wessen Verzeichnisse wir lesen und wie sich der Crawler stoppen lässt. + + + Was hier eine Tatsache ist + + + Gemessen schlägt angegeben, und beides wird gezeigt. + + + Der MSSP-Bericht eines Spiels ist das Spiel, das sich selbst beschreibt. Der Telnet-Handshake ist das, was wir es haben tun sehen. Beides steht auf seiner Seite, versehen mit dem Wie und dem Wann. Wo beides sich widerspricht, zeigen wir den Widerspruch. + + + Eine Spielerzahl nennt ihre Herkunft. + + + Entweder ein WHO oder DOING, am Verbindungsbildschirm gelesen und von uns gezählt, oder das MSSP-Feld PLAYERS des Spiels selbst, das es veröffentlicht hat. Niemals zusammengeführt. + + + Eine Antwort, die wir nicht lesen können, ist unbekannt, niemals null. + + + Server passen ihre WHO-Kopfzeilen frei an, und ab einem gewissen Punkt kann unser Parser eine solche nicht mehr lesen. Das ist unzählbar, ein eigener Zustand. Eine gemessene Null — wir sind hineingekommen, und niemand war da — ist eine Zählung und wird als solche ausgegeben. + + + Erreichbar, niemals Uptime. + + + Wir öffnen in Abständen einen Socket von einem einzigen Host aus. Ein Spiel, zu dem wir keine Route finden, ist nicht erreichbar und dennoch völlig lebendig. Nichts hier behauptet die Uptime eines Spiels, denn nichts hier hat sie gemessen. + + + Eine Stunde ist gezählt, unzählbar oder nicht gemessen. + + + Das Aktivitätsraster hat drei Zustände. Der dritte ist leer und nennt keine Ursache: eine Stunde, die wir nicht erreichen konnten, und eine Stunde, die wir nie abgefragt haben, sind dieselbe Leerstelle, und keine von beiden ist die Ausfallzeit dieses Servers. + + + Was wir falsch machen, soweit wir es wissen + + + Die Schonfrist bis zum Archiv wird ab dem Tag gemessen, an dem wir Sie gefunden haben. + + + Ein Spiel, das nicht mehr antwortet, verlässt nach seiner Schonfrist das Standardverzeichnis: ein Viertel der erreichbaren Zeit, die wir abgefragt haben, mindestens 60 und höchstens 365 Tage. Ein Spiel, das seit 1995 läuft, beginnt am Tag seiner Entdeckung beim Minimum. Wir importieren nichts, um die Jahre vor unserer Ankunft aufzufüllen. + + + MSSP CREATED wird auf diese Schonfrist nicht angerechnet. + + + Es ist eine von Hand getippte Zeile in einer Konfigurationsdatei; sie anzurechnen würde die Archivschwelle manipulierbar machen. Sie wird als Angabe gezeigt und bringt nichts ein. + + + Ein beanspruchtes Spiel erhält das Maximum. + + + Der Nachweis von Serverzugriff ist das volle Jahr Schonfrist wert, gleich wie lange wir schon zusehen. + + + Alles hier ist ein einziger Host, der in Abständen hinsieht. + + + Ein Prozentsatz erreichbarer Zeit ist ein Anteil des von uns beobachteten Zeitraums, nie eines anderen. Keine Grafik hier füllt den Rest auf. + + + Nichts wird jemals gelöscht. + + + Das Archivieren nimmt ein Spiel aus dem Standardverzeichnis, aus den Ranglisten und aus der Zahl der heute aktiven Spiele heraus, und sonst nichts. Seine Seite, seine URL, seine Geschichte und seine Adresse bleiben, es wird weiter abgefragt, und eine einzige erfolgreiche Abfrage bringt es zurück. + + + Was diese Website nicht tut + + + Keine Stimmen, Sterne, Bewertungen oder Empfehlungen. + + + Ranglisten werden ausschließlich aus gemessenen Daten berechnet. Ein Verzeichnis, das danach sortiert, wer die meisten Klicks mobilisieren kann, beschreibt den Wahlkampf und nicht das Hobby — und genau daran sind die Vorgänger zugrunde gegangen. + + + Keine Foren, Rezensionen, Wikis, Kommentare oder Spielerprofile. + + + Einführendes Material — was ein MUSH ist, welche Codebase zu gemeinsamem Rollenspiel passt — wird geschrieben, namentlich gezeichnet und versioniert wie der Rest der Website. + + + Spielernamen werden nie gespeichert. + + + Eine WHO-Antwort wird im Arbeitsspeicher auf eine Zahl und die Form der Kopfzeile hin ausgewertet. Die Namen werden nicht festgehalten; Aggregate verwenden einen gesalzenen Hash mit rotierendem Salt. + + + Es wird keine absolute Spielerzahl veröffentlicht. + + + Anteile pro Codebase und pro Protokoll werden veröffentlicht: Ein Verhältnis über die gemessene Menge übersteht die Spiele, die wir nicht zählen können. „Wie viele Menschen spielen MU*“ übersteht das nicht, denn diese Zahl würde es nicht überstehen, zitiert zu werden. + + + Der Crawler und wie er sich stoppen lässt + + + Eine Abfrage ist eine Verbindung, die sich nie anmeldet. + + + Sie öffnet einen Socket, handelt Telnet-Optionen aus, liest den Verbindungsbildschirm, fragt MSSP über Option 70 an, sendet {commands} und trennt die Verbindung. Kein Charakter, keine Anmeldung, nichts auf der Gegenseite verändert. Ein Timeout begrenzt die Sitzung, damit eine hängengebliebene Abfrage keinen Verbindungsplatz belegt. + + + CRAWL DELAY hat Vorrang. + + + Ein Spiel, das in seinem MSSP-Bericht einen bevorzugten Mindestabstand nennt, bekommt ihn — in beide Richtungen vor unserem eigenen Zeitplan: 720 Stunden heißt monatlich, nicht wöchentlich. Ein verstummtes Spiel wird im längeren Abstand für immer weiter versucht; so trägt es sich selbst wieder ein, wenn es zurückkommt. + + + Eine per Verweis genannte Adresse wird geprüft, nie geglaubt. + + + MSSP erlaubt es einem Spiel, andere Spiele zu nennen. Jeder Name wird aufgelöst, bevor irgendetwas angewählt wird, und abgelehnt, sofern nicht jede Adresse, auf die er auflöst, global routbar ist. Eine gemischte Antwort lehnt das ganze Ziel ab. Unsere Ablehnung wird als unsere verbucht und erscheint nie als Ausfallzeit im Datensatz eines Spiels. + + + Verbindungsbildschirme werden gezeigt, weil sie an alle gesendet werden. + + + Ein Server malt seinen Verbindungsbildschirm ohne Anmeldung an jede anonyme Verbindung. Wir zeigen ihn als Beleg und kennzeichnen ihn. Auf Bitte hin verschwindet er. + + + Ein Stopp genügt, und wir hören auf — auf drei Wegen. + + + Veröffentlichen Sie {variable} 1 in Ihrem MSSP-Bericht, dann ist die Abfrage, die es liest, die letzte. Oder veröffentlichen Sie einen TXT-Eintrag unter {label}.your.host mit dem Inhalt „{value}“, wofür es keine MSSP-Unterstützung und kein Konto hier braucht. Oder schreiben Sie einer Person. Alle drei werden innerhalb eines Crawl-Zyklus befolgt, mit Datum und dem Gelesenen festgehalten und auch im Eintragungsformular durchgesetzt. + + + Das MSSP-Feld stoppt diesen Listener; der TXT-Eintrag stoppt den Host. + + + MSSP wird von dem Port veröffentlicht, der geantwortet hat, und spricht daher für diesen Port — beim MU*-Hosting laufen regelmäßig unabhängige Spiele auf einer Domain, und keines darf sein Nachbarspiel zum Schweigen bringen. Ein TXT-Eintrag umfasst jeden Port, sofern er nicht einen einzelnen nennt, etwa „{value}=4201“. Alles dort, was wir nicht als Portliste lesen können, meint den ganzen Host; deshalb funktioniert auch „{value}=all“. + + + Der Weg über DNS ist der, den Sie ohne Rückfrage bei uns rückgängig machen können. + + + Ein TXT-Eintrag lässt sich lesen, ohne sich mit einem Server zu verbinden, der uns das untersagt hat; deshalb lesen wir ihn vor jeder Anwahl neu. Löschen Sie ihn, und wir wählen binnen einer Woche wieder an. Ein MSSP-Feld lässt sich nicht neu lesen, ohne genau das zu tun, was Sie uns untersagt haben; deshalb gelten ein Ausschluss per MSSP und schriftliche Bitten so lange, bis Sie etwas anderes sagen. Dieser TXT-Abruf ist alles, was eine ausgeschlossene Adresse noch bekommt: Er berührt Ihren Nameserver, nie Ihr Spiel. + + + Aufhören ist kein Löschen, und es ist keine Ausfallzeit. + + + Ein Spiel, das sich ausschließen lässt, behält seine Seite, seine Adresse und alles, was wir vor der Bitte gemessen haben. Nur neue Daten hören auf: Das Aktivitätsraster gewinnt keine Stunden mehr hinzu und nennt keine Ursache, denn unsere Entscheidung, nicht mehr anzuklopfen, ist eine Tatsache über uns. Sie wird bei dem Crawl festgehalten, der nicht stattgefunden hat, und im Register derer, die darum gebeten haben. + + + Wenn Aufhören nicht genügt, kann auch der Eintrag verschwinden. + + + Sobald wir für jede Adresse, unter der Ihr Spiel antwortet, aufgehört haben, bietet Ihre Übersicht eine weitere Möglichkeit: es aus dem Verzeichnis, aus den Ranglisten und aus der Tageszahl herauszunehmen. Die Seite und jede Adresse, die es je hatte, antworten weiterhin, und nichts wird gelöscht — es ist nur kein Ort mehr, an dem man beim Stöbern ankommt. Dafür braucht es einen bestätigten Anspruch, denn es ist eine Entscheidung über Ihr Spiel, und wir halten fest, wer sie getroffen hat. Und eine Abfrage macht sie rückgängig: Nehmen Sie Ihren Ausschluss zurück, dann bringt die nächste Anwahl, die eine Antwort erhält, Sie wieder ins Verzeichnis, ohne dass Sie uns ein zweites Mal fragen müssen. + + + Der Crawler nennt sich {name}, wenn ein Server fragt, was er ist. + + + Der Crawler ist so konfiguriert, dass er sich {name} nennt, kann das aber noch nicht mitteilen. Seine Telnet-Bibliothek bietet einem Client keine Möglichkeit, den Terminaltyp zu setzen; deshalb steht in Ihren Logs der Standardwert dieser Bibliothek, und NEW-ENVIRON wird aus der Umgebung des Crawler-Hosts beantwortet. Beides sind Lücken in der Bibliothek und von uns dort zu beheben. Bis dahin erkennen Sie eine Abfrage an ihrer Form: eine Verbindung, keine Anmeldung, ein kurzer, nur lesender Befehlssatz, weg. + + + Crawler + + + Kontakt + + + Crawler: {name} + + + Kontakt: {url} + + + — Platzhalter; diese Installation hat keine Kontaktadresse gesetzt + + + Es ist keine Kontaktadresse konfiguriert; die obige ist daher ein Platzhalter und antwortet niemandem. + + + Woher die Liste der Spiele stammt + + + Wir übernehmen Adressen. Sonst nichts. + + + Ein Backfill übernimmt einen Host und einen Port. Keine Spielerzahlen, keine Erreichbarkeitsverläufe, keine Beschreibungen, keine Felder und keinen Vermerk, von welcher Website eine Adresse stammt. + + + Bewusst weniger, als diese Websites hergeben. + + + Mehrere davon halten jahrelange, datierte Spielerzahlen bereit. Diese zu importieren würde die Aktivitätsraster genau der Spiele füllen, die schon jemand anderes beobachtet hat, und die zentrale Behauptung dieser Website auf die Abfragen eines Dritten stützen. + + + Die Herkunft eines Spiels ist keine einzelne Tatsache. + + + Jedes Spiel, das einen Eintrag wert ist, steht in mehreren dieser Verzeichnisse; „importiert aus“ würde also nur den Abruf nennen, der zuerst lief. Dass ein Spiel existiert, ist öffentlich bekannt; wo wir es gelesen haben, fügt nichts hinzu und ist der Teil fremder Arbeit, auf den wir am wenigsten Anspruch haben. + + + Die Website eines anderen zu lesen bleibt: die Website eines anderen zu lesen. + + + Wir bitten vor dem Scrapen um einen Massenexport oder einen dokumentierten Endpunkt, lesen zuerst robots.txt und drosseln Scrapes stark. Eine Quelle, die das Einverständnis ihrer Betreuung braucht, wird erst abgerufen, wenn eine Person bestätigen kann, dass gefragt wurde. + + + gelesen — nur Adressen + + + nicht gelesen — Erlaubnis ausstehend + + + Eine Seite, eine Anfrage. Veröffentlicht von einem Crawler, der sich mit jedem Spiel verbindet und ausgibt, was er gelesen hat. + + + Die MSDP-Liste desselben Crawlers. Fast eine Teilmenge ihres MSSP-Gegenstücks; gelesen wegen der wenigen Adressen, die sie erreicht und das andere nicht. + + + Veröffentlicht seinen gesamten Katalog auf einer Seite, das Lesen kostet also eine einzige Anfrage. Unsere größte Quelle für Adressen und für keinerlei Messungen. + + + Eine Übersichtsseite und je eine Seite pro Welt, also ein Scrape statt eines Exports. Am 30. Juli 2026 haben wir 143 ihrer Seiten abgerufen, im Abstand von fünfzehn Sekunden und unter Beachtung von robots.txt, aber bevor ihnen jemand geschrieben hatte. Das hätte nicht geschehen dürfen. Die Sperre verlangt nun eine Person, die bestätigt, dass die Betreuung gefragt wurde. + + + Implementiert, getestet, nie ausgeführt. Die stärkste Quelle hier in jeder Hinsicht außer der Erlaubnis; es wird nichts abgerufen, bevor ihnen jemand geschrieben hat. + + + Lizenz + + + Der Code steht unter MIT. + + + Die Website, der Crawler und die Parser sind Open Source unter der MIT-Lizenz. + + + Die Lizenz für die Daten ist eine offene Frage. + + + Eine vom Code getrennte Entscheidung, und noch nicht getroffen. Die Bedingungen unten sind die derzeitige Antwort dieser Installation, nicht die feststehende Haltung des Projekts. Dass ein konkurrierendes Verzeichnis den gesamten Katalog übernimmt, gilt hier als Erfolg; was auch immer festgelegt wird, wird einem solchen nicht im Weg stehen. + + + Code + + + Daten, so wie diese Installation sie ausliefert + + + Namensnennung als + + + Code: {licence} + + + Daten: {licence} + + + Namensnennung als: {credit} + + + (was diese Installation ausliefert. Die eigene Antwort des Projekts ist noch offen.) + + + Ein Spiel eintragen + + + Uns mitteilen, wo ein Spiel ist. Ein Host und ein Port sind das ganze Formular; alles andere auf dieser Website misst unser eigener Crawler. + + + Host + + + Port + + + mud.example.org, oder mud.example.org:4201 einfügen und den Port leer lassen + + + Eintragen + + + Das Eintragen braucht eine Datenbank, und diese Website läuft auf den Demodaten. Es gibt kein Crawl-Register zum Hineinschreiben, deshalb fehlt das Formular, statt still nichts zu tun. + + + Nicht hier + + + Was mit einer Adresse geschieht + + + Wir lösen die Adresse auf, bevor wir sie anwählen, und lehnen alles ab, was außerhalb des öffentlichen Internets auflöst. Das ist eine Entscheidung über unseren eigenen Socket, nie eine Tatsache über ein Spiel. + + + Wenn die Betreiber dieses Hosts uns gebeten haben, ihn nicht zu crawlen, nehmen wir die Adresse nicht an, gleich wer sie einträgt. Fremde können Ihr Spiel nicht zurück auf diese Website bringen. + + + Antwortet sie, lesen wir, was der Server über sich selbst sagt, und lesen es nach seinem eigenen Zeitplan für immer weiter. Eine Adresse muss nur einmal genannt werden. + + + Nichts erscheint auf der Website, bis jemand nachweist, dass er es betreibt. Ein Anspruch braucht einen Passkey und eine im Spiel selbst veröffentlichte Zeile. + + + Eine Adresse, die wir bereits haben, fällt mit dem bestehenden Eintrag zusammen. Zweimal senden erzeugt keinen zweiten Eintrag und zieht keine Abfrage vor. + + + diese Adresse + + + Im Register. + + + {address} wird im nächsten Crawl-Zyklus angewählt, danach für immer nach eigenem Zeitplan. Sobald jemand nachweist, dass er es betreibt, erscheint es hier — kommen Sie mit derselben Adresse zu diesem Formular zurück, dann bekommen Sie hier den Link dazu. + + + Wir haben es, nicht beansprucht. + + + {address} messen wir bereits. Es bleibt von der Website fern, bis jemand nachweist, dass er es betreibt. Sind Sie das, geht es hier hinein. + + + Das haben wir schon. + + + {address} ist ein Spiel, das wir bereits messen. Es wurde nichts angelegt und nichts geändert. + + + Diese Adresse haben wir schon. + + + {address} ist uns bereits bekannt. Es wurde nichts angelegt und nichts geändert. + + + Wartet bereits. + + + {address} steht im Crawl-Register und hat noch nicht geantwortet. Erneutes Senden zieht sie nicht vor: Ein Ziel behält seinen eigenen Zeitplan, damit niemand uns am Server eines anderen antreiben kann. + + + Keine Adresse, die wir anwählen können. + + + Ein Host braucht einen Punkt oder einen Doppelpunkt, und ein Port ist eine Zahl zwischen 1 und 65535. Beide Felder ausfüllen oder mud.example.org:4201 in das erste einfügen. + + + Das können wir nicht anwählen. + + + Für {address} führen drei Dinge zu dieser Antwort: Der Name löst womöglich nicht auf, er löst womöglich außerhalb des öffentlichen Internets auf, oder die Betreiber dieses Hosts haben uns gebeten, fernzubleiben. Wir sagen bewusst nicht, welches davon, denn das für Fremde zu beantworten kartiert ein Netz von außen. Über die Adresse wurde nichts festgehalten; die Entscheidung war unsere und ist als unsere verbucht. + + + Genug für jetzt. + + + Dieses Formular ist pro Absender begrenzt, und Sie haben die Grenze erreicht. Kommen Sie in einer Stunde wieder. Nichts ging verloren — was wir angenommen haben, steht bereits im Register. + + + dieses Spiel beanspruchen + + + Anmelden + + + Melden Sie sich mit einem Passkey an, um ein Spiel zu beanspruchen, das Sie betreiben. Es gibt kein Passwort, das verloren gehen, und keines, das gestohlen werden kann. + + + Das Beanspruchen braucht eine Datenbank, und diese Website läuft auf den Demodaten. Es gibt nichts, wo man sich anmelden könnte. + + + Die Anmeldung ist ein Passkey. + + + Ihr Gerät oder Ihr Passwortmanager hält den privaten Schlüssel; wir halten nur die öffentliche Hälfte. Kein Passwort, keine E-Mail. + + + Mit einem Passkey anmelden + + + Die einzige Seite hier, die JavaScript braucht. Passkeys funktionieren ohne es nicht. + + + Noch kein Konto? + + + Sie brauchen eines nur, um ein Spiel zu beanspruchen, das Sie betreiben. Wählen Sie einen Namen, unter dem Sie erscheinen — eine Beschriftung neben Ihrem Anspruch, kein echter Name. + + + Name + + + z. B. corvid-admin + + + Konto mit einem Passkey anlegen + + + Was wir speichern + + + Den Namen, den Sie gewählt haben. + + + Den öffentlichen Schlüssel jedes Passkeys, den Sie registrieren, und den Namen, den Ihr Gerät ihm gegeben hat. + + + Welche Spiele Sie beansprucht haben und wann. + + + Keine E-Mail-Adresse, kein Passwort, kein an Ihr Konto gebundenes IP-Protokoll. Verlieren Sie alle Passkeys, können Sie einen frischen Anspruchs-Token in Ihrem Spiel veröffentlichen und von vorn beginnen: Der Beweis ist das Spiel, nicht das Konto. + + + {day}. {month} {year} + + + {date} {time} UTC + + + jetzt + + + {count, plural, one {# Min} other {# Min}} + + + {count, plural, one {# Std} other {# Std}} + + + {count, plural, one {# T} other {# T}} + + + {count, plural, one {# W} other {# W}} + + + {count, plural, one {# Mon} other {# Mon}} + + + {count, plural, one {# J} other {# J}} + + + gerade eben + + + {count, plural, one {vor # Min} other {vor # Min}} + + + {count, plural, one {vor # Std} other {vor # Std}} + + + {count, plural, one {vor # T} other {vor # T}} + + + {count, plural, one {vor # W} other {vor # W}} + + + {count, plural, one {vor # Mon} other {vor # Mon}} + + + {count, plural, one {vor # J} other {vor # J}} + + + gerade eben + + + {count, plural, one {vor # Min} other {vor # Min}} + + + {count, plural, one {vor # Std} other {vor # Std}} + + + {count, plural, one {vor # T} other {vor # T}} + + + {count, plural, one {vor # W} other {vor # W}} + + + {count, plural, one {vor # Mon} other {vor # Mon}} + + + {count, plural, one {vor # J} other {vor # J}} + + + {age}, {stamp} + + + , {stamp} + + + {value} — {how}, Quelle: {source}, zuletzt bestätigt {date} + + + {value} — {how}, Quelle: {source}, zuletzt bestätigt {date} (Aktualisierung überfällig) + + + ({how}, {age}) + + + ({how}, {age}, überfällig) + + + vom Betreiber angegeben + + + Redaktion + + + der Telnet-Handshake + + + der Betreiber + + + WHO + + + I3 + + + MSSP + + + INFO + + + die I3-Mudlist + + + der Verbindungsbildschirm + + + Das Ökosystem + + + Anteile, niemals Gesamtzahlen. Wir veröffentlichen keine Zahl dazu, wie viele Menschen MU* spielen: Ein Verhältnis über die von uns gemessenen Spiele übersteht die, die wir nicht erreichen können; eine Kopfzahl nicht. + + + {count, plural, one {{value} Spiel eingetragen} other {{value} Spiele eingetragen}} + + + {count, plural, one {{value} Spiel, dessen Handshake wir abgeschlossen haben} other {{value} Spiele, deren Handshake wir abgeschlossen haben}} + + + {count, plural, one {{value} Spiel, dessen MSSP-Bericht uns vorliegt} other {{value} Spiele, deren MSSP-Bericht uns vorliegt}} + + + Ältester Handshake hier: bestätigt vor {age}. + + + {count, number} von {total, number} ({fraction, number, ::percent .0}) + + + {count, number} von {total, number} — noch nichts gemessen + + + {fraction, number, ::percent .0} + + + Codebases + + + Von {listed, plural, one {# eingetragenen Spiel} other {# eingetragenen Spielen}} haben uns {identified, number} mitgeteilt, was sie ausführen, und jeder Anteil unten bezieht sich auf diese {identified, number}. Eine Codebase, die wir nicht lesen konnten, bleibt aus dem Nenner heraus und wird nie als etwas anderes gezählt. + + + Noch kein eingetragenes Spiel hat uns seine Codebase mitgeteilt. + + + {share} führen eine Codebase aus, die kein anderes eingetragenes Spiel ausführt — je ein Spiel, was ein Name ist und kein Anteil. Sie stecken im Nenner oben und sind aus den Balken herausgefaltet, nicht entfernt: + + + Abstammungen + + + Dieselben Spiele, gruppiert nach der Tradition, von der ihr Server abstammt — unsere Lesart der Codebase, nicht etwas, das ein Spiel veröffentlicht hätte. Kein Spiel meldet „MUSH“: MSSP kennt keinen solchen Wert, und der größte Teil der MUSH-Welt veröffentlicht überhaupt kein MSSP, deshalb lässt sich die Frage nur so stellen. + + + Noch kein eingetragenes Spiel führt eine Codebase aus, die wir einer Abstammung zuordnen. + + + {count, plural, one {# dieser Spiele führt eine Codebase aus} other {# dieser Spiele führen Codebases aus}}, die wir keiner Abstammung zuordnen — mehrere sagen das selbst und veröffentlichen {family}. Sie stecken im Nenner oben und in niemandes Anteil. + + + Protokolle + + + Jede gemessene Zahl unten ist als Untergrenze zu lesen. MSSP fragen wir namentlich an, dort ist Schweigen also eine Antwort. Sonst wird hier nichts angefragt, und ein Server kann ein Protokoll unterstützen, ohne es je anzubieten. + + + {instrument} ist die einzige Zeile unten, die keine Untergrenze ist: Wir fragen jeden Server namentlich danach, die Spiele, die es nicht angeboten haben, wurden also gefragt und haben abgelehnt. Es ist zugleich die einzige ohne angegebene Zahl, denn jedes Spiel, dessen Bericht uns vorliegt, unterstützt es durch Vorführung, und eine Zählung derer, die es zusätzlich aufgeführt haben, würde daran nur eine Gewohnheit messen. + + + Uns liegen {reports, number} Berichte vor, und {offered, plural, one {# Spiel bietet} other {# Spiele bieten}} MSSP heute an: Die übrigen {gap, number} haben nach unserem Lesen aufgehört, einen zu veröffentlichen, und ein Bericht wird nicht verworfen, weil er nicht mehr neu ausgegeben wird. + + + Verbreitung der Protokolle. Gemessen ist, was ein Server in einem abgeschlossenen Handshake angeboten hat; angegeben ist, was sein MSSP behauptet. Zwei Mengen von Spielen, also zwei Nenner. + + + Protokoll + + + gemessen — von {basis} + + + angegeben — von {basis} + + + nicht gemessen — nie beobachtet + + + {share} · {declined, plural, one {# Spiel hat} other {# Spiele haben}} auf Nachfrage abgelehnt + + + {share} · bei {unobserved, plural, one {# Spiel} other {# Spielen}} weder angeboten noch nachgefragt + + + {share} · {declined, plural, one {# Spiel hat} other {# Spiele haben}} auf Nachfrage abgelehnt · bei {unobserved, plural, one {# Spiel} other {# Spielen}} weder angeboten noch nachgefragt + + + nicht nachgefragt — jeder Bericht hier ist die Antwort + + + Verbreitung im Zeitverlauf + + + Jeder Punkt ist ein Anteil an den Spielen, die wir an jenem Tag gemessen hatten; diese Linie bewegt sich also aus zwei Gründen: weil ein Spiel ändert, was es anbietet, und weil sich die Menge der messbaren Spiele darum herum ändert. Nur das Erste ist Verbreitung. Die Zahl der Wechsel unten ist der Teil, der rein aus Spielen besteht, die es sich anders überlegt haben. + + + Gemessener Anteil je Protokoll, älteste Messung zuerst + + + damals + + + jetzt + + + nicht gemessen + + + Eine Momentaufnahme, keine Kurve + + + Eine Momentaufnahme dessen, was wir jetzt messen können. Eine Verbreitungskurve zeichnet Spiele, die es sich anders überlegen; wir halten eine Änderung fest, wenn sie geschieht, die Kurve wird also zeichenbar, sobald genug davon erfasst sind. Zu zeichnen, wann wir jedes Spiel zuerst erreicht haben, würde den Crawl messen und nicht das Hobby. + + + {count, plural, one {# geänderte Fähigkeit} other {# geänderte Fähigkeiten}} bisher erfasst — das Material, aus dem eine Kurve gezeichnet wird. + + + Noch hat sich keine gemessene Fähigkeit geändert, es gibt also nichts zu zeichnen. + + + Gemessen bezieht sich auf {measured}; angegeben auf {declared}. Zwei Mengen von Spielen, also zwei Nenner. + + + {listed} · {handshakes} · {mssp}. + + + Der älteste Handshake in diesem Bild wurde zuletzt vor {age} bestätigt. + + + Dieselben Spiele, gruppiert nach der Tradition, von der ihr Server abstammt. Das ist {evidence} — {meaning} — und nicht etwas, das ein Spiel veröffentlicht hätte: Kein Spiel meldet „MUSH“, denn MSSP kennt keinen solchen Wert, und der größte Teil der MUSH-Welt veröffentlicht überhaupt kein MSSP. + + + gemessen: {value} + + + angegeben: {value} + + + Ranglisten + + + Ausschließlich aus gemessenen Daten berechnet. Keine Stimmen, Sterne oder Bewertungen, niemals. Nichts hier ordnet nach Qualität. Die haben wir nicht gemessen. + + + Am belebtesten, nach gemessenen gleichzeitigen Spielern + + + Zeitraum der Rangliste + + + {days, plural, one {# Tag} other {# Tage}} + + + Median der Spielerzahlen, die wir in {days, plural, one {dem letzten # Tag} other {den letzten # Tagen}} gemessen haben. + + + Noch kein Spiel hat die {samples, number} Zählungen über {days, plural, one {# Tag} other {# Tage}}, die ein Median braucht. + + + {eligible, plural, one {# von {listed, number} Spielen hat} other {# von {listed, number} Spielen haben}} die nötigen {samples, number} Zählungen über {days, plural, one {# Tag} other {# Tage}}. + + + Eine gemessene Null zählt; eine unlesbare Zählung nicht. + + + Eine Woche sagt, wer jetzt belebt ist; ein Quartal sagt, wer belebt gewesen ist. Das sind verschiedene Fragen, und ein Spiel kann die eine anführen und die andere nicht. Tage sind ganze Tage, in UTC. + + + Noch hat kein eingetragenes Spiel genug Zählungen für eine Rangfolge — eine Aussage darüber, wie lange wir schon messen, nicht darüber, wie belebt irgendjemand ist. + + + Spiele, geordnet nach dem Median der Spielerzahlen, die wir in {days, plural, one {dem letzten # Tag} other {den letzten # Tagen}} gemessen haben. Spiele mit demselben Median teilen sich einen Platz; nichts hier entscheidet den Gleichstand. + + + # + + + Spiel + + + Median + + + Spitze + + + Zählungen + + + gemessene Tage + + + Längste durchgehende Erreichbarkeit + + + Jede Abfrage seit dem genannten Datum hat das Spiel erreichbar vorgefunden. Erreichbar, nicht online: Wir messen einen Socket von einem einzigen Host aus, und ein Spiel, zu dem wir keine Route finden, ist dennoch völlig lebendig. Eine solche Strecke kann nicht länger sein, als wir zusehen, deshalb ist das Datum die Tatsache und die Dauer folgt daraus. + + + Zurzeit ist kein eingetragenes Spiel durchgehend erreichbar. + + + Spiele, bei denen jede Abfrage seit dem genannten Datum sie erreichbar vorgefunden hat. Spiele, die seit demselben Datum erreichbar sind, teilen sich einen Platz; nichts hier entscheidet den Gleichstand. + + + erreichbar seit + + + das sind + + + Archivierte Spiele sind aus beiden Tabellen heraus und aus sonst nichts; eine einzige erfolgreiche Abfrage bringt sie zurück. + + + Am belebtesten — Median der gemessenen Spieler, {days, plural, one {letzter # Tag} other {letzte # Tage}} + + + Zeiträume: + + + dieser + + + Median {median, number} · Spitze {peak, number} · {samples, plural, one {# Zählung} other {# Zählungen}} an {days, number} von {window, number} Tagen + + + bei jeder Abfrage seit {date} erreichbar · {duration} + + + Das Archiv + + + Spiele, die nicht mehr antworten. Nichts wurde gelöscht. Sie werden weiterhin wöchentlich abgefragt, und eine einzige erfolgreiche Abfrage bringt ein Spiel noch am selben Tag zurück ins Verzeichnis. + + + das Archiv durchsuchen + + + Archivierte Spiele suchen + + + Name, Codebase oder Beschreibung + + + zeigen + + + {count, plural, =0 {Keine archivierten Spiele} one {# archiviertes Spiel} other {# archivierte Spiele}} + + + archiviert + + + zuletzt erreichbar + + + nachweislich aktiv + + + (vor {age}) + + + Keine Treffer. + + + nie, in nichts, was wir gemessen haben + + + keine erreichbare Zeit gemessen + + + unbekannt + + + {years, number, ::.#} Jahre + + + {days, plural, one {# Tag} other {# Tage}} + + + {from} – {to} · {span} + + + {count, plural, one {# Monat} other {# Monate}} + + + {count, plural, one {# Jahr} other {# Jahre}} + + + {count, plural, one {# Spiel passt} other {# Spiele passen}} zu „{query}“ + + + {count, plural, one {# Spiel} other {# Spiele}} + + + Zuletzt erreichbar: + + + Nachweislich aktiv: + + + {value} gemessener erreichbarer Zeit + + + Laufzeit: + + + Codebase: + + + zufälliges Spiel + + + {d, date, medium} — durchschnittlich {typical}, {low}–{high} über {probes, plural, one {# Abfrage} other {# Abfragen}} + + + {d, date, medium} — {count, plural, =0 {0 Spieler} one {# Spieler} other {# Spieler}}, in jeder von {probes, plural, one {# Abfrage} other {# Abfragen}} + + + {d, date, medium} — abgefragt, keine Zählung ablesbar + + + {d, date, medium} — keine Messung + + + Typischerweise {typical} verbunden, Spitze {peak}, an {counted} von {days, plural, one {# Tag} other {# Tagen}}. + + + Über den Zeitraum hinweg gleichbleibend. + + + Vom Anfang des Zeitraums bis zum Ende um etwa {change, number, percent} gestiegen. + + + Vom Anfang des Zeitraums bis zum Ende um etwa {change, number, percent} gefallen. + + + In diesem Zeitraum abgefragt, und aus nichts davon war eine Spielerzahl ablesbar. + + + Keine Messung in diesem Zeitraum. + + + In diesem Zeitraum nichts gezählt. + + + {from, date, d MMM}–{to, date, d MMM} + + + {d, date, d MMM} + + + {span}: typischerweise {typical}, Spitze {peak}, {days, plural, one {# Tag} other {# Tage}} gezählt + + + {span}: abgefragt, keine Zählung ablesbar + + + {span}: nicht gemessen + + + {count, plural, one {# Tag ohne Zählung abgefragt} other {# Tage ohne Zählung abgefragt}} + + + {count, plural, one {# Tag nicht gemessen} other {# Tage nicht gemessen}} + + + {line}, {clause} + + + Tage in UTC · {from, date, medium} – {to, date, medium} + + + {value} ganz oben + + + {counted} von {days, plural, one {# Tag} other {# Tagen}} gezählt + + + {d, date, MMM} + + + {d, date, MMM yyyy} + + + Mittel der an dem Tag gelesenen Zählungen + + + bis zur höchsten Zählung des Tages + + + niedrigste bis höchste Zählung des Tages + + + abgefragt, keine Zählung ablesbar + + + nicht gemessen — gar kein Balken + + + nicht gemessen — eine Lücke in der Linie + + + Zeitraum des Verlaufs + + + Form des Verlaufs + + + {days, plural, one {# Tag} other {# Tage}} + + + ← früher + + + später → + + + Linie + + + Balken + + + Wie viele, im Zeitverlauf + + + {from, date, medium} – {to, date, medium}, UTC + + + früher + + + eine Woche wird über die in ihr gezählten Tage zusammengefasst; eine Woche ohne solche sagt das + + + erreichbar · {days, plural, one {letzter # Tag} other {letzte # Tage}} + + + erreichbar + + + längste Unterbrechung + + + letzte Ursache + + + keine im Zeitraum + + + nichts festgehalten + + + {days, plural, one {vor # Tag} other {vor # Tagen}} + + + heute + + + erreichbar + + + eingeschränkt — geantwortet, nicht abgeschlossen + + + nicht erreichbar + + + nicht gemessen + + + erreichbar + + + eingeschränkt + + + nicht erreichbar + + + nicht gemessen + + + {d, date, d MMM} — den ganzen Tag erreichbar + + + {d, date, d MMM} — eingeschränkt ({cause}): geantwortet, nicht abgeschlossen + + + {d, date, d MMM} — nicht erreichbar ({cause}) + + + {d, date, d MMM} — nicht gemessen; wir haben dieses Spiel damals noch nicht beobachtet + + + {days, plural, one {Für den letzten # Tag noch nicht gemessen.} other {Für die letzten # Tage noch nicht gemessen.}} + + + {days, plural, one {Erreichbar in {percent} des letzten Tages.} other {Erreichbar in {percent} der letzten # Tage.}} + + + {days, plural, one {Erreichbar in {percent} des einen Tages, den wir gemessen haben.} other {Erreichbar in {percent} der # Tage, die wir gemessen haben.}} + + + {days, plural, one {Die Erreichbarkeit des letzten Tages ist noch nicht gemessen.} other {Die Erreichbarkeit der letzten # Tage ist noch nicht gemessen.}} + + + An keinem Tag im Zeitraum war das Spiel nicht erreichbar. + + + An keinem von uns gemessenen Tag war das Spiel nicht erreichbar. + + + {count, plural, one {# Tag nicht erreichbar.} other {# Tage nicht erreichbar.}} + + + {count, plural, one {# Tag eingeschränkt — wir sind hineingekommen und konnten nicht abschließen.} other {# Tage eingeschränkt — wir sind hineingekommen und konnten nicht abschließen.}} + + + Längste Unterbrechung {duration}. + + + Längste Unterbrechung {duration} ({cause}). + + + {count, plural, one {# Tag liegt vor allem, was wir gemessen haben.} other {# Tage liegen vor allem, was wir gemessen haben.}} + + + {from, date, d MMM} – {to, date, d MMM} + + + {d, date, d MMM} + + + {range}: {count, plural, one {# Tag} other {# Tage}} {word} + + + {range}: {count, plural, one {# Tag} other {# Tage}} {word} ({cause}) + + + Erreichbar + + + {days, plural, one {letzter # Tag} other {letzte # Tage}} + + + Erreichbar: {percent} {days, plural, one {des letzten Tages} other {der letzten # Tage}} + + + Längste Unterbrechung: {duration} + + + dns hat nicht aufgelöst + + + Verbindung abgelehnt + + + tls fehlgeschlagen + + + Zeitüberschreitung + + + Handshake hängen geblieben + + + keine Ursache festgehalten + + + Der Betreiber hat uns gebeten, den Verbindungsbildschirm dieses Spiels nicht erneut zu veröffentlichen. + + + Von diesem Spiel wurde kein Verbindungsbildschirm aufgezeichnet. + + + {count, plural, one {Nur # Zeile kam zurück — zu wenig zum Anzeigen.} other {Nur # Zeilen kamen zurück — zu wenig zum Anzeigen.}} + + + so, wie der Server ihn gesendet hat + + + {columns}×{rows} + + + {columns}×{rows}, doppelte Breite + + + 16-Farben-SGR + + + keine Farbe + + + gelesen als {charset} + + + aufgezeichnet + + + eingefroren — der letzte Bildschirm, den wir gesehen haben + + + ASCII-Grafik: der Verbindungsbildschirm von {game}. Sein Text steht unten unter „als Text lesen“. + + + ASCII-Grafik: der Verbindungsbildschirm dieses Spiels. Sein Text steht unten unter „als Text lesen“. + + + {count, plural, one {Verbindungsbildschirm: # Zeile, nur Text} other {Verbindungsbildschirm: # Zeilen, nur Text}} + + + {count, plural, one {Verbindungsbildschirm: # Zeile, nur Text, gelesen als {charset}} other {Verbindungsbildschirm: # Zeilen, nur Text, gelesen als {charset}}} + + + das Spiel gibt es an, und der Server hat es in keinem Handshake je angeboten. + + + der Server bietet es an, und die eigene Angabe des Spiels sagt, dass er es nicht tut. + + + Meist ein veraltetes, von Hand getipptes Feld und keine Lüge. Gezeigt, weil ein Client sich auf das, worin beide sich widersprechen, nicht verlassen sollte. + + + Nicht gefunden + + + Kein Spiel unter dieser Adresse. Schreibweise prüfen. + + + zuletzt geantwortet + + + {count, plural, one {# ist über das Aktualisierungsfenster hinaus. Alt, nicht falsch.} other {# sind über das Aktualisierungsfenster hinaus. Alt, nicht falsch.}} + + + Die eigene Verweisliste dieses Spiels nennt: + + + Genannt von der Verweisliste von: + + + seit {date} + + + nicht mehr gelistet, zuletzt gesehen + + + Zuletzt geantwortet am {date}, vor {ago}. + + + Wird weiterhin wöchentlich abgefragt; diese Seite aktualisiert sich an dem Tag, an dem es antwortet. + + + {span} nachweislich aktiv + + + Nicht im Verzeichnis, nicht in den Ranglisten und nicht in der Tageszahl: Wir halten diese Adresse nicht für ein Spiel, das sich spielen lässt. Alles unten ist das, was sie uns mitgeteilt hat, unverändert. + + + Unser Grund: {why} + + + Nicht im Verzeichnis, nicht in den Ranglisten und nicht in der Tageszahl, auf Bitte der Leute, die es betreiben. Alles unten bleibt erhalten, wie es war, diese Seite und jede Adresse, die es je hatte, antworten weiterhin, und wir wählen es nicht an. + + + Jetzt verbunden: {count} + + + Fähigkeiten ({disagreeing} von {total} widersprechen) + + + Vom Spiel angegeben + + + Verbindungsbildschirm + + + Was sich geändert hat + + + gemessen + + + angegeben + + + ** widersprechen + + + Verbindungen + + + erreicht + + + Suche + + + archiviert + + + Erwachseneninhalte + + + einbezogen + + + keine Zählung + + + keine aktuelle Zählung, und nichts hier nennt einen Grund + + + Jetzt verbunden: keine Zählung (nichts hier nennt einen Grund) + + + Crawler aktiv · letzte Abfrage {age} + + + Crawler im Leerlauf · letzte Abfrage {age} + + + hier ist noch keine Abfrage abgeschlossen + + + in diesem Zyklus nichts fällig + + + {considered, plural, one {# fällig} other {# fällig}} · {answered, plural, one {# geantwortet} other {# geantwortet}} · {failed, plural, one {# fehlgeschlagen} other {# fehlgeschlagen}} + + + {targets, plural, one {# Adresse im Register} other {# Adressen im Register}}, {due, plural, one {# jetzt fällig} other {# jetzt fällig}} + + + {page} — {site} + + + Ein Verzeichnis des MU*-Hobbys — MUSHes, MUDs, MUCKs, MOOs — in dem jede Tatsache ihre Herkunft und ihr Alter nennt. + + + Demodaten — nichts davon wurde gemessen. {description} + + + {site} — gemessen, nicht behauptet + + + {title} auf {site} + + + Spiele + + + Das Archiv + + + Ranglisten + + + Das Ökosystem + + + Referenz + + + Über uns + + + Nicht gefunden + + + Zufälliges Spiel + + + Ihre Spiele + + + {game} beanspruchen + + + Jedes MU*, das wir erreicht haben, gefiltert nach Gemessenem: Codebase, Protokolle aus dem Handshake, TLS, Kodierung, Sprache, letzter Zugang. + + + Verstummte Spiele, aufbewahrt. Jedes behält Seite, Verlauf und URL, wird wöchentlich abgefragt und kehrt bei einer erfolgreichen Verbindung zurück. + + + Am belebtesten, am besten erreichbar, am längsten laufend — allein aus Messungen berechnet. Keine Stimmen, Sterne oder Bewertungen auf dieser Website. + + + Codebase-Anteile und Protokollverbreitung über die von uns gemessenen Spiele, das Angebotene neben dem Angegebenen. Anteile, niemals Gesamtzahlen. + + + Von Hand geschriebene Seiten zu den Codebases, Clients und Protokollen des MU*-Hobbys, verlinkt mit Zahlen aus dem Crawl. + + + Wie dieser Katalog entsteht: was der Crawler tut, was er zu tun verweigert und wie er sich stoppen lässt. + + + Kein Spiel unter dieser Adresse. Hier wird nichts gelöscht, ein Spiel, das einmal unter dieser URL lag, liegt also weiterhin dort — Schreibweise prüfen. + + + Ein Spiel aus dem Katalog, zufällig gewählt und nie zweimal dasselbe. + + + Die Einträge, die Sie beansprucht haben, und was ein Anspruch Ihnen zu ändern erlaubt. + + + Weisen Sie nach, dass Sie dieses Spiel betreiben, indem Sie einen Token dort veröffentlichen, wo nur sein Betreiber ihn hinstellen könnte. + + + Archiviert — zuletzt erreichbar {age}, wird weiter abgefragt + + + Archiviert, wird weiter abgefragt + + + Spielerzahl unbekannt — das Spiel antwortet und veröffentlicht keine Zahl, die wir lesen können + + + {count, plural, one {# Spieler} other {# Spieler}}, {how} {age} + + + Neu entdeckt + + + Verstummt + + + Wieder da + + + {count, plural, one {# Spiel bekannt} other {# Spiele bekannt}} + + + {count, plural, one {# jetzt verbunden (gemessen)} other {# jetzt verbunden (gemessen)}} + + + {count, plural, one {# antwortend, ungezählt} other {# antwortend, ungezählt}} + + + {count, plural, one {# archiviert, wird weiter abgefragt} other {# archiviert, werden weiter abgefragt}} + + + Referenz + + + Was die Codebases sind, was die Clients tun und was die Protokolle bedeuten. Von Hand geschrieben und im Repository neben dem Crawler abgelegt — kein Wiki, und auf dieser Seite gibt es nichts zu bearbeiten. Jede {number} hier ist etwas anderes: Sie stammt aus dem Katalog und wird bei jedem Aufruf der Seite neu berechnet. + + + Zahl + + + Von Hand geschrieben, namentlich gezeichnet und in git versioniert. Der Text hier ist unserer; jede Zahl daneben wurde vom Crawler gemessen und wird bei jeder Anfrage neu berechnet. Das ist kein Wiki, und von dieser Seite aus lässt sich nichts bearbeiten. + + + Hier anfangen + + + Codebases + + + Clients + + + Protokolle + + + Orientierung + + + Codebase + + + Client + + + Protokoll + + + Nicht gefunden + + + Hier gibt es keine Referenzseite. Dieser Abschnitt ist von Hand geschrieben; eine Lücke ist also Arbeit, die niemand gemacht hat, und nichts, was entfernt worden wäre — {index}. + + + sehen, was es gibt + + + Spiele, die sie ausführen + + + Wir haben noch keines identifiziert. Das ist eine Tatsache darüber, was dieser Crawler gemessen hat, und keine darüber, was es gibt — ein Spiel, das wir nicht erreicht haben oder dessen Codebase wir nicht lesen konnten, wird hier nicht mitgezählt. + + + {count, plural, one {# eingetragen} other {# eingetragen}} + + + {count, plural, one {# archiviert} other {# archiviert}} + + + gemessen, nie behauptet + + + Bei dieser Anfrage aus dem Katalog gezählt, über denselben Filter, den der Link oben trägt — diese Zahl und jenes Verzeichnis stammen also aus einer einzigen Abfrage und können nicht auseinanderlaufen. + + + in ihren Handshakes angeboten: {protocols} + + + Gemessene Verbreitung + + + Noch nichts gemessen. + + + von {listed, plural, one {# eingetragenen Spiel} other {# eingetragenen Spielen}} — {percent} + + + Die Spiele, die hier nicht mitgezählt sind, sind keine Spiele ohne das Protokoll. Ein Spiel zählt hier, wenn sein Server die Option in einem von uns beobachteten Handshake angeboten hat; der Rest sind Server, die sie uns nicht angeboten haben, und Server, deren Handshake wir nicht gelesen haben, und welches davon zutrifft, können wir Ihnen nicht sagen. + + + Spiele, die {protocol} in einem von uns beobachteten Handshake angeboten haben, nach der Codebase, die wir bei ihnen identifiziert haben. + + + Codebase + + + angeboten + + + identifiziert + + + Siehe auch + + + Fähigkeiten + + + {count, plural, one {# von {total} aus der Dokumentation des Projekts selbst festgestellt} other {# von {total} aus der Dokumentation des Projekts selbst festgestellt}} + + + Aus der Dokumentation des jeweiligen Projekts abgelesen, nicht von uns gemessen — ein Client hat keinen Handshake, den wir beobachten könnten. „{unknown}“ heißt, dass wir nachgesehen und es nicht feststellen konnten. Es heißt nie nein. + + + Fähigkeiten der Clients, jede aus der Dokumentation des Projekts selbst abgelesen. Unbekannt heißt, dass wir sie nicht feststellen konnten, und nie, dass sie dem Client fehlt. + + + dokumentiert + + + Quelle + + + wir haben keine gefunden + + + ja + + + nein + + + unbekannt + + + Läuft auf: {platforms} + + + Spiele, bei denen wir diese Codebase identifiziert haben + + + Noch keines. Das ist eine Aussage darüber, was wir gemessen haben, und keine darüber, was es gibt — ein Spiel, das wir nicht erreicht haben oder dessen Codebase wir nicht lesen konnten, wird hier nicht mitgezählt. + + + {listed, plural, one {# eingetragen} other {# eingetragen}}, {archived, plural, one {# archiviert} other {# archiviert}} + + + In ihren Handshakes gemessen: {protocols} + + + In keinem Handshake, den wir von ihnen gelesen haben, wurde etwas angeboten. + + + {offering} von {listed, plural, one {# eingetragenen Spiel} other {# eingetragenen Spielen}} haben es in einem von uns beobachteten Handshake angeboten ({percent}) + + + Nach Codebase, unter den Spielen, die wir identifiziert haben + + + {offering} von {identified} haben es angeboten + + + {count, plural, one {# von {total} Zeilen ist unbekannt} other {# von {total} Zeilen sind unbekannt}}: Wir haben in der Dokumentation des Projekts selbst nichts gefunden, was das eine oder das andere sagt. Eine kurze ehrliche Tabelle ist besser als eine lange geratene. + + + Ihre Spiele + + + Konten brauchen eine Datenbank, und diese Website läuft auf den Demodaten. + + + Anmelden + + + Angemeldet als {name}. + + + Abmelden + + + Sie haben noch nichts beansprucht. Suchen Sie Ihr Spiel in {listing} und drücken Sie auf seiner Seite {claimControl}. + + + dem Verzeichnis + + + Dieses Spiel beanspruchen + + + Aufgegeben. + + + Der Vermerk darüber bleibt erhalten, und Sie können jederzeit erneut nachweisen, dass Sie das Spiel betreiben, indem Sie einen frischen Token veröffentlichen. + + + Gespeichert. + + + Dieses Spiel + + + Die Seite von {game} zeigt es jetzt als vom Betreiber angegeben. + + + Wir veröffentlichen den Verbindungsbildschirm von {game} nicht mehr erneut. Die Seite sagt das deutlich, statt eine Leerstelle zu lassen. + + + Der Verbindungsbildschirm von {game} steht wieder auf seiner Seite. + + + Wir wählen {game} nicht mehr an, unter keiner Adresse, die wir dafür haben. Seine Seite behält alles, was vor Ihrer Bitte gemessen wurde. + + + Wir wählen {game} wieder an, ab seinem nächsten Termin im Zeitplan. + + + {game} ist aus dem Verzeichnis, aus den Ranglisten und aus der Tageszahl heraus. Seine Seite und jede URL, die es je hatte, antworten weiterhin. + + + {game} steht wieder im Verzeichnis. Eine einzige Abfrage, die eine Antwort erhält, genügt, damit es wieder gemessen wird. + + + {field} wurde nicht geändert. + + + Das sind einzeilige Angaben; mehr als {max} Zeichen speichern wir nicht. + + + Dieses Feld ist gemessen. Ein Anspruch erlaubt Ihnen hinzuzufügen, wofür MSSP keinen Platz hat; er erlaubt niemandem, das zu ändern, was wir beobachtet haben, uns eingeschlossen. + + + Beansprucht + + + bestätigt {date} + + + bestätigt {date}, Token zuletzt gesehen {seen} + + + Ihr MSSP prüfen + + + {count, plural, one {Außerdem beansprucht von {names} — mit einem eigenen bestätigten Token.} other {Außerdem beansprucht von {names} — jeweils mit einem eigenen bestätigten Token.}} + + + ein anderes Konto + + + Ihre Spielerzahl auf Ihrer eigenen Website zeigen + + + Das Abzeichen trägt die Zählung und den Zeitpunkt, an dem wir sie gemessen haben, denn eine Zahl ohne Alter ist genau das, was diese Website ersetzen soll. + + + Es sagt {unknown} statt null, wenn wir nicht zählen konnten, und {archived}, wenn das Spiel nicht mehr antwortet. + + + Es gibt auch {json}, falls Sie lieber selbst zeichnen möchten. + + + Verlauf + + + Token ausgegeben + + + Token erneut ausgegeben + + + bestätigt — wir haben Ihren Token gelesen + + + Token weiterhin veröffentlicht + + + Token diesmal nicht gelesen + + + Anspruch aufgegeben + + + Token ungenutzt abgelaufen + + + ein anderes Konto hat nachgewiesen, dass es das Spiel betreibt, und es übernommen + + + Prüfung angefragt + + + diesen Anspruch aufgeben + + + Tippen Sie {word} zur Bestätigung. Nichts wird gelöscht, und Sie können erneut nachweisen, dass Sie das Spiel betreiben, indem Sie einen frischen Token veröffentlichen; das Spiel bleibt beansprucht, solange es noch jemand anderes beansprucht. + + + {game} aufgeben + + + Warten auf einen Token + + + Token ausgegeben {issued}, gültig bis {expires} + + + Passkeys + + + ohne Namen + + + hinzugefügt {date} + + + hinzugefügt {date} · nur auf einem Gerät + + + Dieser Passkey liegt auf einem einzigen Gerät. Verlieren Sie ihn, kommen Sie weiterhin hinein, indem Sie einen frischen Token in Ihrem Spiel veröffentlichen, aber ein zweiter Passkey geht schneller. + + + Einen weiteren Passkey hinzufügen + + + Kein solches Spiel + + + {game} beanspruchen + + + Das Beanspruchen braucht eine Datenbank, und diese Website läuft auf den Demodaten. + + + Dafür brauchen Sie zuerst ein Konto. Es braucht einen Passkey und einen Namen. + + + Anmelden oder ein Konto anlegen + + + Ihre Spiele + + + {count, plural, one {Dieses Spiel hat bereits einen Betreiber, der den Zugriff auf den Server nachgewiesen hat.} other {Dieses Spiel hat bereits # Betreiber, die den Zugriff auf den Server nachgewiesen haben.}} Sie können ihn ebenfalls nachweisen — die Prüfung ist in beiden Fällen dieselbe —, aber wir müssen wissen, was Sie damit meinen, denn am Token ist es nicht zu erkennen. + + + Ich betreibe es auch — mich als Betreiber eintragen + + + Alle behalten ihren Anspruch. Das sind zwei Menschen, die ein Spiel betreiben. + + + Ich habe es übernommen — auf mich übertragen + + + {count, plural, one {Sobald Ihr Token bestätigt ist, wird der bestehende Anspruch widerrufen und das Spiel gehört Ihnen.} other {Sobald Ihr Token bestätigt ist, werden die bestehenden Ansprüche widerrufen und das Spiel gehört Ihnen.}} In ihrem eigenen Verlauf steht, warum. Nichts wird gelöscht, und sie können erneut nachweisen, dass sie es betreiben, auf demselben Weg, den Sie gerade gehen. + + + Bestätigt. + + + Wir haben Ihren Token am {date} aus dem MSSP-Bericht des Spiels gelesen. + + + Wir haben Ihren Token am {date} vom Verbindungsbildschirm gelesen. + + + Lassen Sie den Token, wo er ist. Er dient zugleich als Erkennungsmerkmal, damit dieses Spiel wiedererkennbar bleibt, wenn es den Host wechselt oder den Namen ändert. Ihn wegzunehmen hebt Ihren Anspruch nicht auf. + + + Veröffentlichen Sie diesen Token irgendwo, wo das Spiel ihn einer anonymen Verbindung zeigt. Die nächste Abfrage liest ihn auf, und damit ist nachgewiesen, dass Sie auf diesen Server schreiben können. + + + Das ist eine Übertragung. + + + {count, plural, one {Sobald wir diesen Token lesen, wird der Anspruch des bisherigen Betreibers auf dieses Spiel widerrufen und es gehört Ihnen.} other {Sobald wir diesen Token lesen, werden die Ansprüche der bisherigen Betreiber auf dieses Spiel widerrufen und es gehört Ihnen.}} + + + Eines von beidem genügt + + + Eine MSSP-Variable + + + In {codebase} ist das eine Zeile in {file}; jede Codebase mit MSSP hat eine Entsprechung. + + + {aliases} werden ebenfalls angenommen. + + + Eine Zeile auf dem Verbindungsbildschirm + + + Irgendwo im Bildschirm, und Farbcodes darum herum sind kein Problem. + + + Dann + + + Wir prüfen im gewöhnlichen Crawl-Zeitplan. Dieser Token ist gültig bis {date}. Kommen Sie jederzeit wieder; nichts davon muss notiert werden. + + + Früher nachsehen + + + Bringt Ihr Spiel an den Anfang der Warteschlange. Wir wählen nach unserem eigenen Zeitplan an, das heißt also früher und nicht jetzt. + + + Gerade eben schon angefragt. Versuchen Sie es in ein paar Minuten wieder — es ist begrenzt, weil es einen echten Server früher anwählt, als wir es getan hätten. + + + Was nur Sie uns über {game} sagen können + + + Das sind die Dinge, für die MSSP kein Feld hat. Sie erscheinen auf der Seite Ihres Spiels als {declared}, mit dem Datum, an dem Sie sie zuletzt bestätigt haben, neben dem, was wir gemessen haben — nie an dessen Stelle. Nichts Gemessenes lässt sich von hier aus ändern, weder von Ihnen noch von uns. + + + angegeben {age}. Leeren Sie dieses Feld, um die Angabe zurückzuziehen — was darin stand, bleibt so oder so festgehalten. + + + Ihre Angaben speichern + + + Was {game} meldet und was Sie stattdessen gezeigt sehen möchten + + + Ihr MSSP ist das, was jeder Crawler liest, und wir zeigen es weiterhin neben allem, was Sie hier eintragen — eine Angabe von Ihnen verdeckt keine Ihres Spiels. + + + Nichts Gemessenes lässt sich von hier aus ändern: keine Spielerzahl, keine Fähigkeit, keine Stunde Erreichbarkeit. + + + Ist eine Zeile unten in Ihrer {file} falsch, dann genügt es, sie dort zu berichtigen — damit stimmt sie überall. + + + Ihr Spiel meldet {value}, bestätigt {age} + + + Ihr Spiel meldet hier nichts + + + Den Namen zu ändern ändert, unter welchem Namen {game} eingetragen ist, und die Adresse seiner Seite. Die alte Adresse funktioniert für immer weiter — jede URL, die dieses Spiel je hatte, leitet auf die aktuelle weiter —, und wenn Sie das Feld leeren, gilt wieder der Name, den Ihr MSSP nennt. + + + Ihr Verbindungsbildschirm + + + Wir veröffentlichen ihn nicht erneut. Die Seite sagt das deutlich, statt eine Leerstelle zu lassen, und der Crawler liest ihn weiterhin — daran erkennen wir Ihr Spiel wieder, wenn es umzieht. + + + Wieder zeigen + + + Wir zeigen ihn, weil Ihr Server ihn an jede anonyme Verbindung sendet. Ist Ihnen das nicht recht, sagen Sie es, und wir hören auf. Wir fragen nicht nach dem Grund. + + + Unseren Verbindungsbildschirm nicht mehr zeigen + + + Gecrawlt werden + + + Wir haben aufgehört. Nichts an {game} wird angewählt, und die Seite behält alles, was vor Ihrer Bitte gemessen wurde — die leeren Stunden nennen keine Ursache, denn {ourFact} ist eine Tatsache über uns und keine Messung an Ihrem Spiel. + + + dass Sie uns um den Stopp gebeten haben + + + Uns wieder crawlen + + + Dieser Ausschluss kam von Ihrem eigenen Server und nicht von hier — {routes}. Damit wir wieder crawlen, hören Sie auf, ihn zu veröffentlichen; wir bekommen das im nächsten Zyklus mit. + + + Ihr MSSP-Bericht veröffentlicht {variable} + + + ein TXT-Eintrag unter {label} bittet uns aufzuhören + + + eine von uns festgehaltene Bitte + + + Bei {stopped} haben wir aufgehört, und {dialling} wählen wir weiterhin an. Das ist meist ein Port, der nach dem Ausschluss hinzugekommen ist. + + + Auch unter jeder Adresse aufhören + + + Wir wählen {game} nach einem Zeitplan an und lesen, was jeder anonymen Verbindung gezeigt wird. Ist Ihnen das nicht recht, sagen Sie es, und wir hören auf — innerhalb eines Zyklus, unter jeder Adresse, die wir von Ihnen haben, und wir fragen nicht nach dem Grund. + + + Nichts bereits Gemessenes wird gelöscht: Ihre Seite behält ihre Geschichte und ihre URL, und eine einzige Abfrage, nachdem Sie das zurückgenommen haben, setzt es wieder in Gang. Sie können es auch ohne uns sagen, in Ihrer eigenen Konfiguration — {mssp} in MSSP oder ein TXT-Eintrag unter {dns} —, und wir befolgen das, ob dieses Spiel hier jemals beansprucht wurde oder nicht. + + + Uns nicht mehr crawlen + + + Eingetragen sein + + + {game} ist aus dem Verzeichnis, aus den Ranglisten und aus der Tageszahl heraus. Seine Seite und jede URL, die es je hatte, antworten weiterhin, und alles, was vor Ihrer Bitte gemessen wurde, steht weiterhin darauf. Es wurde nichts gelöscht; es ist nur kein Ort mehr, an dem man beim Stöbern ankommt. + + + Uns wieder ins Verzeichnis aufnehmen + + + Eine Abfrage, die eine Antwort erhält, tut das ebenfalls. Solange Ihr Ausschluss gilt, wählen wir nicht an, es wird also keine geben — aber an dem Tag, an dem Sie ihn zurücknehmen, kommt die Adresse binnen einer Woche an die Reihe, und die Abfrage, die eine Antwort erhält, bringt Sie zurück. Sie müssen uns kein zweites Mal fragen. + + + Wir wählen Sie nicht mehr an, und Ihre Seite steht weiterhin im Verzeichnis, mit dem, was wir davor gemessen haben. Ist Ihnen das nicht recht, sagen Sie es, und sie kommt heraus — aus dem Verzeichnis, aus den Ranglisten und aus der Tageszahl. + + + Nichts wird gelöscht und nichts geht kaputt: Die Seite antwortet, jede URL, die sie je hatte, leitet weiterhin auf sie, und wer von Ihnen dorthin geschickt wird, sieht sie. Sie ist nur kein Ort mehr, an dem ein Lesender beim Stöbern ankommt. Von hier aus rückgängig zu machen, und ebenso durch jede Abfrage, die nach der Rücknahme Ihres Ausschlusses eine Antwort erhält. + + + Uns auch aus dem Verzeichnis nehmen + + + was wir messen konnten + + + Diese auszublenden nimmt sie aus der Auflistung heraus; es heißt nicht, dass das Spiel leer ist. + + + ungezählt + + + nicht erreichbar + + + aus dieser Auflistung ausgeblendet + + + aus dieser Auflistung ausgeblendet + + + In der linken Spalte steht * für einen Wert, nach dem diese Auflistung gefiltert ist, und - für einen, gegen den gefiltert wird. Beides sind Entscheidungen in der Suche, keine Tatsachen über ein Spiel. + + diff --git a/src/MUI.Web/Resources/Messages.ja.resx b/src/MUI.Web/Resources/Messages.ja.resx new file mode 100644 index 0000000..84f452f --- /dev/null +++ b/src/MUI.Web/Resources/Messages.ja.resx @@ -0,0 +1,2877 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + {count, plural, other {#件のゲーム}} + + + {value}、{count, plural, other {#件のゲーム}}、のみ + + + {value}、{count, plural, other {#件のゲーム}}、除外 + + + {value}、{count, plural, other {#件のゲーム}} + + + {facet}を問わない、{count, plural, other {#件のゲーム}} + + + {count, plural, =0 {ここに掲載しているゲームはなし。} other {#件のゲーム。どの事実にも、その出所が記されている。}} + + + {days, plural, other {#日間実測}} · {probes, plural, other {#回の探査}} + + + {days, plural, other {#日}} · {count, plural, other {#件の計数}} + + + {disagreeing, plural, =0 {{total}件のうち不一致はなし。} other {{total}件のうち#件がゲームの自己申告と不一致。}} + + + {count, plural, =0 {すべての回答に一致するゲームはなし。} other {すべての回答に一致するゲームは#件。}} + + + {listed, plural, other {掲載#件のゲーム}}のうち · {answers, plural, =0 {回答なし} other {回答#件}} + + + {count, plural, other {この#件のゲームを表示}} + + + 「{answer}」を解除 — {count, plural, other {#件のゲーム}} + + + 回答を解除:{question} + + + ゲームを探す + + + すべての回答に一致 + + + {count, plural, other {ゲーム}} + + + 回答をすべて解除 + + + やり直す + + + {count, plural, other {他#件}} + + + 回答済み + + + 一覧全体 + + + そのクエリは拒否された + + + 名前(分かっていれば) + + + 名前、または名前の一部 + + + 名前で検索 + + + 今プレイしている人はいる? + + + 何をプレイしたい? + + + どんな種類のゲーム? + + + どの言語? + + + クライアントに必要なものは? + + + 消息不明のゲームも含める? + + + 問わない + + + ジャンルを問わない + + + 種類を問わない + + + 言語を問わない + + + 問わない + + + いいえ、ライブのゲームのみ + + + はい、それらも表示 + + + 消息不明のゲーム + + + TLS — 暗号化、ハンドシェイクは当サイトで完了 + + + MSSP — サーバーの自己申告 + + + MCCP — 出力の圧縮 + + + MXP — クリック可能なリンク + + + GMCP — 構造化クライアントデータ + + + MSDP — 構造化クライアントデータ + + + CHARSET — エンコーディングのネゴシエーション + + + UTF-8 — 非ラテン文字が表示される + + + TTYPE — クライアントが自分の種類を伝える + + + ATCP — 構造化クライアントデータ + + + MSP — サウンドのトリガー + + + EOR — プロンプトの区切り + + + {token} — ハンドシェイクで実測 + + + 実測 + + + 実測 + + + 実測 + + + 実測 + + + 実測 + + + 自己申告 + + + 自己申告 + + + 自己申告 + + + 自己申告 + + + 導出 + + + 導出 + + + 未計測 + + + 接続数読み取り不可 + + + 到達不可 + + + 読み取り不可 + + + 計数なし + + + ここから + + + アーカイブ済 + + + 申請済 + + + 選べるゲームがない + + + そのフィルターに一致するゲームはない。{listing}か、{archive}。 + + + 一覧全体 + + + アーカイブも含める + + + 接続数 + + + 所有者未申請 + + + 所有者により申請済み + + + 探査は継続中 + + + 通常 + + + ピーク + + + テキストで読む + + + プレーンテキスト + + + 本文へスキップ + + + ASCIIバナー:{game}の接続画面。 + + + カタログ + + + このサイトとアカウント + + + 閲覧 + + + 学ぶ + + + このサイト + + + メニュー + + + ゲーム + + + 探す + + + ランダム + + + アーカイブ + + + リファレンス + + + エコシステム + + + ランキング + + + 概要 + + + 登録 + + + ゲームを登録 + + + サインイン + + + 自分のゲーム + + + テーマ + + + 自動 + + + ライト + + + ダーク + + + デモデータ。 + + + データベースが未設定のため、これはダミーデータ。ここにある値は実測ではない。 + + + すべてのゲーム + + + アーカイブ + + + ゲームの自己申告 + + + 変更点 + + + MU*ホビーのディレクトリ + + + どの事実にも、その出所といつのものかが記されている:当サイトのクローラーが実測したものか、ゲームが自己申告し、そう明記したものか。 + + + 名前・テーマ・コードベース・ホストでゲームを検索 + + + 名前・テーマ・コードベース・ホストで検索 + + + 検索 + + + 既知のゲーム + + + 現在の接続数 + + + 応答あり・読み取り不可 + + + アーカイブ済み + + + 新規発見 + + + 消息不明 — 探査は継続中 + + + 復帰 + + + 新規なし。 + + + 消息不明になったゲームはなし。 + + + 復帰したゲームはなし。探査は続ける。 + + + ライブ + + + ゲーム + + + {order}順 + + + ランダム + + + 接続数 · 到達 + + + ここから + + + 該当なし。 + + + 語を減らすか、フィルターを外す。 + + + フィルターを解除 + + + {codebase}について + + + 到達なし + + + 所有者により申請済み + + + コードベース不明 + + + このゲームのコードベースは特定できなかった + + + ほか{count, plural, other {#件}}:{names} + + + 並び順 + + + 期間 + + + 現在 + + + 通常 + + + ピーク + + + 名前 + + + 到達 + + + 7日 + + + 30日 + + + 90日 + + + ゲームを検索 + + + ゲームを検索 + + + フィルター + + + 表示中 + + + すべて解除 + + + — この絞り込みを解除 + + + 問わない + + + 他のフィルター({count}) + + + {count, plural, other {他#件}} + + + 追加で表示 + + + 既定ではオフ。どちらもゲームへの評価ではない。 + + + アーカイブ済み + + + アダルト + + + アーカイブ済みのゲーム、{shown, select, true {表示} other {非表示}} + + + アダルト指定を自己申告したゲーム、{shown, select, true {表示} other {非表示}} + + + 件数は実測したゲーム数であり、推定値ではない。 + + + バッジと空欄の意味 + + + 空欄は実測の欠落であり、否定ではない。ファセットごとに表記が異なる:特定できず、申告なし、ネゴシエーションなし。 + + + 実測のゼロは計数である。接続数不明はゼロではなく、ゼロとして並べ替えられることもない。 + + + 値が定まらないファセットは最頻の{count}件の値のみを挙げる。残りは検索とURLから到達可能。 + + + 未チェックは未計測の意味 — ゲームに備わっていないという意味ではない。 + + + 活動 + + + 最終確認 + + + 提供プロトコル + + + 暗号化 + + + エンコーディング + + + コードベース + + + バージョン + + + 系統 + + + ファミリー + + + ジャンル + + + 言語 + + + 現在接続あり + + + 今週アクティブ + + + 閑散 — 計数は0を超えず + + + 消息不明 — 1か月間到達なし + + + アーカイブ済み + + + 24時間以内 + + + 7日以内 + + + 30日以内 + + + それ以前 + + + 一度も到達なし + + + ネゴシエーションなし + + + 特定できず + + + 申告なし + + + TLSで接続 + + + {value}以外 + + + ネゴシエーションあり + + + 特定済み + + + 申告あり + + + 実際に観測した + + + ゲームの申告であり、検証はしていない + + + ゲームの申告を分類したもの + + + 名前 + + + 現在の接続数 + + + 最終到達 + + + 通常の接続数 · 7日 + + + 通常の接続数 · 30日 + + + 通常の接続数 · 90日 + + + 同時接続ピーク · 7日 + + + 同時接続ピーク · 30日 + + + 同時接続ピーク · 90日 + + + 行の現在値 + + + 通常 + + + ピーク + + + 接続数不明 + + + 一度も到達なし — 到達が古いのではない + + + 期間内の計数が{minimum}件未満、または皆無 — 通常の接続数がゼロなのではない + + + 期間内に計数を読み取れず — 誰も接続していなかったのではない + + + 中央値 {value} · {days, plural, other {#日}} · {count, plural, other {#件の計数}} + + + 同時最大 {value} · {days, plural, other {#日}} · {count, plural, other {#件の計数}} + + + 接続画面 + + + 時間帯別の接続数 + + + 接続数の推移 + + + 到達可能 + + + 変更点 + + + 機能 + + + ゲームの自己申告 + + + リファラル + + + 所有者未申請 — ここにある情報はすべて実測。 + + + 所有者により申請済み — 以下の実測値は引き続き当サイトのもの。 + + + このゲームを申請する + + + {date}から応答 + + + テキストで読む — {count, plural, other {#行}} + + + 機能 + + + 経過 + + + 提供 + + + 無応答 + + + なし + + + 拒否 + + + 自己申告 + + + 不一致 + + + 不一致の箇所({count}) + + + {day} {time} — {count, plural, =0 {接続数0、実測} other {平均#人}} + + + {day} {time} — 探査したが接続数は読み取れず + + + {day} {time} — この時間は未計測 + + + このゲームの活動はまだ実測していない。 + + + 週のどの時間にも接続数は得られていない。 + + + すべての時間を実測し、どの時間も接続はなかった。 + + + {count, plural, other {{day}の#時間はまだ未計測。}} + + + {count, plural, other {週全体で#時間がまだ未計測。}} + + + {count, plural, other {{day}の#時間は応答したが接続数は読み取れず。}} + + + {count, plural, other {週全体で#時間が応答したが接続数は読み取れず。}} + + + 毎日、{window}が最も多い。 + + + 毎日、{part}の{window}が最も多い。 + + + {days}は{window}が最も多い。 + + + {days}は{part}、{window}が最も多い。 + + + {who}の{window}は常に接続が少ない。 + + + {who}の{part}、{window}は常に接続が少ない。 + + + 毎日 + + + 実測できたすべての日 + + + 平日 + + + {days} + + + 午前 + + + 午後 + + + 夜 + + + 深夜 + + + 午前 + + + 午後 + + + 夜 + + + 深夜 + + + {list}、{next} + + + {first}と{second} + + + 実測がまだ足りない + + + 週のどの時間もまだ未計測。 + + + {count, plural, other {#時間は応答したが接続数は読み取れず。}} + + + 週のすべての曜日に実測が入るとグリッドが表示される。 + + + {days, plural, other {これまでに7日のうち#日を実測。すべての曜日に1時間でも入るとグリッドが表示される。}} + + + {count, plural, other {#時間を実測、いずれも接続なし。}} + + + {count, plural, other {#時間を実測、最多は{day} {time} UTCの{peak}。}} + + + {count, plural, other {他に#時間が応答したが接続数は読み取れず。}} + + + {day} — {facts} + + + {first}、{second} + + + 終日ゼロを実測 + + + {time}にピーク{count} + + + {window}は接続なし + + + どの時間も接続数を読み取れず + + + {count, plural, other {#時間が未計測}} + + + {count, plural, other {#時間は探査したが読み取り不可}} + + + 曜日 + + + 最少 + + + 最多 + + + 時刻 + + + 読み取り不可 + + + 曜日別の接続数(UTC)。{window}。 + + + 時刻はUTC · {window} + + + {weeks, plural, other {#週の移動平均}} + + + 計数あり(実測のゼロを含む) + + + 探査したが接続数は読み取れず + + + その時間は未計測 + + + 人がいる時間帯(UTC) + + + 計数あり + + + 接続でき、数値を読み取れた(実測のゼロを含む) + + + 接続できたが、数値は読み取れなかった + + + その時間の実測がない + + + 言語 + + + 言語を変更 + + + mu*indexについて + + + ここにあるゲームはすべて、接続した機械が実測したもの。どの値にも、出所といつのものかが記されている。このページでは、それが何を証明するのか、何を誤ると分かっているのか、誰のディレクトリを読んでいるのか、そしてクローラーを止める方法を扱う。 + + + ここでの事実とは何か + + + 実測は自己申告に優先し、どちらも表示する。 + + + ゲームのMSSPレポートは、そのゲームが自分自身を説明したもの。telnetハンドシェイクは、当サイトがそのゲームの振る舞いを実際に見たもの。どちらもそのゲームのページに、方法と時期を添えて表示する。両者が食い違う場合は、その不一致を示す。 + + + 接続数は、その出所を明示する。 + + + 接続画面で読み取ったWHOかDOING(当サイトが数えたもの)、あるいはゲーム自身が公開したMSSPのPLAYERSフィールドのいずれか。両者を混ぜることはない。 + + + 読み取れなかった応答は不明であって、ゼロではない。 + + + サーバーはWHOのヘッダーを自由に書き換えるので、ある程度を超えると当サイトのパーサーは読み取れない。それは接続数読み取り不可という、それ自体で一つの状態。実測のゼロ — 接続でき、誰もいなかった — は計数であり、計数として表示する。 + + + 到達可能であって、稼働率ではない。 + + + 当サイトは1台のホストから一定の間隔でソケットを開く。経路が通らないゲームは到達不可であり、それでいて健在。実測していない以上、ここではどのゲームの稼働率も主張しない。 + + + 1時間は、計数あり、読み取り不可、未計測のいずれか。 + + + 活動グリッドには3つの状態がある。3つ目は空欄であり、原因を名指ししない:到達できなかった時間も、一度も探査しなかった時間も、同じ欠落であって、どちらもそのサーバーの停止時間ではない。 + + + 誤ると分かっていること + + + アーカイブまでの猶予は、こちらが見つけた日から数える。 + + + 応答しなくなったゲームは、猶予期間を過ぎると既定の一覧から外れる:猶予は探査した到達可能時間の4分の1で、下限60日、上限365日。1995年から動いているゲームでも、発見した日には下限から始まる。当サイトが来る前の年月を埋めるためのインポートは一切しない。 + + + MSSPのCREATEDはその猶予に算入しない。 + + + 設定ファイルに手で書かれた1行にすぎず、算入すればアーカイブのしきい値を操作できてしまう。自己申告として表示するが、それで得られるものはない。 + + + ゲームを申請すれば上限が得られる。 + + + サーバーへのアクセスを証明すれば、観測してきた期間の長さにかかわらず、猶予は満1年になる。 + + + ここにあるものはすべて、1台のホストが一定間隔で見た結果。 + + + 到達可能時間の割合は、観測した期間に対する割合であって、観測しなかった期間に対するものではない。ここにあるどの図も、残りを埋めることはしない。 + + + 削除は一切しない。 + + + アーカイブはゲームを既定の一覧、ランキング、本日アクティブの数値から外すだけで、それ以上のことはしない。ページ、URL、履歴、アドレスはそのまま残り、探査は続き、1回の探査が成功すれば元に戻る。 + + + このサイトがしないこと + + + 投票も、星も、評価も、おすすめもない。 + + + ランキングは実測データのみから算出する。最も多くクリックを動員できた者で順位が決まるディレクトリが表すのは、その動員活動であってこのホビーではない。先行したディレクトリを滅ぼしたのはそれだ。 + + + フォーラムも、レビューも、ウィキも、コメントも、プレイヤープロフィールもない。 + + + 入門的な解説 — MUSHとは何か、共同ロールプレイにはどのコードベースが向くか — は、サイトの他の部分と同じく、執筆され、署名され、バージョン管理される。 + + + プレイヤー名は一切保存しない。 + + + WHOの応答は、人数とヘッダーの形を得るためにメモリ上で解析する。名前は書き出さない。集計には、ローテーションするソルトを用いたソルト付きハッシュを使う。 + + + 人口の絶対数は公表しない。 + + + コードベース別・プロトコル別のシェアは公開する:実測した集合に対する比率は、計数できないゲームがあっても意味を保つ。「MU*を遊んでいる人は何人か」は保たない。その数字は引用に耐えないから。 + + + クローラーと、その止め方 + + + 探査とは、ログインしない1本の接続のこと。 + + + ソケットを開き、telnetオプションをネゴシエートし、接続画面を読み、オプション70のネゴシエーションでMSSPを要求し、{commands}を送り、切断する。キャラクターも作らず、ログインもせず、相手側では何も変わらない。セッションにはタイムアウトを設けてあり、詰まった探査が接続枠を占有し続けることはない。 + + + CRAWL DELAYが優先する。 + + + MSSPレポートで希望する最小間隔を示したゲームには、そのとおりにする。当サイト自身のスケジュールより双方向に優先する:720時間なら週次ではなく月次。消息不明のゲームも、その長い間隔で永久に試し続ける。復帰したときに自力で一覧に戻るのは、そのおかげ。 + + + 紹介されたアドレスは検証する。信用はしない。 + + + MSSPでは、ゲームが他のゲームを名指しできる。名前はダイヤルする前にすべて解決し、解決先のアドレスがすべてグローバルに到達可能でなければ拒否する。混在した応答は、その対象全体を拒否する。この拒否は当サイトの判断として記録し、ゲームの記録に停止時間として現れることはない。 + + + 接続画面を表示するのは、それが誰にでも送られるものだから。 + + + サーバーは接続画面を、認証なしで、すべての匿名接続に描き出す。当サイトはそれを証拠として表示し、ラベルを付ける。求められれば取り下げる。 + + + 止めろと言われれば止める — 方法は3つ。 + + + MSSPレポートに{variable} 1を公開すれば、それを読んだ探査が最後になる。または{label}.your.hostに「{value}」と書いたTXTレコードを公開する。MSSPへの対応も当サイトのアカウントも要らない。あるいは人に連絡する。3つとも1回のクロールサイクル以内に反映し、日付と読み取った内容とともに記録し、登録フォームにも適用する。 + + + MSSPフィールドはそのリスナーを止め、レコードはホストを止める。 + + + MSSPは応答したポートが公開するものなので、そのポートについてしか語らない — MU*のホスティングでは無関係なゲームが1つのドメインで動いているのが普通であり、一方が隣を黙らせてはならない。TXTレコードは、「{value}=4201」のようにポートを名指ししない限り、すべてのポートを対象とする。そこにポート一覧として読めないものが書かれていればホスト全体を意味するので、「{value}=all」でも通る。 + + + DNSによる方法は、こちらに頼まずに自分で取り消せる唯一の方法。 + + + TXTレコードは、来るなと言ったサーバーに接続しなくても読めるので、ダイヤルのたびに読み直す。削除すれば1週間以内にまたダイヤルする。MSSPフィールドは、止めてくれと言われたその行為をしなければ読み直せない。そのためMSSPによる離脱と書面での要請は、撤回の連絡があるまで有効なまま。離脱したアドレスに対して行うのはそのTXT参照だけ:触れるのはネームサーバーであって、ゲームではない。 + + + 停止は削除ではないし、停止時間でもない。 + + + 離脱したゲームも、ページ、アドレス、そして要請より前に実測したものはすべて残る。止まるのは新しいデータだけ:活動グリッドは時間が増えなくなり、原因は名指ししない。ノックをやめるという判断は当サイトについての事実だから。それは行われなかったクロールの記録として、また誰が要請したかの台帳に記録する。 + + + 停止だけで足りなければ、掲載も取り下げられる。 + + + そのゲームが応答するすべてのアドレスで探査を止めたあと、ダッシュボードにもう一つの選択肢が出る:一覧、ランキング、日次の数値から取り下げること。ページも、これまで持っていたすべてのアドレスも応答し続け、何も削除されない — 閲覧してたどり着ける場所ではなくなるだけ。これには検証済みの申請が要る。あなたのゲームについての判断であり、誰が下したかを記録するから。そして探査がそれを取り消す:離脱を撤回すれば、次に応答が返ったダイヤルで、二度頼まなくても一覧に戻る。 + + + サーバーから何者かと尋ねられたとき、クローラーは自らを{name}と名乗る。 + + + クローラーは自らを{name}と名乗るよう設定されているが、まだそう伝えられない。使っているtelnetライブラリには、クライアント側から端末種別を設定する手段がなく、そちらのログにはそのライブラリの既定値が見える。NEW-ENVIRONもクローラーホストの環境から応答される。どちらもライブラリ側の欠落であり、そこを直すのは当方の仕事。それまでは、探査はその形で見分けられる:1本の接続、ログインなし、読み取り専用の短いコマンド列、そして退出。 + + + クローラー + + + 連絡先 + + + クローラー:{name} + + + 連絡先:{url} + + + — プレースホルダー。このデプロイでは連絡先が設定されていない + + + 連絡先が設定されていないため、上のものはプレースホルダーであり、誰にもつながらない。 + + + ゲーム一覧の出どころ + + + 受け取るのはアドレス。それ以外は何も。 + + + バックフィルが受け取るのはホストとポート。接続数も、到達可能性の履歴も、説明も、各種フィールドも、アドレスがどのサイト由来かの記録も取らない。 + + + それらのサイトが渡せるものより、意図的に少なく。 + + + いくつかは何年分もの日付付き接続数を持っている。それを取り込めば、他人がすでに観測していたゲームのヒートマップが埋まり、このサイトの中心的な主張が他者の探査機に寄りかかることになる。 + + + ゲームの出どころは一つの事実ではない。 + + + 掲載する価値のあるゲームは、これらのディレクトリのいくつもに載っている。だから「インポート元」は、たまたま先に走った取得を指すだけになる。ゲームが存在することは公開情報。どこで読んだかは何も足さず、他人の仕事のうち当サイトのものだと最も言いにくい部分でもある。 + + + 他人のサイトを読むことは、やはり他人のサイトを読むこと。 + + + スクレイプの前に一括エクスポートか文書化されたエンドポイントを求め、まずrobots.txtを読み、スクレイプには厳しくレート制限をかける。管理者の了承が要る情報源は、依頼した旨を人が明言できるまで取得しない。 + + + 読み取り済み — アドレスのみ + + + 未読 — 許諾待ち + + + 1ページ、1リクエスト。各ゲームに接続して読み取った内容を出力するクローラーが公開している。 + + + 同じクローラーによるMSDPの一覧。ほぼMSSP版の部分集合で、そちらでは届かない少数のアドレスのために読んでいる。 + + + カタログ全体を1ページで公開しているので、読むのに要するリクエストは1回。最大のアドレス供給源であり、実測値は一つも取っていない。 + + + インデックスが1ページと、ワールドごとに1ページなので、エクスポートではなくスクレイプになる。2026年7月30日、robots.txtを守り15秒間隔で、先方の143ページを取得した。ただし誰も先方に連絡していなかった。あれは起きてはならなかった。現在の関門は、管理者に依頼したと明言する用意のある人を要求する。 + + + 実装済み、テスト済み、実行はしていない。許諾を除くあらゆる軸でここでは最も優れた情報源だが、誰かが先方に連絡するまで何も取得しない。 + + + ライセンス + + + コードはMIT。 + + + サイトもクローラーもパーサーも、MITライセンスのオープンソース。 + + + データのライセンスは未解決の問題。 + + + コードとは別の判断であり、まだ下していない。以下の条件は、このデプロイの現時点での答えであって、プロジェクトとして定まった立場ではない。競合するディレクトリがカタログ全体を持っていくことはここでは成功条件なので、どう決まるにせよそれを妨げることはない。 + + + コード + + + データ、このデプロイが提供する形での + + + クレジット表記 + + + コード:{licence} + + + データ:{licence} + + + クレジット表記:{credit} + + + (このデプロイが提供する内容。プロジェクト自身の答えはまだ未定。) + + + ゲームを登録 + + + ゲームの所在を教えてほしい。フォームはホストとポートだけ。このサイトのそれ以外はすべて、当サイト自身のクローラーが実測する。 + + + ホスト + + + ポート + + + mud.example.org、または mud.example.org:4201 を貼り付けてポートは空のまま + + + 登録 + + + 登録にはデータベースが必要だが、このサイトはデモ用のダミーデータで動いている。書き込む先のクロール登録簿がないので、黙って何もしないフォームを出すのではなく、フォーム自体を置いていない。 + + + ここにはない + + + アドレスに何が起きるか + + + ダイヤルの前にアドレスを解決し、公開インターネットの外に解決するものは拒否する。それは当サイト自身のソケットについての判断であり、ゲームについての事実ではない。 + + + そのホストを運用している人からクロールしないよう要請されている場合、誰が登録しようとそのアドレスは受け取らない。他人があなたのゲームをこのサイトに戻すことはできない。 + + + 応答があれば、サーバーが自分について語る内容を読み、そのゲーム自身のスケジュールで永久に読み続ける。アドレスを渡すのは一度でいい。 + + + 運用者だと誰かが証明するまで、サイトには何も出ない。申請にはパスキーと、ゲーム自体に公開する1行が要る。 + + + すでに持っているアドレスは既存の項目にまとめられる。二度送っても掲載が二つになることはなく、探査が早まることもない。 + + + そのアドレス + + + 登録簿に入った。 + + + {address}は次のクロールサイクルでダイヤルし、以降はそのゲーム自身のスケジュールで永久に続ける。運用者だと誰かが証明した時点でここに現れる — 同じアドレスでこのフォームに戻れば、そのリンクを渡す。 + + + こちらにはある、所有者未申請。 + + + {address}はすでに実測しているもの。運用者だと誰かが証明するまで、サイトには出ないまま。それがあなたなら、ここが入口。 + + + それはすでにある。 + + + {address}はすでに実測しているゲーム。何も作られず、何も変わっていない。 + + + そのアドレスはすでにある。 + + + {address}はすでに把握している。何も作られず、何も変わっていない。 + + + すでに待機中。 + + + {address}はクロール登録簿にあり、まだ応答がない。もう一度送っても順番は早まらない:対象はそれぞれ自分のスケジュールを保つので、他人のサーバーに対して当サイトを急かすことは誰にもできない。 + + + ダイヤルできるアドレスではない。 + + + ホストにはドットかコロンが要り、ポートは1から65535までの数値。両方の欄を埋めるか、最初の欄に mud.example.org:4201 を貼り付ける。 + + + それにはダイヤルできない。 + + + {address}にこの答えが出る理由は3つ:名前が解決しない、公開インターネットの外に解決する、あるいはそのホストを運用している人から近寄らないよう要請されている。どれなのかは意図的に言わない。見知らぬ相手にそれを答えることは、ネットワークを外側から地図にすることだから。アドレスについては何も記録していない。この判断は当サイトのものであり、当サイトのものとして記録する。 + + + 今はここまで。 + + + このフォームは送信者ごとにレート制限があり、その上限に達した。1時間後にまた来てほしい。失われたものはない — 受け取ったものはすでに登録簿にある。 + + + このゲームを申請する + + + サインイン + + + 自分が運用するゲームを申請するには、パスキーでサインインする。失くすパスワードも、盗まれるパスワードもない。 + + + 申請にはデータベースが必要だが、このサイトはデモ用のダミーデータで動いている。サインインする先がない。 + + + サインインはパスキーで行う。 + + + 秘密鍵はあなたの端末かパスワードマネージャーが持ち、当サイトが持つのは公開鍵の側だけ。パスワードもメールアドレスもない。 + + + パスキーでサインイン + + + このサイトでJavaScriptを必要とする唯一のページ。パスキーはそれなしでは動かない。 + + + アカウントがまだない場合 + + + アカウントが要るのは、自分が運用するゲームを申請するときだけ。名乗る名前を選ぶ — 申請の横に並ぶラベルであって、本名ではない。 + + + 名前 + + + 例:corvid-admin + + + パスキーでアカウントを作成 + + + 保存するもの + + + 選んだ名前。 + + + 登録した各パスキーの公開鍵と、端末が付けた名称。 + + + 申請したゲームと、その時期。 + + + アカウントに紐づくメールアドレスも、パスワードも、IPの記録もない。パスキーをすべて失っても、ゲームに新しい申請トークンを公開すればやり直せる:証明になるのはゲームであって、アカウントではない。 + + + {year}年{month}{day}日 + + + {date} {time} UTC + + + 今 + + + {count, plural, other {#分}} + + + {count, plural, other {#時間}} + + + {count, plural, other {#日}} + + + {count, plural, other {#週}} + + + {count, plural, other {#か月}} + + + {count, plural, other {#年}} + + + たった今 + + + {count, plural, other {#分前}} + + + {count, plural, other {#時間前}} + + + {count, plural, other {#日前}} + + + {count, plural, other {#週前}} + + + {count, plural, other {#か月前}} + + + {count, plural, other {#年前}} + + + たった今 + + + {count, plural, other {#分前}} + + + {count, plural, other {#時間前}} + + + {count, plural, other {#日前}} + + + {count, plural, other {#週前}} + + + {count, plural, other {#か月前}} + + + {count, plural, other {#年前}} + + + {age}、{stamp} + + + 、{stamp} + + + {value} — {source}による{how}、最終確認{date} + + + {value} — {source}による{how}、最終確認{date}(想定の更新期間を過ぎている) + + + ({how}、{age}) + + + ({how}、{age}、更新期間超過) + + + 所有者申告 + + + スタッフ + + + telnetハンドシェイク + + + 所有者 + + + WHO + + + I3 + + + MSSP + + + INFO + + + I3のmudlist + + + 接続画面 + + + エコシステム + + + 示すのはシェアであって、総数ではない。MU*を遊んでいる人数の数値は公表しない:実測したゲームに対する比率は、到達できないゲームがあっても意味を保つが、人数の総計は保たない。 + + + {count, plural, other {掲載ゲーム{value}件}} + + + {count, plural, other {ハンドシェイクを完了したゲーム{value}件}} + + + {count, plural, other {MSSPレポートを保有するゲーム{value}件}} + + + ここで最も古いハンドシェイク:{age}前に確認。 + + + {total, number}件中{count, number}件({fraction, number, ::percent .0}) + + + {total, number}件中{count, number}件 — 実測はまだなし + + + {fraction, number, ::percent .0} + + + コードベース + + + 掲載している{listed, plural, other {#件のゲーム}}のうち、{identified, number}件が動かしているものを伝えてきた。以下のシェアはすべてその{identified, number}件に対するもの。読み取れなかったコードベースは分母から外し、別の何かとして数えることはしない。 + + + コードベースを伝えてきた掲載ゲームはまだない。 + + + {share}は、他のどの掲載ゲームも使っていないコードベースを動かしている — それぞれ1件ずつで、シェアというより名前。上の分母には含まれており、棒グラフからまとめて外してあるだけで、除外はしていない: + + + 系統 + + + 同じゲームを、サーバーが受け継いだ伝統ごとにまとめたもの — コードベースについての当サイトの読み取りであって、ゲームが公開した内容ではない。「MUSH」と報告するゲームはない:MSSPにその値はなく、MUSH界の大半はそもそもMSSPを公開していないので、この問いを立てる方法はこれしかない。 + + + 系統に位置づけているコードベースを動かしている掲載ゲームは、まだない。 + + + {count, plural, other {そのうち#件は、どの系統にも位置づけていないコードベースを動かしている}} — {family}を公開して、そう自ら述べているものもいくつかある。上の分母には含まれており、どのシェアにも入っていない。 + + + プロトコル + + + 以下の実測値はいずれも下限として読むこと。MSSPは名指しで要求するので、そこでの沈黙は一つの答え。それ以外は要求していないので、サーバーが一度も提供しないまま対応していることもありうる。 + + + {instrument}は、以下で唯一、下限ではない行:すべてのサーバーに名指しで要求しているので、提供しなかったゲームは訊かれたうえで断ったことになる。自己申告の数値がない唯一の行でもある。レポートを保有しているゲームは実演によって対応を示しており、そのうえで自ら列挙したゲームを数えても、測れるのは習慣のほうだから。 + + + 保有しているレポートは{reports, number}件で、現在MSSPを提供しているのは{offered, plural, other {#件のゲーム}}:残る{gap, number}件は、こちらが読んだあとに公開をやめたもの。再発行されなくなったからといって、レポートを捨てることはしない。 + + + プロトコルの採用状況。実測は完了したハンドシェイクでサーバーが提供したもの、自己申告はそのMSSPが主張するもの。ゲームの集合が2つなので、分母も2つ。 + + + プロトコル + + + 実測 — {basis}中 + + + 自己申告 — {basis}中 + + + 未計測 — 一度も観測なし + + + {share} · {declined, plural, other {#件のゲーム}}が要求に対して辞退 + + + {share} · {unobserved, plural, other {#件のゲーム}}は提供も要求もなし + + + {share} · {declined, plural, other {#件のゲーム}}が要求に対して辞退 · {unobserved, plural, other {#件のゲーム}}は提供も要求もなし + + + 要求せず — ここにあるレポートのすべてが答え + + + 採用状況の推移 + + + 各点は、その日までに実測していたゲームに対するシェアなので、この線が動く理由は2つある:ゲームが提供内容を変えること、そして実測できるゲームの集合が周りで変わること。採用と言えるのは前者だけ。以下の変化件数は、純粋にゲームが考えを変えた分。 + + + プロトコルごとの実測シェア、古い読み取りから順に + + + 当時 + + + 現在 + + + 未計測 + + + 曲線ではなくスナップショット + + + 現在実測できるもののスナップショット。採用曲線が描くのはゲームが考えを変えた点であり、当サイトは変化が起きたときにそれを記録しているので、十分な数がたまれば曲線を描けるようになる。各ゲームに最初に到達した時点を描いても、測れるのはクロールであってホビーではない。 + + + {count, plural, other {これまでに記録した機能の変化は#件}} — 曲線を描くための材料。 + + + 実測した機能はまだ一つも変化していないので、描くものがない。 + + + 実測は{measured}に対するもの、自己申告は{declared}に対するもの。ゲームの集合が2つなので、分母も2つ。 + + + {listed} · {handshakes} · {mssp}。 + + + この図で最も古いハンドシェイクの最終確認は{age}前。 + + + 同じゲームを、サーバーが受け継いだ伝統ごとにまとめたもの。これは{evidence} — {meaning} — であって、ゲームが公開した内容ではない:「MUSH」と報告するゲームはない。MSSPにその値はなく、MUSH界の大半はそもそもMSSPを公開していないから。 + + + 実測:{value} + + + 自己申告:{value} + + + ランキング + + + 実測データのみから算出。投票も、星も、評価も一切ない。ここでは品質の順位づけはしない。それは実測していない。 + + + 最も賑わうゲーム、実測の同時接続数順 + + + ランキングの期間 + + + {days, plural, other {#日}} + + + 直近{days, plural, other {#日}}に実測した接続数の中央値。 + + + 中央値に必要な、{days, plural, other {#日}}にわたる{samples, number}件のサンプルを満たすゲームは、まだない。 + + + 掲載{listed, number}件のうち{eligible, plural, other {#件のゲーム}}が、必要な、{days, plural, other {#日}}にわたる{samples, number}件のサンプルを満たしている。 + + + 実測のゼロは数に入る。読み取れなかった計数は入らない。 + + + 1週間は今誰が賑わっているかを、四半期はこれまで誰が賑わってきたかを示す。別々の問いであり、一方で首位のゲームが他方でもそうとは限らない。日は暦日、UTC。 + + + 順位づけに足るだけの計数サンプルがある掲載ゲームは、まだない — これは実測を続けてきた期間についての言明であって、誰かの賑わいについてのものではない。 + + + 直近{days, plural, other {#日}}に実測した接続数の中央値でゲームを順位づけたもの。中央値が同じゲームは同順位で、ここでは同点を崩さない。 + + + # + + + ゲーム + + + 中央値 + + + ピーク + + + 計数サンプル + + + 実測日数 + + + 到達可能が途切れなかった最長期間 + + + 示した日付以降、すべての探査でそのゲームは到達可能だった。稼働ではなく到達可能:1台のホストからソケットを実測しているだけで、経路が通らないゲームも健在。この期間が観測してきた期間より長くなることはないので、事実は日付のほうであり、長さはそこから導かれる。 + + + 現在、到達可能が途切れずに続いている掲載ゲームはない。 + + + 示した日付以降のすべての探査で到達可能だったゲーム。到達可能の起点が同じゲームは同順位で、ここでは同点を崩さない。 + + + 到達可能の起点 + + + その長さ + + + アーカイブ済みのゲームは両方の表から外れるだけで、それ以外は何も変わらない。1回の探査が成功すれば戻る。 + + + 最も賑わうゲーム — 実測接続数の中央値、直近{days, plural, other {#日}} + + + 期間: + + + これ + + + 中央値{median, number} · ピーク{peak, number} · {window, number}日のうち{days, number}日で{samples, plural, other {計数サンプル#件}} + + + {date}以降のすべての探査で到達可能 · {duration} + + + アーカイブ + + + 応答しなくなったゲーム。削除はしていない。毎週の探査は続いており、1回の探査が成功すれば、その日のうちに一覧へ戻る。 + + + アーカイブを検索 + + + アーカイブ済みのゲームを検索 + + + 名前・コードベース・説明 + + + 表示 + + + {count, plural, =0 {アーカイブ済みのゲームなし} other {アーカイブ済みのゲーム#件}} + + + アーカイブ済み + + + 最終到達 + + + 稼働確認期間 + + + ({age}前) + + + 該当なし。 + + + 実測した範囲では一度もなし + + + 到達可能時間の実測なし + + + 不明 + + + {years, number, ::.#}年 + + + {days, plural, other {#日}} + + + {from} – {to} · {span} + + + {count, plural, other {#か月}} + + + {count, plural, other {#年}} + + + 「{query}」に一致する{count, plural, other {#件のゲーム}} + + + {count, plural, other {#件のゲーム}} + + + 最終到達: + + + 稼働確認期間: + + + 実測した到達可能時間のうち{value} + + + 稼働期間: + + + コードベース: + + + ランダムなゲーム + + + {d, date, medium} — 平均{typical}、{probes, plural, other {#回の探査}}で{low}〜{high} + + + {d, date, medium} — {count, plural, =0 {接続数0} other {#人}}、{probes, plural, other {#回の探査}}のいずれでも同じ + + + {d, date, medium} — 探査したが接続数は読み取れず + + + {d, date, medium} — 実測なし + + + {days, plural, other {#日}}のうち{counted}日で、通常{typical}、ピークは{peak}。 + + + この範囲を通じて横ばい。 + + + 範囲の始めから終わりまでで約{change, number, percent}の増加。 + + + 範囲の始めから終わりまでで約{change, number, percent}の減少。 + + + この範囲では探査したが、そのどこからも接続数は読み取れず。 + + + この範囲に実測なし。 + + + この範囲に計数なし。 + + + {from, date, d MMM}〜{to, date, d MMM} + + + {d, date, d MMM} + + + {span}:通常{typical}、ピーク{peak}、{days, plural, other {#日}}を計数 + + + {span}:探査したが接続数は読み取れず + + + {span}:未計測 + + + {count, plural, other {#日は探査したが計数なし}} + + + {count, plural, other {#日は未計測}} + + + {line}、{clause} + + + 日付はUTC · {from, date, medium} – {to, date, medium} + + + 上端は{value} + + + {days, plural, other {#日}}のうち{counted}日を計数 + + + {d, date, MMM} + + + {d, date, MMM yyyy} + + + その日に読み取った計数の平均 + + + その日の最多の計数まで + + + その日の最少から最多までの計数 + + + 探査したが接続数は読み取れず + + + 未計測 — 棒そのものなし + + + 未計測 — 線が途切れる + + + 推移の範囲 + + + 推移の形 + + + {days, plural, other {#日}} + + + ← 前の期間 + + + 後の期間 → + + + 折れ線 + + + 棒 + + + 接続数の推移 + + + {from, date, medium} – {to, date, medium}、UTC + + + 前の期間 + + + 週は、その週のうち計数できた日をまとめたもの。1日もない週はそう記す + + + 到達可能 · 直近{days, plural, other {#日}} + + + 到達可能 + + + 最長の途絶 + + + 直近の原因 + + + 期間内になし + + + 記録なし + + + {days, plural, other {#日前}} + + + 今日 + + + 到達可能 + + + 劣化 — 応答はあったが完了せず + + + 到達不可 + + + 未計測 + + + 到達可能 + + + 劣化 + + + 到達不可 + + + 未計測 + + + {d, date, d MMM} — 終日到達可能 + + + {d, date, d MMM} — 劣化({cause}):応答はあったが完了せず + + + {d, date, d MMM} — 到達不可({cause}) + + + {d, date, d MMM} — 未計測。このゲームはまだ観測していなかった + + + {days, plural, other {直近#日はまだ未計測。}} + + + 直近{days, plural, other {#日}}のうち{percent}が到達可能。 + + + 実測した{days, plural, other {#日}}のうち{percent}が到達可能。 + + + {days, plural, other {直近#日の到達可能性は、まだ未計測。}} + + + 期間内に到達不可の日はなし。 + + + 実測した日に到達不可はなし。 + + + {count, plural, other {#日が到達不可。}} + + + {count, plural, other {#日が劣化 — 接続はできたが完了できず。}} + + + 最長の途絶は{duration}。 + + + 最長の途絶は{duration}({cause})。 + + + {count, plural, other {#日は、実測が始まる前。}} + + + {from, date, d MMM} – {to, date, d MMM} + + + {d, date, d MMM} + + + {range}:{count, plural, other {#日}}{word} + + + {range}:{count, plural, other {#日}}{word}({cause}) + + + 到達可能 + + + {days, plural, other {直近#日}} + + + 到達可能:{days, plural, other {直近#日}}のうち{percent} + + + 最長の途絶:{duration} + + + dnsが解決しなかった + + + 接続を拒否された + + + tlsが失敗した + + + タイムアウトした + + + ハンドシェイクが止まった + + + 原因の記録なし + + + このゲームの接続画面を再公開しないよう、所有者から要請があった。 + + + このゲームから接続画面は取得できていない。 + + + {count, plural, other {返ってきたのは#行だけ — 表示するには少なすぎる。}} + + + サーバーが送ったまま + + + {columns}×{rows} + + + {columns}×{rows}、倍幅 + + + 16色SGR + + + 色なし + + + {charset}として読み取り + + + 取得 + + + 凍結 — 最後に見た画面 + + + ASCIIアート:{game}の接続画面。その文字は下の「テキストで読む」にある。 + + + ASCIIアート:このゲームの接続画面。その文字は下の「テキストで読む」にある。 + + + {count, plural, other {接続画面:#行、テキストのみ}} + + + {count, plural, other {接続画面:#行、テキストのみ、{charset}として読み取り}} + + + ゲームは申告しているが、サーバーはハンドシェイクで一度も提供していない。 + + + サーバーは提供しているが、ゲーム自身の記載では非対応となっている。 + + + たいていは手書きのフィールドが古いだけで、嘘ではない。両者が食い違う点にクライアントが依存すべきではないので表示している。 + + + 見つからない + + + このアドレスにゲームはない。綴りを確認。 + + + 最終応答 + + + {count, plural, other {#件が更新期間を過ぎている。古いだけで、誤りではない。}} + + + このゲーム自身のリファラル一覧が挙げているもの: + + + このゲームを挙げているリファラル一覧: + + + {date}から + + + 掲載終了、最終確認 + + + 最終応答は{date}、{ago}前。 + + + 毎週の探査は継続中。応答があれば、その日のうちにこのページが更新される。 + + + 稼働確認期間{span} + + + 一覧、ランキング、日次の数値には含まれない:このアドレスが誰かの遊べるゲームだとは考えていない。以下はすべて、このアドレスが伝えてきた内容そのまま。 + + + 当サイトの理由:{why} + + + 運用している人たちの要請により、一覧、ランキング、日次の数値には含まれない。以下はすべてそのまま保存されており、このページとこれまで持っていたすべてのアドレスは応答し続け、当サイトはダイヤルしていない。 + + + 現在の接続数:{count} + + + 機能({total}件のうち{disagreeing}件が不一致) + + + ゲームの自己申告 + + + 接続画面 + + + 変更点 + + + 実測 + + + 自己申告 + + + ** 不一致 + + + 接続数 + + + 到達 + + + 検索 + + + アーカイブ済み + + + アダルト + + + 含む + + + 計数なし + + + 現在の計数なし、理由もここには記されていない + + + 現在の接続数:計数なし(理由もここには記されていない) + + + クローラー実行中 · 最終探査{age} + + + クローラー待機中 · 最終探査{age} + + + ここではまだ探査が一度も完了していない + + + このサイクルに探査予定なし + + + {considered, plural, other {#件が探査予定}} · {answered, plural, other {#件が応答}} · {failed, plural, other {#件が失敗}} + + + 登録簿に{targets, plural, other {#件のアドレス}}、{due, plural, other {うち#件が現在探査予定}} + + + {page} — {site} + + + MU*ホビーのディレクトリ — MUSH、MUD、MUCK、MOO — どの事実にも、その出所といつのものかが記されている。 + + + デモデータ — ここにある値は実測ではない。{description} + + + {site} — 実測であって、申告ではない + + + {site}の{title} + + + ゲーム + + + アーカイブ + + + ランキング + + + エコシステム + + + リファレンス + + + 概要 + + + 見つからない + + + ランダムなゲーム + + + 自分のゲーム + + + {game}を申請 + + + 到達したすべてのMU*を、実測した内容で絞り込む:コードベース、サーバーがハンドシェイクで提供したプロトコル、TLS、エンコーディング、言語、そして最後に到達した時期。 + + + 消息不明になったゲームを、そのまま保持している。ページも履歴もURLも残り、毎週の探査は続き、1回接続できれば一覧に戻る。 + + + 最も賑わう、最も到達可能、最も長く続く — 実測データのみから算出。投票も、星も、評価も、このサイトのどこにもない。 + + + 実測しているゲーム全体でのコードベースのシェアとプロトコルの採用状況。サーバーが提供するものを、自己申告の内容と並べて示す。示すのはシェアであって、総数ではない。 + + + MU*ホビーのコードベース、クライアント、プロトコルについて、人の手で書いたページ。クロールから得た件数と相互にリンクしている。 + + + このカタログの作り方:クローラーが何をするか、何を拒むか、そしてどう止めるか。 + + + このアドレスにゲームはない。ここでは何も削除しないので、かつてこのURLにあったゲームは今もある — 綴りを確認。 + + + カタログから1件、ランダムに選んだゲーム。同じものが二度出ることはない。 + + + 申請した掲載と、申請によって何を変えられるか。 + + + 運用者にしか置けない場所にトークンを公開して、このゲームを運用していることを証明する。 + + + アーカイブ済み — 最終到達{age}、探査は継続中 + + + アーカイブ済み、探査は継続中 + + + 接続数不明 — ゲームは応答するが、読み取れる数値は公開していない + + + {count, plural, other {#人}}、{how}、{age} + + + 新規発見 + + + 消息不明 + + + 復帰 + + + {count, plural, other {既知のゲーム#件}} + + + {count, plural, other {現在接続中#人(実測)}} + + + {count, plural, other {応答あり・読み取り不可#件}} + + + {count, plural, other {アーカイブ済み#件、探査は継続中}} + + + リファレンス + + + コードベースとは何か、クライアントは何をするか、プロトコルは何を意味するか。人の手で書き、クローラーと同じリポジトリに置いている — ウィキではなく、このページに編集できるものはない。ここにある{number}はどれも別物:カタログから取り、ページを読み込むたびに再計算している。 + + + 数値 + + + 人の手で書き、著者は一人、gitでバージョン管理している。ここにある文章は当サイトのもの。その横にある数値はすべてクローラーが実測し、リクエストのたびに再計算している。ウィキではなく、このページから編集する方法もない。 + + + はじめに + + + コードベース + + + クライアント + + + プロトコル + + + 入門 + + + コードベース + + + クライアント + + + プロトコル + + + 見つからない + + + ここにリファレンスのページはない。この節は人の手で書いているので、空白は誰も手をつけていない仕事であって、削除されたものではない — {index}。 + + + 何があるかを見る + + + これを動かしているゲーム + + + まだ一つも特定していない。これはこのクローラーが実測した内容についての事実であって、存在するものについてのものではない — 到達していないゲームや、コードベースを読み取れなかったゲームは、ここには数えていない。 + + + {count, plural, other {掲載#件}} + + + {count, plural, other {アーカイブ済み#件}} + + + 実測であって、申告ではない + + + このリクエストの時点でカタログから数えたもので、上のリンクが持つのと同じフィルターによる — この数値とその一覧は一つのクエリであり、食い違うことはない。 + + + ハンドシェイクで提供していたもの:{protocols} + + + 実測した採用状況 + + + まだ実測なし。 + + + {listed, plural, other {掲載#件のゲーム}}のうち — {percent} + + + ここに数えていないゲームは、そのプロトコルを持たないゲームということではない。数えるのは、そのサーバーがハンドシェイクでそのオプションを提供するのを観測したとき。残りは、当サイトに提供しなかったサーバーと、ハンドシェイクをまだ読んでいないサーバーであり、どちらなのかは言えない。 + + + ハンドシェイクで{protocol}を提供するのを観測したゲームを、動かしていると特定したコードベース別に示したもの。 + + + コードベース + + + 提供あり + + + 特定済み + + + 関連項目 + + + 機能 + + + {count, plural, other {{total}件のうち#件はプロジェクト自身のドキュメントで確認}} + + + 各プロジェクト自身のドキュメントから読み取ったものであり、当サイトの実測ではない — クライアントには観測できるハンドシェイクがない。「{unknown}」は、調べたが確認できなかったという意味。「いいえ」の意味では決してない。 + + + クライアントの機能。いずれも各プロジェクト自身のドキュメントから読み取ったもの。不明は確認できなかったという意味であって、そのクライアントに備わっていないという意味ではない。 + + + 文書化 + + + 典拠 + + + 見つけられなかった + + + はい + + + いいえ + + + 不明 + + + 動作環境:{platforms} + + + このコードベースを動かしていると特定したゲーム + + + まだない。これは当サイトが実測した内容についての言明であって、存在するものについてのものではない — 到達していないゲームや、コードベースを読み取れなかったゲームは、ここには数えていない。 + + + {listed, plural, other {掲載#件}}、{archived, plural, other {アーカイブ済み#件}} + + + ハンドシェイクで実測:{protocols} + + + これまで読んだどのハンドシェイクでも、何も提供されなかった。 + + + {listed, plural, other {掲載#件のゲーム}}のうち{offering}件で提供を観測({percent}) + + + 特定できたゲームの、コードベース別 + + + {identified}件のうち{offering}件が提供 + + + {count, plural, other {{total}行のうち#行が不明}}:どちらとも述べたプロジェクト自身のドキュメントを見つけられなかった。短くて正直な表のほうが、長くて当て推量の表よりよい。 + + + 自分のゲーム + + + アカウントにはデータベースが必要だが、このサイトはデモ用のダミーデータで動いている。 + + + サインイン + + + {name}としてサインイン中。 + + + サインアウト + + + まだ何も申請していない。{listing}で自分のゲームを見つけ、そのページで{claimControl}を押す。 + + + 一覧 + + + このゲームを申請する + + + 手放した。 + + + その記録は残る。新しいトークンを公開すれば、いつでもまた管理していることを証明できる。 + + + 保存した。 + + + そのゲーム + + + {game}のページに、所有者申告として表示されるようになった。 + + + {game}の接続画面の再公開をやめた。ページには穴を残さず、そのことを明記する。 + + + {game}の接続画面を、またそのページに表示している。 + + + 把握しているすべてのアドレスで、{game}へのダイヤルをやめた。そのページには、要請より前に実測したものがすべて残る。 + + + スケジュールで次の順番が来たときから、{game}へのダイヤルを再開する。 + + + {game}は一覧、ランキング、日次の数値から外れた。そのページと、これまで持っていたすべてのURLは応答し続ける。 + + + {game}は一覧に戻った。また実測されるのに要るのは、応答が返る探査1回だけ。 + + + {field}は変更していない。 + + + ここは1行で答える欄で、保存するのは最大{max}文字まで。 + + + その項目は実測。申請で足せるのはMSSPに収まらない内容であって、観測した内容は誰にも編集できない。当サイト自身にも。 + + + 申請済み + + + {date}に検証 + + + {date}に検証、トークンの最終確認{seen} + + + MSSPを確認する + + + {count, plural, other {{names}も所有者 — それぞれ自分のトークンを検証している。}} + + + 別のアカウント + + + 自分のサイトに接続数を載せる + + + バッジは接続数と、それを実測した時期を併せて示す。古さの分からない数値こそ、このサイトが置き換えるために存在するものだから。 + + + 数えられなかったときはゼロではなく{unknown}と表示し、ゲームが応答しなくなれば{archived}と表示する。 + + + 自分で描きたければ{json}もある。 + + + 履歴 + + + トークン発行 + + + トークン再発行 + + + 検証 — トークンを読み取った + + + トークンは引き続き公開中 + + + 今回はトークンを読み取れず + + + 申請を手放した + + + トークンが未使用のまま期限切れ + + + 別のアカウントが管理していることを証明し、ゲームを引き継いだ + + + 確認を要請 + + + この申請を手放す + + + 確認のため{word}と入力する。何も削除されず、新しいトークンを公開すればまた管理していることを証明できる。他に所有者がいれば、ゲームは申請済みのまま。 + + + {game}を手放す + + + トークン待ち + + + トークン発行{issued}、有効期限{expires} + + + パスキー + + + 名称なし + + + 追加{date} + + + 追加{date} · 端末1台のみ + + + このパスキーは1台の端末にしかない。失くしても、ゲームに新しいトークンを公開すれば入り直せるが、2つ目のパスキーがあるほうが早い。 + + + パスキーをもう1つ追加 + + + そのゲームはない + + + {game}を申請 + + + 申請にはデータベースが必要だが、このサイトはデモ用のダミーデータで動いている。 + + + 先にアカウントが要る。要るのはパスキーと名前だけ。 + + + サインイン、またはアカウントを作成 + + + 自分のゲーム + + + {count, plural, other {このゲームには、サーバーを管理していることを証明した所有者がすでに#人いる。}}こちらも証明できる — 確かめ方はどちらでも同じ — が、それが何を意味するのかは伝えてもらう必要がある。トークンからは判別できないから。 + + + こちらも運用している — 所有者に加えてほしい + + + 全員が自分の申請を保つ。1つのゲームを2人で運用している状態。 + + + 引き継いだ — こちらに移してほしい + + + {count, plural, other {トークンが検証された時点で、既存の申請は取り消され、ゲームはこちらのものになる。}}相手には自分の履歴でその理由が見える。何も削除されず、相手はこれから行うのと同じ方法で、また管理していることを証明できる。 + + + 検証できた。 + + + {date}、そのゲームのMSSPレポートからトークンを読み取った。 + + + {date}、接続画面からトークンを読み取った。 + + + トークンはそのままにしておく。同一性の手がかりも兼ねるので、ホストを移しても名前を変えても、このゲームは見分けがつく。外しても申請が解けるわけではない。 + + + 匿名の接続に見える場所であれば、どこにこのトークンを公開してもよい。次の探査がそれを拾い、それがそのサーバーに書き込めることの証明になる。 + + + これは移管。 + + + {count, plural, other {このトークンを読み取った時点で、このゲームに対する現所有者の申請は取り消され、こちらのものになる。}} + + + どちらか一方でよい + + + MSSPの変数 + + + {codebase}では{file}の1行。MSSPを持つコードベースには、どれも同じものがある。 + + + {aliases}も受け付ける。 + + + 接続画面の1行 + + + 画面内のどこでもよく、周りに色コードがあっても構わない。 + + + そのあと + + + 確認は通常のクロールスケジュールで行う。このトークンの有効期限は{date}。いつ戻ってきてもよく、書き留めておくものは何もない。 + + + 早めに見に行く + + + 自分のゲームを待ち行列の先頭に入れる。ダイヤルは当サイト自身のスケジュールで行うので、今すぐではなく、早まるだけ。 + + + 今要請したばかり。数分おいてからもう一度 — 本来より早く実際のサーバーにダイヤルすることになるので、回数を制限している。 + + + {game}について、運用者にしか伝えられないこと + + + ここにあるのは、MSSPに対応する欄がないもの。自分のゲームのページには、最後に確認した日付を添えて{declared}として、実測した内容と並べて表示する — 実測に代えて表示することは決してない。実測したものは、ここからは誰にも編集できない。運用者にも当サイトにも。 + + + {age}に自己申告。取り下げるにはこの欄を空にする — どちらにせよ、何と書かれていたかの記録は残る。 + + + 自己申告した内容を保存 + + + {game}が報告している内容と、代わりに表示してほしい内容 + + + MSSPはどのクローラーも読むものであり、ここに入れた内容の横に、当サイトはそれを表示し続ける — 運用者の回答が、ゲーム自身の回答を隠すことはない。 + + + 実測したものは、ここからは何も編集できない:接続数も、機能も、到達可能だった1時間も。 + + + 下の行が{file}で誤っているなら、そこで直せばすべての場所で直る。 + + + ゲームの報告は{value}、確認は{age} + + + ゲームはここで何も報告していない + + + 名前を変えると、{game}の掲載名と、そのページのアドレスが変わる。古いアドレスは永久に動き続け — このゲームがこれまで持っていたすべてのURLは現在のURLへ転送される — 欄を空にすれば、名前はMSSPの内容に戻る。 + + + 自分の接続画面 + + + 再公開はしていない。ページには穴を残さず、そのことを明記しており、クローラーは読み続けている — ゲームが移ったときに見分ける手がかりがそれだから。 + + + また表示する + + + 表示しているのは、サーバーがそれをすべての匿名接続に送っているから。表示してほしくなければ、そう言えばやめる。理由は訊かない。 + + + 接続画面の表示をやめてほしい + + + クロールされること + + + 停止した。{game}へは一切ダイヤルしておらず、ページには要請より前に実測したものがすべて残る — 空いた時間は原因を名指ししない。{ourFact}というのは当サイトについての事実であって、そのゲームについての実測ではないから。 + + + 停止を要請された + + + またクロールしてほしい + + + これはここからではなく、サーバー自身から来たもの — {routes}。またクロールされるには、その公開をやめる。次のサイクルでそれを受け取る。 + + + MSSPレポートが{variable}を公開している + + + {label}のTXTレコードが停止を求めている + + + 記録済みの要請 + + + {stopped}では停止し、{dialling}には引き続きダイヤルしている。たいていは離脱のあとに追加されたポート。 + + + すべてのアドレスでも停止してほしい + + + {game}へは一定のスケジュールでダイヤルし、匿名の接続に見えるものを読んでいる。やめてほしければ、そう言えばやめる — 1回のサイクル以内に、把握しているすべてのアドレスで。理由は訊かない。 + + + すでに実測したものは何も削除されない:ページは履歴とURLを保ち、これを撤回したあとの探査1回で再開する。当サイトを通さず、自分の設定で伝えることもできる — MSSPの{mssp}か、{dns}のTXTレコード — このゲームをここで誰かが申請しているかどうかにかかわらず、それに従う。 + + + クロールをやめてほしい + + + 掲載されること + + + {game}は一覧からも、ランキングからも、日次の数値からも外れている。ページと、これまで持っていたすべてのURLは応答し続け、要請より前に実測したものはすべてそこに残っている。何も削除していない。閲覧してたどり着ける場所ではなくなるだけ。 + + + 一覧に戻してほしい + + + 応答が返る探査1回でも同じことが起きる。離脱が有効な間はダイヤルしないので、そうなることはない — ただし撤回した日から1週間以内にそのアドレスの順番が来て、応答が返った探査が一覧に戻す。二度頼む必要はない。 + + + ダイヤルは停止しており、ページはそれ以前に実測した内容とともに一覧に残っている。残したくなければ、そう言えば外す — 一覧からも、ランキングからも、日次の数値からも。 + + + 何も削除されず、何も壊れない:ページは応答し、これまで持っていたすべてのURLは今もそこへ転送され、案内した相手には見える。閲覧してたどり着ける場所ではなくなるだけ。ここから元に戻せるし、離脱を撤回したあとに応答が返った探査でも元に戻る。 + + + 一覧からも外してほしい + + + 実測できたもの + + + 非表示にすると一覧から外れる — そのゲームが無人だという意味ではない。 + + + 接続数読み取り不可 + + + 到達不可 + + + この一覧では非表示 + + + この一覧では非表示 + + + 左の列で、* はこの一覧が絞り込みに使っている値、- は除外に使っている値。どちらもクエリでの選択であって、ゲームについての事実ではない。 + + diff --git a/src/MUI.Web/Resources/Messages.nl.resx b/src/MUI.Web/Resources/Messages.nl.resx new file mode 100644 index 0000000..56549b8 --- /dev/null +++ b/src/MUI.Web/Resources/Messages.nl.resx @@ -0,0 +1,2877 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + {count, plural, one {# spel} other {# spellen}} + + + {value}, {count, plural, one {# spel} other {# spellen}}, alleen + + + {value}, {count, plural, one {# spel} other {# spellen}}, uitgesloten + + + {value}, {count, plural, one {# spel} other {# spellen}} + + + ongeacht {facet}, {count, plural, one {# spel} other {# spellen}} + + + {count, plural, =0 {Hier staan geen spellen.} one {# spel, waarvan elk feit vermeldt hoe het verkregen is.} other {# spellen, waarvan elk feit vermeldt hoe het verkregen is.}} + + + {days, plural, one {# dag} other {# dagen}} gemeten · {probes, plural, one {# peiling} other {# peilingen}} + + + {days, plural, one {#d} other {#d}} · {count, plural, one {# telling} other {# tellingen}} + + + {disagreeing, plural, =0 {Geen van de {total} wijkt af.} one {# van de {total} wijkt af van wat het spel opgeeft.} other {# van de {total} wijken af van wat het spel opgeeft.}} + + + {count, plural, =0 {Geen spel past bij elk antwoord.} one {# spel past bij elk antwoord.} other {# spellen passen bij elk antwoord.}} + + + van {listed, plural, one {# vermeld spel} other {# vermelde spellen}} · {answers, plural, =0 {geen antwoorden gegeven} one {# antwoord gegeven} other {# antwoorden gegeven}} + + + {count, plural, one {Toon het ene spel} other {Toon deze # spellen}} + + + "{answer}" laten vallen — {count, plural, one {# spel} other {# spellen}} + + + antwoord wissen op: {question} + + + Een spel zoeken + + + passend bij alle antwoorden + + + {count, plural, one {spel} other {spellen}} + + + alle antwoorden wissen + + + opnieuw beginnen + + + {count, plural, one {# meer} other {# meer}} + + + gegeven antwoorden + + + de hele lijst + + + die zoekopdracht is geweigerd + + + een naam, als je er een hebt + + + naam, of een deel ervan + + + Zoeken op naam + + + Speelt er nu iemand? + + + Wat wil je spelen? + + + Wat voor spel? + + + In welke taal? + + + Heeft je client iets nodig? + + + Ook stilgevallen spellen meenemen? + + + maakt niet uit + + + elk genre + + + elke soort + + + elke taal + + + maakt niet uit + + + nee, alleen live spellen + + + ja, toon die ook + + + stilgevallen spellen + + + TLS — versleuteld, handshake door ons voltooid + + + MSSP — zelfbeschrijving van de server + + + MCCP — gecomprimeerde uitvoer + + + MXP — klikbare links + + + GMCP — gestructureerde clientgegevens + + + MSDP — gestructureerde clientgegevens + + + CHARSET — onderhandeling over codering + + + UTF-8 — niet-Latijnse tekst wordt weergegeven + + + TTYPE — client meldt zijn type + + + ATCP — gestructureerde clientgegevens + + + MSP — geluidstriggers + + + EOR — promptmarkering + + + {token} — gemeten in de handshake + + + gemeten + + + gemeten + + + gemeten + + + gemeten + + + gemeten + + + opgegeven + + + opgegeven + + + opgegeven + + + opgegeven + + + afgeleid + + + afgeleid + + + niet gemeten + + + ongeteld + + + onbereikbaar + + + niet geteld + + + geen telling + + + van hieruit + + + gearchiveerd + + + geclaimd + + + Niets om uit te kiezen + + + Geen spel past bij dat filter. Probeer {listing}, of {archive}. + + + de hele lijst + + + het archief meenemen + + + verbonden + + + niet geclaimd + + + geclaimd door de eigenaar + + + wordt nog gepeild + + + typisch + + + piek + + + als tekst lezen + + + platte tekst + + + naar de inhoud + + + ASCII-banner: het verbindingsscherm van {game}. + + + Catalogi + + + Deze site en je account + + + bladeren + + + uitleg + + + deze site + + + menu + + + spellen + + + zoeken + + + willekeurig + + + archief + + + naslag + + + ecosysteem + + + ranglijsten + + + over + + + aanmelden + + + een spel aanmelden + + + inloggen + + + je spellen + + + thema + + + automatisch + + + licht + + + donker + + + Demogegevens. + + + Er is geen database geconfigureerd, dus dit zijn testgegevens. Niets hiervan is gemeten. + + + alle spellen + + + archief + + + opgegeven door het spel + + + wat er veranderd is + + + Een gids voor de MU*-hobby + + + Elk feit vermeldt hoe het verkregen is en hoe oud het is: gemeten door onze crawler, of opgegeven door het spel en als zodanig gemarkeerd. + + + Spellen zoeken op naam, thema, codebase of host + + + zoeken op naam, thema, codebase of host + + + zoeken + + + bekende spellen + + + nu verbonden + + + antwoordt, ongeteld + + + gearchiveerd + + + nieuw ontdekt + + + stilgevallen — wordt nog gepeild + + + weer terug + + + Niets nieuws. + + + Er is niets stilgevallen. + + + Er is niets teruggekomen. We blijven kloppen. + + + live + + + Spellen + + + gesorteerd op {order} + + + willekeurig + + + verbonden · bereikt + + + van hieruit + + + Geen treffers. + + + Probeer minder woorden, of laat een filter vallen. + + + filters wissen + + + over {codebase} + + + nooit + + + geclaimd door de eigenaar + + + Onbekende codebase + + + we konden niet vaststellen op welke codebase dit spel draait + + + en {count, plural, one {# meer} other {# meer}}: {names} + + + Volgorde + + + Periode + + + nu + + + typisch + + + piek + + + naam + + + bereikt + + + 7 dagen + + + 30 dagen + + + 90 dagen + + + Spellen zoeken + + + spellen zoeken + + + filters + + + getoond + + + alles wissen + + + — hier niet meer op filteren + + + alle + + + meer filters ({count}) + + + {count, plural, one {# meer} other {# meer}} + + + ook tonen + + + Standaard uit. Geen van beide is een oordeel over het spel. + + + gearchiveerd + + + voor volwassenen + + + gearchiveerde spellen, {shown, select, true {getoond} other {verborgen}} + + + spellen die inhoud voor volwassenen opgeven, {shown, select, true {getoond} other {verborgen}} + + + Aantallen zijn spellen die we gemeten hebben, nooit schattingen. + + + wat de badges en de lege plekken betekenen + + + Een lege plek is een gat in onze meting, geen nee. Elk facet zegt het op zijn eigen manier: niet vastgesteld, niet opgegeven, niets onderhandeld. + + + Een gemeten nul is een telling. Een onbekende telling is geen nul en wordt ook nooit als nul gesorteerd. + + + Open facetten tonen hun {count} meest voorkomende waarden. De rest is bereikbaar via zoeken en via URL. + + + Niet aangevinkt betekent niet gemeten — niet dat het spel het mist. + + + activiteit + + + laatst gezien + + + aangeboden protocollen + + + versleuteld + + + codering + + + codebase + + + versie + + + afstamming + + + familie + + + genre + + + taal + + + nu verbonden + + + actief deze week + + + rustig — nooit boven 0 geteld + + + stil — een maand niet bereikt + + + gearchiveerd + + + in de laatste 24 uur + + + in de laatste 7 dagen + + + in de laatste 30 dagen + + + langer geleden + + + nooit bereikt + + + niets onderhandeld + + + niet vastgesteld + + + niet opgegeven + + + verbonden via TLS + + + niet {value} + + + iets onderhandeld + + + wel vastgesteld + + + wel opgegeven + + + we hebben dit zien gebeuren + + + het spel zegt het, en wij hebben het niet gecontroleerd + + + we hebben gegroepeerd wat het spel ons vertelde + + + naam + + + nu verbonden + + + laatst bereikt + + + typisch verbonden · 7 dagen + + + typisch verbonden · 30 dagen + + + typisch verbonden · 90 dagen + + + meeste tegelijk verbonden · 7 dagen + + + meeste tegelijk verbonden · 30 dagen + + + meeste tegelijk verbonden · 90 dagen + + + nu op de rij + + + typisch + + + piek + + + Onbekende telling + + + nog nooit bereikt — niet dat het lang geleden bereikt is + + + minder dan {minimum} tellingen in de periode, of helemaal geen — geen typische telling van nul + + + niets dat we in de periode konden tellen — niet dat er niemand verbonden was + + + mediaan {value} · {days, plural, one {#d} other {#d}} · {count, plural, one {# telling} other {# tellingen}} + + + meeste {value} tegelijk · {days, plural, one {#d} other {#d}} · {count, plural, one {# telling} other {# tellingen}} + + + Verbindingsscherm + + + Verbindingen per uur + + + Hoeveel, in de loop van de tijd + + + Bereikbaar + + + Wat er veranderd is + + + Mogelijkheden + + + Opgegeven door het spel + + + Verwijzingen + + + Niet geclaimd — alles hier is gemeten. + + + Geclaimd door de eigenaar — de gemeten feiten hieronder blijven van ons. + + + Dit spel claimen + + + antwoordt sinds {date} + + + als tekst lezen — {count, plural, one {# rij} other {# rijen}} + + + mogelijkheid + + + leeftijd + + + aangeboden + + + stil + + + afwezig + + + geweigerd + + + geclaimd + + + wijkt af + + + waar ze afwijken ({count}) + + + {day} {time} — {count, plural, =0 {0 spelers, gemeten} one {gemiddeld # speler} other {gemiddeld # spelers}} + + + {day} {time} — gepeild, geen telling kon worden gelezen + + + {day} {time} — geen meting in dit uur + + + We hebben de activiteit van dit spel nog niet gemeten. + + + Geen enkel uur van de week heeft een spelerstelling opgeleverd. + + + Elk uur gemeten en in geen enkel uur was er iemand verbonden. + + + {count, plural, one {# uur op {day} heeft nog geen meting.} other {# uren op {day} hebben nog geen meting.}} + + + {count, plural, one {# uur in de week heeft nog geen meting.} other {# uren in de week hebben nog geen meting.}} + + + {count, plural, one {# uur op {day} antwoordde maar leverde geen telling op.} other {# uren op {day} antwoordden maar leverden geen telling op.}} + + + {count, plural, one {# uur in de week antwoordde maar leverde geen telling op.} other {# uren in de week antwoordden maar leverden geen telling op.}} + + + Elke dag het drukst, {window}. + + + Elke dag het drukst in de {part}, {window}. + + + Het drukst op {days}, {window}. + + + Het drukst op {days} in de {part}, {window}. + + + Steevast rustig {who}, {window}. + + + Steevast rustig {who} in de {part}, {window}. + + + elke dag + + + elke dag die we konden meten + + + op werkdagen + + + op {days} + + + ochtend + + + middag + + + avond + + + nachtelijke uren + + + ochtenden + + + middagen + + + avonden + + + nachtelijke uren + + + {list}, {next} + + + {first} en {second} + + + nog te weinig metingen + + + Nog geen enkel uur van de week is gemeten. + + + {count, plural, one {# uur antwoordde en leverde geen telling op.} other {# uren antwoordden en leverden geen telling op.}} + + + Het raster verschijnt zodra elke dag van de week er een heeft. + + + {days, plural, one {Tot nu toe op # van de zeven dagen gemeten; het raster verschijnt zodra elke dag een uur bevat.} other {Tot nu toe op # van de zeven dagen gemeten; het raster verschijnt zodra elke dag een uur bevat.}} + + + {count, plural, one {# uur gemeten, met niemand verbonden.} other {# uren gemeten, allemaal met niemand verbonden.}} + + + {count, plural, one {# uur gemeten, het drukst met {peak} op {day} om {time} UTC.} other {# uren gemeten, het drukst met {peak} op {day} om {time} UTC.}} + + + {count, plural, one {Nog # uur antwoordde en leverde geen telling op.} other {Nog # uren antwoordden en leverden geen telling op.}} + + + {day} — {facts} + + + {first}, {second} + + + de hele dag op nul gemeten + + + piek {count} om {time} + + + niemand verbonden {window} + + + in geen enkel uur een telling + + + {count, plural, one {# uur niet gemeten} other {# uren niet gemeten}} + + + {count, plural, one {# uur gepeild maar niet telbaar} other {# uren gepeild maar niet telbaar}} + + + dag + + + rustigst + + + drukst + + + om + + + geen telling + + + Spelers verbonden per dag, in UTC. {window}. + + + tijden in UTC · {window} + + + {weeks, plural, one {voortschrijdend gemiddelde over # week} other {voortschrijdend gemiddelde over # weken}} + + + geteld, inclusief een gemeten nul + + + gepeild, geen telling kon worden gelezen + + + geen meting in dat uur + + + Wanneer er spelers verbonden zijn (UTC) + + + geteld + + + we kwamen binnen en lazen een getal, inclusief een gemeten nul + + + we kwamen binnen en er was geen getal te lezen + + + we hebben geen meting voor dat uur + + + taal + + + taal wijzigen + + + Over mu*index + + + Elk spel hier is gemeten door een machine die er verbinding mee maakte, en elke waarde vermeldt waar hij vandaan komt en wanneer. Deze pagina behandelt wat dat bewijst, wat we fout doen, wiens gidsen we lezen, en hoe de crawler te stoppen is. + + + Wat een feit hier is + + + Gemeten wint van opgegeven, en beide worden getoond. + + + Het MSSP-rapport van een spel is het spel dat zichzelf beschrijft. De telnet-handshake is wat we het hebben zien doen. Beide staan op zijn pagina, gelabeld met hoe en wanneer. Waar ze van elkaar afwijken, tonen we de afwijking. + + + Een spelerstelling vermeldt waar hij vandaan komt. + + + Ofwel een WHO of DOING die bij het verbindingsscherm gelezen is en door ons geteld, ofwel het eigen MSSP PLAYERS-veld van het spel, dat het zelf publiceerde. Nooit samengevoegd. + + + Een antwoord dat we niet kunnen lezen is onbekend, nooit nul. + + + Servers passen hun WHO-koppen naar believen aan, en voorbij een zeker punt kan onze parser er geen meer lezen. Dat is niet telbaar, een toestand op zichzelf. Een gemeten nul — we kwamen binnen, er was niemand — is een telling, en wordt ook als telling weergegeven. + + + Bereikbaar, nooit uptime. + + + We openen met tussenpozen een socket vanaf één host. Een spel waarheen we geen route hebben is onbereikbaar en volkomen springlevend. Niets hier beweert iets over de uptime van een spel, omdat niets hier die gemeten heeft. + + + Een uur is geteld, niet telbaar, of niet gemeten. + + + Het activiteitenraster heeft drie toestanden. De derde is leeg en noemt geen oorzaak: een uur dat we niet konden bereiken en een uur dat we nooit gepeild hebben zijn dezelfde afwezigheid, en geen van beide is downtime van die server. + + + Wat we weten dat we fout doen + + + Het archiefrespijt wordt gemeten vanaf de dag dat we je vonden. + + + Een spel dat stopt met antwoorden verlaat de standaardlijst na zijn respijtperiode: een kwart van de bereikbare tijd die we gepeild hebben, met een ondergrens van 60 dagen en een bovengrens van 365. Een spel dat sinds 1995 draait, begint op de ondergrens op de dag dat we het ontdekken. We importeren niets om de jaren vóór onze komst in te vullen. + + + We laten MSSP CREATED niet meetellen voor dat respijt. + + + Het is één met de hand ingetypte regel in een configuratiebestand, dus meetellen zou de archiefdrempel manipuleerbaar maken. Het wordt als opgave getoond en koopt niets. + + + Een spel claimen levert de bovengrens op. + + + Aantonen dat er servertoegang is, is het volle jaar respijt waard, hoe lang we ook al meekijken. + + + Alles hier is één host die met tussenpozen kijkt. + + + Een percentage bereikbare tijd is een deel van de periode die we waarnamen, nooit van een periode die we niet waarnamen. Geen enkele grafiek hier vult de rest in. + + + Er wordt nooit iets verwijderd. + + + Archiveren haalt een spel uit de standaardlijst, de ranglijsten en het cijfer van vandaag actief, en verder niets. Zijn pagina, URL, geschiedenis en adres blijven bestaan, het wordt nog gepeild, en één geslaagde peiling zet het terug. + + + Wat deze site niet zal doen + + + Geen stemmen, sterren, beoordelingen of aanbevelingen. + + + Ranglijsten worden alleen uit gemeten gegevens berekend. Een gids die gerangschikt is naar wie de meeste klikken kan mobiliseren, beschrijft de campagne en niet de hobby, en daaraan zijn de gevestigde gidsen ten onder gegaan. + + + Geen forums, recensies, wiki’s, reacties of spelersprofielen. + + + Oriënterend materiaal — wat een MUSH is, welke codebase past bij gezamenlijk rollenspel — wordt geschreven, ondertekend en van versies voorzien zoals de rest van de site. + + + Spelersnamen worden nooit bewaard. + + + Een WHO-antwoord wordt in het geheugen ontleed op een telling en de vorm van de kop. De namen worden niet vastgelegd; aggregaten gebruiken een gezouten hash met een roulerende salt. + + + Er wordt geen absoluut spelersaantal gepubliceerd. + + + Aandelen per codebase en per protocol gaan wel de deur uit: een verhouding over de gemeten verzameling overleeft de spellen die we niet kunnen tellen. ‘Hoeveel mensen spelen MU*’ overleeft dat niet, want dat getal zou het niet houden zodra het geciteerd werd. + + + De crawler, en hoe die te stoppen is + + + Een peiling is één verbinding die nooit inlogt. + + + Die opent een socket, onderhandelt over telnet-opties, leest het verbindingsscherm, vraagt om MSSP door optie 70 te onderhandelen, stuurt {commands} en verbreekt de verbinding. Geen personage, geen login, niets veranderd aan de andere kant. Een time-out begrenst de sessie, zodat een vastgelopen peiling geen verbindingsplek bezet kan houden. + + + CRAWL DELAY wint. + + + Een spel dat in zijn MSSP-rapport een gewenste minimale tussenpoos opgeeft, krijgt die, boven ons eigen schema in beide richtingen: 720 uur betekent maandelijks, niet wekelijks. Bij een stilgevallen spel wordt voor altijd op de langere tussenpoos aangeklopt, en zo zet het zichzelf weer in de lijst wanneer het terugkomt. + + + Een doorverwezen adres wordt geverifieerd, nooit vertrouwd. + + + Met MSSP kan een spel andere spellen noemen. Elke naam wordt opgelost voordat er ergens wordt aangeklopt, en geweigerd tenzij elk adres waarnaar hij oplost wereldwijd routeerbaar is. Een gemengd antwoord weigert het hele doel. Onze weigering wordt als de onze vastgelegd en verschijnt nooit als downtime in het dossier van een spel. + + + Verbindingsschermen worden getoond omdat ze naar iedereen gestuurd worden. + + + Een server tekent zijn verbindingsscherm, zonder authenticatie, naar elke anonieme verbinding. We tonen het als bewijs en labelen het. Eén verzoek en het gaat eraf. + + + Zeg stop, en we stoppen — op drie manieren. + + + Publiceer {variable} 1 in je MSSP-rapport, en de peiling die dat leest is de laatste. Of publiceer een TXT-record op {label}.jouw.host met de inhoud ‘{value}’, waarvoor geen MSSP-ondersteuning en geen account hier nodig is. Of schrijf een mens aan. Alle drie worden binnen één crawlcyclus gehonoreerd, vastgelegd met de datum en met wat we lazen, en ook op het aanmeldformulier afgedwongen. + + + Het MSSP-veld stopt die luisterpoort; het record stopt de host. + + + MSSP wordt gepubliceerd door de poort die antwoordde, dus spreekt het voor die poort — bij MU*-hosting draaien routinematig ongerelateerde spellen op één domein, en het ene mag het andere niet het zwijgen opleggen. Een TXT-record geldt voor elke poort tenzij het er een noemt, als ‘{value}=4201’. Alles wat daar staat en wat we niet als poortenlijst kunnen lezen betekent de hele host, dus ‘{value}=all’ werkt. + + + De DNS-route is de enige die je ongedaan kunt maken zonder het ons te vragen. + + + Een TXT-record is leesbaar zonder verbinding te maken met een server die dat verboden heeft, dus we lezen het opnieuw voordat we aankloppen. Verwijder het en we kloppen binnen een week weer aan. Een MSSP-veld kan niet opnieuw gelezen worden zonder precies te doen wat je ons gevraagd hebt te laten, dus afmeldingen via MSSP en schriftelijke verzoeken blijven staan tot je het tegendeel zegt. Die TXT-opzoeking is alles wat een afgemeld adres krijgt: ze raakt je nameserver, nooit je spel. + + + Stoppen is niet verwijderen, en het is geen downtime. + + + Een spel dat zich afmeldt, houdt zijn pagina, zijn adres en alles wat we gemeten hebben voordat het erom vroeg. Alleen nieuwe gegevens stoppen: het activiteitenraster krijgt er geen uren meer bij en noemt geen oorzaak, want ons besluit om te stoppen met kloppen is een feit over ons. Het wordt vastgelegd bij de crawl die niet plaatsvond, en in het register van wie erom vroeg. + + + Als stoppen niet genoeg is, kan de vermelding er ook af. + + + Zodra we gestopt zijn op elk adres waarop je spel antwoordt, biedt je dashboard nog één ding: het uit de lijst, de ranglijsten en het dagcijfer halen. De pagina en elk adres dat het ooit gehad heeft blijven antwoorden, en er wordt niets verwijderd — het houdt op een plek te zijn waar een lezer al bladerend aankomt. Er is een geverifieerde claim voor nodig, want het is een besluit over je spel en we leggen vast wie het genomen heeft. En een peiling maakt het ongedaan: trek je afmelding in, en de eerstvolgende keer dat er aangeklopt wordt en er antwoord komt, sta je weer in de lijst zonder dat je het ons nog eens hoeft te vragen. + + + De crawler noemt zichzelf {name} wanneer een server vraagt wat hij is. + + + De crawler is ingesteld om zichzelf {name} te noemen, maar kan dat nog niet zeggen. Zijn telnet-bibliotheek biedt een client geen manier om het terminaltype in te stellen, dus je logboeken zien de standaardwaarde van die bibliotheek, en NEW-ENVIRON wordt beantwoord vanuit de omgeving van de crawlerhost. Beide zijn gaten in de bibliotheek en aan ons om ze daar te dichten. Tot die tijd: herken een peiling aan de vorm ervan — één verbinding, geen login, een korte set alleen-lezen-commando’s, weg. + + + crawler + + + contact + + + Crawler: {name} + + + Contact: {url} + + + — tijdelijke aanduiding; deze installatie heeft geen contactadres ingesteld + + + Er is geen contactadres ingesteld, dus het adres hierboven is een tijdelijke aanduiding en beantwoordt niemand. + + + Waar de lijst met spellen vandaan komt + + + We nemen adressen over. Verder niets. + + + Een backfill haalt een host en een poort op. Geen spelerstellingen, geen bereikbaarheidsgeschiedenis, geen beschrijvingen, geen velden, en geen aantekening van welke site een adres kwam. + + + Bewust minder dan die sites kunnen geven. + + + Verschillende bewaren jaren aan gedateerde spelerstellingen. Dat importeren zou de heatmaps vullen van de spellen die iemand anders al in de gaten hield, en de centrale claim van deze site laten steunen op de peilingen van een ander. + + + De herkomst van een spel is niet één feit. + + + Elk spel dat het vermelden waard is, staat in meerdere van deze gidsen, dus ‘geïmporteerd uit’ zou noemen welke ophaalronde toevallig het eerst liep. Dat een spel bestaat is openbare informatie; waar we het gelezen hebben voegt niets toe en is het deel van andermans werk waarop wij de minste aanspraak hebben. + + + Andermans site lezen blijft andermans site lezen. + + + We vragen om een bulkexport of een gedocumenteerd endpoint voordat we scrapen, lezen eerst robots.txt, en beperken de snelheid van scrapes streng. Een bron waarvoor toestemming van de beheerder nodig is, wordt niet opgehaald tot een mens kan verklaren dat het gevraagd is. + + + gelezen — alleen adressen + + + niet gelezen — wacht op toestemming + + + Eén pagina, één verzoek. Gepubliceerd door een crawler die met elk spel verbinding maakt en afdrukt wat hij las. + + + De MSDP-lijst van dezelfde crawler. Bijna een deelverzameling van zijn MSSP-tegenhanger, gelezen om de paar adressen die hij wel bereikt en de andere niet. + + + Publiceert zijn hele catalogus op één pagina, dus het lezen ervan kost één verzoek. Onze grootste bron van adressen, en van geen enkele meting. + + + Eén indexpagina en één pagina per wereld, dus een scrape in plaats van een export. Op 30 juli 2026 hebben we 143 van hun pagina’s opgehaald, met vijftien seconden ertussen en met inachtneming van robots.txt, maar voordat iemand hun geschreven had. Dat had niet mogen gebeuren. De poort vraagt nu om een mens die wil verklaren dat de beheerder gevraagd is. + + + Geïmplementeerd, getest, nooit uitgevoerd. De sterkste bron hier op elke as behalve toestemming, en er wordt niets opgehaald tot iemand hun geschreven heeft. + + + Licentie + + + De code is MIT. + + + De site, de crawler en de parsers zijn opensource onder de MIT-licentie. + + + De licentie voor de gegevens is een open vraag. + + + Een besluit los van dat over de code, en nog niet genomen. Lees de voorwaarden hieronder als het huidige antwoord van deze installatie, niet als het vastgelegde standpunt van het project. Dat een concurrerende gids de hele catalogus overneemt geldt hier als succes, dus wat er ook wordt vastgelegd, het zal zo’n gids niet in de weg staan. + + + code + + + gegevens, zoals deze installatie ze aanbiedt + + + vermelden als + + + Code: {licence} + + + Gegevens: {licence} + + + Vermelden als: {credit} + + + (wat deze installatie aanbiedt. Het antwoord van het project zelf ligt nog open.) + + + Een spel aanmelden + + + Vertel ons waar een spel staat. Een host en een poort zijn het hele formulier; al het andere op deze site wordt door onze eigen crawler gemeten. + + + Host + + + Poort + + + mud.example.org, of plak mud.example.org:4201 en laat de poort leeg + + + Aanmelden + + + Aanmelden vereist een database, en deze site draait op de demofixture. Er is geen crawlregister om in te schrijven, dus het formulier ontbreekt in plaats van stilletjes niets te doen. + + + Niet hier + + + Wat er met een adres gebeurt + + + We lossen het adres op voordat we aankloppen, en weigeren alles wat buiten het publieke internet uitkomt. Dat is een besluit over onze eigen socket, nooit een feit over een spel. + + + Als degene die die host draait ons gevraagd heeft er niet te crawlen, nemen we het adres niet aan, door wie het ook wordt aangemeld. Een vreemde kan je spel niet terugzetten op deze site. + + + Als het antwoordt, lezen we wat de server over zichzelf zegt en blijven we dat lezen op zijn eigen schema, voor altijd. Een adres hoeft maar één keer gegeven te worden. + + + Er verschijnt niets op de site voordat iemand bewezen heeft het te draaien. Claimen vereist een passkey en één regel die op het spel zelf gepubliceerd wordt. + + + Een adres dat we al hebben valt samen met de bestaande vermelding. Het twee keer sturen levert geen tweede vermelding op en haalt geen peiling naar voren. + + + dat adres + + + In het register. + + + Bij {address} wordt in de volgende crawlcyclus aangeklopt, daarna op zijn eigen schema, voor altijd. Het verschijnt hier zodra iemand bewijst het te draaien — kom met hetzelfde adres terug naar dit formulier en het geeft je de link. + + + We hebben het, niet geclaimd. + + + {address} is er een die we al meten. Het blijft van de site af tot iemand bewijst het te draaien. Ben jij dat, dan is dit de weg naar binnen. + + + Die hebben we al. + + + {address} is een spel dat we al meten. Er is niets aangemaakt en niets gewijzigd. + + + Dat adres hebben we al. + + + {address} is bij ons al bekend. Er is niets aangemaakt en niets gewijzigd. + + + Staat al te wachten. + + + {address} staat in het crawlregister en heeft nog niet geantwoord. Het opnieuw sturen haalt het niet naar voren: een doel houdt zijn eigen schema aan, dus niemand kan ons opjagen bij andermans server. + + + Geen adres waar we kunnen aankloppen. + + + Een host heeft een punt of een dubbele punt nodig, en een poort is een getal tussen 1 en 65535. Vul beide velden in, of plak mud.example.org:4201 in het eerste. + + + Daar kunnen we niet aankloppen. + + + Drie dingen leveren dit antwoord op voor {address}: de naam wordt misschien niet opgelost, hij komt misschien buiten het publieke internet uit, of degene die die host draait heeft ons misschien gevraagd weg te blijven. We zeggen bewust niet welke, want dat voor een vreemde beantwoorden brengt een netwerk van buitenaf in kaart. Er is niets over het adres vastgelegd; het besluit was het onze en het is als het onze vastgelegd. + + + Genoeg voor nu. + + + Dit formulier is per afzender in snelheid beperkt, en je hebt de grens bereikt. Kom over een uur terug. Er is niets verloren gegaan — alles wat we aangenomen hebben staat al in het register. + + + dit spel claimen + + + Inloggen + + + Log in met een passkey om een spel te claimen dat je zelf draait. Er is geen wachtwoord om kwijt te raken en geen om te stelen. + + + Claimen vereist een database, en deze site draait op de demofixture. Er is niets om op in te loggen. + + + Inloggen gaat met een passkey. + + + Je apparaat of wachtwoordmanager houdt de privésleutel; wij houden alleen de publieke helft. Geen wachtwoord, geen e-mail. + + + Inloggen met een passkey + + + De enige pagina hier die JavaScript nodig heeft. Passkeys werken er niet zonder. + + + Nog geen account? + + + Je hebt er alleen een nodig om een spel te claimen dat je zelf draait. Kies een naam om onder bekend te staan — een label naast je claim, geen echte naam. + + + Naam + + + bijv. corvid-admin + + + Een account aanmaken met een passkey + + + Wat we opslaan + + + De naam die je gekozen hebt. + + + De publieke sleutel van elke passkey die je registreert, en hoe je apparaat die noemde. + + + Welke spellen je geclaimd hebt, en wanneer. + + + Geen e-mailadres, geen wachtwoord, geen IP-log gekoppeld aan je account. Raak je elke passkey kwijt, dan kun je een nieuw claimtoken op je spel publiceren en opnieuw beginnen: het spel is het bewijs, niet het account. + + + {day} {month} {year} + + + {date} {time} UTC + + + nu + + + {count, plural, one {#m} other {#m}} + + + {count, plural, one {#u} other {#u}} + + + {count, plural, one {#d} other {#d}} + + + {count, plural, one {#w} other {#w}} + + + {count, plural, one {#mnd} other {#mnd}} + + + {count, plural, one {#j} other {#j}} + + + zojuist + + + {count, plural, one {#m geleden} other {#m geleden}} + + + {count, plural, one {#u geleden} other {#u geleden}} + + + {count, plural, one {#d geleden} other {#d geleden}} + + + {count, plural, one {#w geleden} other {#w geleden}} + + + {count, plural, one {#mnd geleden} other {#mnd geleden}} + + + {count, plural, one {#j geleden} other {#j geleden}} + + + zojuist + + + {count, plural, one {#m geleden} other {#m geleden}} + + + {count, plural, one {#u geleden} other {#u geleden}} + + + {count, plural, one {#d geleden} other {#d geleden}} + + + {count, plural, one {#w geleden} other {#w geleden}} + + + {count, plural, one {#mnd geleden} other {#mnd geleden}} + + + {count, plural, one {#j geleden} other {#j geleden}} + + + {age}, {stamp} + + + , {stamp} + + + {value} — {how} via {source}, laatst bevestigd {date} + + + {value} — {how} via {source}, laatst bevestigd {date} (voorbij de verwachte verversing) + + + ({how}, {age}) + + + ({how}, {age}, verouderd) + + + opgegeven door de eigenaar + + + de redactie + + + de telnet-handshake + + + de eigenaar + + + WHO + + + I3 + + + MSSP + + + INFO + + + de I3-mudlist + + + het verbindingsscherm + + + Het ecosysteem + + + Aandelen, nooit totalen. We publiceren geen cijfer voor hoeveel mensen MU* spelen: een verhouding over de spellen die we gemeten hebben overleeft de spellen die we niet kunnen bereiken, en een koppentelling niet. + + + {count, plural, one {{value} spel vermeld} other {{value} spellen vermeld}} + + + {count, plural, one {{value} spel waarvan we de handshake voltooiden} other {{value} spellen waarvan we de handshake voltooiden}} + + + {count, plural, one {{value} spel waarvan we het MSSP-rapport hebben} other {{value} spellen waarvan we het MSSP-rapport hebben}} + + + Oudste handshake hier: {age} geleden bevestigd. + + + {count, number} van {total, number} ({fraction, number, ::percent .0}) + + + {count, number} van {total, number} — nog niets gemeten + + + {fraction, number, ::percent .0} + + + Codebases + + + Van de {listed, plural, one {# vermelde spel} other {# vermelde spellen}} hebben er {identified, number} ons verteld waarop ze draaien, en elk aandeel hieronder gaat over die {identified, number}. Een codebase die we niet konden lezen valt buiten de noemer, en wordt nooit als iets anders geteld. + + + Nog geen vermeld spel heeft ons zijn codebase verteld. + + + {share} draait op een codebase die geen enkel ander vermeld spel draait — één spel per stuk, wat een naam is en geen aandeel. Ze vallen binnen de noemer hierboven en zijn uit de balken gevouwen, niet weggelaten: + + + Afstammingen + + + Dezelfde spellen, gegroepeerd naar de traditie waar hun server van afstamt — onze lezing van de codebase, niet iets wat een spel gepubliceerd heeft. Geen enkel spel meldt ‘MUSH’: MSSP kent die waarde niet, en het grootste deel van de MUSH-wereld publiceert helemaal geen MSSP, dus alleen zo kan de vraag gesteld worden. + + + Nog geen vermeld spel draait op een codebase die we in een afstamming plaatsen. + + + {count, plural, one {# van die spellen draait op een codebase} other {# van die spellen draaien op codebases}} die we in geen enkele afstamming plaatsen — verschillende zeggen dat zelf ook, door {family} te publiceren. Ze vallen binnen de noemer hierboven en in niemands aandeel. + + + Protocollen + + + Lees elk gemeten cijfer hieronder als een ondergrens. We vragen MSSP met name aan, dus stilte daar is een antwoord. Om de rest hier wordt niet gevraagd, en een server kan een protocol ondersteunen zonder het ooit aan te bieden. + + + {instrument} is de enige rij hieronder die geen ondergrens is: we vragen elke server er met name om, dus de spellen die het niet aanboden zijn gevraagd en hebben geweigerd. Het is ook de enige zonder opgegeven cijfer, want elk spel waarvan we het rapport hebben ondersteunt het bij wijze van bewijs, en een telling van de spellen die het ook vermeldden zou daartegen een gewoonte meten. + + + We hebben {reports, number} rapporten en {offered, plural, one {# spel biedt} other {# spellen bieden}} MSSP vandaag aan: de andere {gap, number} zijn gestopt met publiceren nadat we het gelezen hadden, en een rapport wordt niet weggegooid omdat het niet meer opnieuw uitgegeven wordt. + + + Protocoladoptie. Gemeten is wat een server aanbood in een voltooide handshake; opgegeven is wat zijn MSSP beweert. Twee verzamelingen spellen, dus twee noemers. + + + protocol + + + gemeten — van {basis} + + + opgegeven — van {basis} + + + niet gemeten — nooit waargenomen + + + {share} · {declined, plural, one {# spel weigerde toen we het vroegen} other {# spellen weigerden toen we het vroegen}} + + + {share} · {unobserved, plural, one {bij # spel niet aangeboden en niet gevraagd} other {bij # spellen niet aangeboden en niet gevraagd}} + + + {share} · {declined, plural, one {# spel weigerde toen we het vroegen} other {# spellen weigerden toen we het vroegen}} · {unobserved, plural, one {bij # spel niet aangeboden en niet gevraagd} other {bij # spellen niet aangeboden en niet gevraagd}} + + + niet gevraagd — elk rapport hier is het antwoord + + + Adoptie, in de loop van de tijd + + + Elk punt is een aandeel over de spellen die we die dag gemeten hadden, dus deze lijn beweegt om twee redenen: doordat een spel verandert wat het aanbiedt, en doordat de verzameling spellen die we kunnen meten eromheen verandert. Alleen het eerste is adoptie. De telling van veranderingen hieronder is het deel dat puur spellen zijn die van gedachten veranderen. + + + Gemeten aandeel van elk protocol, oudste meting eerst + + + toen + + + nu + + + niet gemeten + + + Een momentopname, geen curve + + + Een momentopname van wat we nu kunnen meten. Een adoptiecurve zet spellen uit die van gedachten veranderen, en we leggen een verandering vast wanneer die gebeurt, dus de curve wordt tekenbaar zodra er genoeg vastgelegd zijn. Uitzetten wanneer we elk spel voor het eerst bereikten zou de crawl meten, niet de hobby. + + + {count, plural, one {# wijziging in mogelijkheden} other {# wijzigingen in mogelijkheden}} tot nu toe vastgelegd — het materiaal waaruit een curve getekend wordt. + + + Er is nog geen gemeten mogelijkheid veranderd, dus er valt niets uit te zetten. + + + Gemeten is van {measured}; opgegeven is van {declared}. Twee verzamelingen spellen, dus twee noemers. + + + {listed} · {handshakes} · {mssp}. + + + De oudste handshake in dit beeld is {age} geleden voor het laatst bevestigd. + + + Dezelfde spellen, gegroepeerd naar de traditie waar hun server van afstamt. Dit is {evidence} — {meaning} — en niet iets wat een spel gepubliceerd heeft: geen enkel spel meldt ‘MUSH’, want MSSP kent die waarde niet en het grootste deel van de MUSH-wereld publiceert helemaal geen MSSP. + + + gemeten: {value} + + + opgegeven: {value} + + + Ranglijsten + + + Alleen berekend uit gemeten gegevens. Nooit stemmen, sterren of beoordelingen. Niets hier rangschikt kwaliteit. Die hebben we niet gemeten. + + + Drukst, naar gemeten gelijktijdige spelers + + + Ranglijstperiode + + + {days, plural, one {# dag} other {# dagen}} + + + Mediaan van de spelerstellingen die we over de laatste {days, plural, one {# dag} other {# dagen}} gemeten hebben. + + + Nog geen spel heeft de {samples, number} tellingen over {days, plural, one {# dag} other {# dagen}} die een mediaan nodig heeft. + + + Van de {listed, number} spellen {eligible, plural, one {heeft # spel} other {hebben # spellen}} de {samples, number} tellingen over {days, plural, one {# dag} other {# dagen}} die daarvoor nodig zijn. + + + Een gemeten nul telt mee; een onleesbare telling niet. + + + Een week zegt wie er nu druk is; een kwartaal zegt wie druk is geweest. Dat zijn verschillende vragen, en een spel kan de ene aanvoeren en de andere niet. Dagen zijn hele dagen, UTC. + + + Nog geen vermeld spel heeft genoeg tellingen om te rangschikken — een uitspraak over hoe lang we al meten, niet over hoe druk het ergens is. + + + Spellen gerangschikt naar de mediaan van de spelerstellingen die we over de laatste {days, plural, one {# dag} other {# dagen}} gemeten hebben. Spellen met dezelfde mediaan delen een plaats; niets hier breekt de gelijkstand. + + + # + + + spel + + + mediaan + + + piek + + + tellingen + + + gemeten dagen + + + Langste ononderbroken bereikbaarheid + + + Elke peiling sinds de genoemde datum trof het spel bereikbaar aan. Bereikbaar, niet ‘up’: we meten een socket vanaf één host, en een spel waarheen we geen route hebben is volkomen springlevend. Zo’n reeks kan niet langer zijn dan we al meekijken, dus de datum is het feit en de duur volgt daaruit. + + + Geen vermeld spel zit op dit moment in een ononderbroken reeks bereikbaarheid. + + + Spellen die bij elke peiling sinds de genoemde datum bereikbaar waren. Spellen die sinds dezelfde datum bereikbaar zijn delen een plaats; niets hier breekt de gelijkstand. + + + bereikbaar sinds + + + dat is + + + Gearchiveerde spellen staan buiten beide tabellen en verder niets; één geslaagde peiling zet ze terug. + + + Drukst — mediaan gemeten spelers, laatste {days, plural, one {# dag} other {# dagen}} + + + periodes: + + + deze + + + mediaan {median, number} · piek {peak, number} · {samples, plural, one {# telling} other {# tellingen}} over {days, number} van {window, number} dagen + + + bereikbaar bij elke peiling sinds {date} · {duration} + + + Het archief + + + Spellen die gestopt zijn met antwoorden. Er is niets verwijderd. Ze worden nog wekelijks gepeild, en één geslaagde peiling zet een spel diezelfde dag terug in de lijst. + + + het archief doorzoeken + + + Gearchiveerde spellen zoeken + + + naam, codebase of beschrijving + + + tonen + + + {count, plural, =0 {Geen gearchiveerde spellen} one {# gearchiveerd spel} other {# gearchiveerde spellen}} + + + gearchiveerd + + + laatst bereikbaar + + + bekend live + + + ({age} geleden) + + + Geen treffers. + + + nooit, in niets wat we gemeten hebben + + + geen bereikbare tijd gemeten + + + onbekend + + + {years, number, ::.#} jaar + + + {days, plural, one {# dag} other {# dagen}} + + + {from} – {to} · {span} + + + {count, plural, one {# maand} other {# maanden}} + + + {count, plural, one {# jaar} other {# jaar}} + + + {count, plural, one {# spel dat overeenkomt met ‘{query}’} other {# spellen die overeenkomen met ‘{query}’}} + + + {count, plural, one {# spel} other {# spellen}} + + + Laatst bereikbaar: + + + Bekend live: + + + {value} van de gemeten bereikbare tijd + + + Looptijd: + + + Codebase: + + + willekeurig spel + + + {d, date, medium} — gemiddeld {typical}, {low}–{high} over {probes, plural, one {# peiling} other {# peilingen}} + + + {d, date, medium} — {count, plural, =0 {0 spelers} one {# speler} other {# spelers}}, bij elk van {probes, plural, one {# peiling} other {# peilingen}} + + + {d, date, medium} — gepeild, geen telling kon worden gelezen + + + {d, date, medium} — geen meting + + + Typisch {typical} verbonden, met een piek van {peak}, over {counted} van {days, plural, one {# dag} other {# dagen}}. + + + Gelijkmatig over het hele bereik. + + + Ongeveer {change, number, percent} omhoog van het begin van het bereik tot het eind. + + + Ongeveer {change, number, percent} omlaag van het begin van het bereik tot het eind. + + + In dit bereik gepeild, en uit niets ervan kon een spelerstelling worden gelezen. + + + Geen meting in dit bereik. + + + Niets geteld in dit bereik. + + + {from, date, d MMM}–{to, date, d MMM} + + + {d, date, d MMM} + + + {span}: typisch {typical}, piek {peak}, {days, plural, one {# dag} other {# dagen}} geteld + + + {span}: gepeild, geen telling kon worden gelezen + + + {span}: niet gemeten + + + {count, plural, one {# dag gepeild zonder telling} other {# dagen gepeild zonder telling}} + + + {count, plural, one {# dag niet gemeten} other {# dagen niet gemeten}} + + + {line}, {clause} + + + dagen in UTC · {from, date, medium} – {to, date, medium} + + + {value} bovenaan + + + {counted} van {days, plural, one {# dag} other {# dagen}} geteld + + + {d, date, MMM} + + + {d, date, MMM yyyy} + + + gemiddelde van de tellingen die we die dag lazen + + + tot aan de drukste telling van die dag + + + laagste tot hoogste telling van die dag + + + gepeild, geen telling kon worden gelezen + + + niet gemeten — helemaal geen balk + + + niet gemeten — een onderbreking in de lijn + + + Trendbereik + + + Trendvorm + + + {days, plural, one {# dag} other {# dagen}} + + + ← eerder + + + later → + + + lijn + + + staven + + + Hoeveel, in de loop van de tijd + + + {from, date, medium} – {to, date, medium}, UTC + + + eerder + + + een week wordt samengevat over de dagen erin die we geteld hebben; een week zonder zegt dat + + + bereikbaar · laatste {days, plural, one {# dag} other {# dagen}} + + + bereikbaar + + + langste onderbreking + + + laatste oorzaak + + + geen in de periode + + + niets vastgelegd + + + {days, plural, one {# dag geleden} other {# dagen geleden}} + + + vandaag + + + bereikbaar + + + haperend — antwoordde, kon niet afronden + + + onbereikbaar + + + niet gemeten + + + bereikbaar + + + haperend + + + onbereikbaar + + + niet gemeten + + + {d, date, d MMM} — de hele dag bereikbaar + + + {d, date, d MMM} — haperend ({cause}): antwoordde, kon niet afronden + + + {d, date, d MMM} — onbereikbaar ({cause}) + + + {d, date, d MMM} — niet gemeten; we volgden dit spel toen nog niet + + + {days, plural, one {Nog niet gemeten over de laatste # dag.} other {Nog niet gemeten over de laatste # dagen.}} + + + Bereikbaar {percent} van de laatste {days, plural, one {# dag} other {# dagen}}. + + + Bereikbaar {percent} van de {days, plural, one {# dag} other {# dagen}} die we gemeten hebben. + + + {days, plural, one {De bereikbaarheid over de laatste # dag is nog niet gemeten.} other {De bereikbaarheid over de laatste # dagen is nog niet gemeten.}} + + + Geen enkele dag in de periode was onbereikbaar. + + + Geen enkele dag die we gemeten hebben was onbereikbaar. + + + {count, plural, one {# dag onbereikbaar.} other {# dagen onbereikbaar.}} + + + {count, plural, one {# dag haperend — we kwamen binnen en konden niet afronden.} other {# dagen haperend — we kwamen binnen en konden niet afronden.}} + + + Langste onderbreking {duration}. + + + Langste onderbreking {duration} ({cause}). + + + {count, plural, one {# dag ligt vóór alles wat we gemeten hebben.} other {# dagen liggen vóór alles wat we gemeten hebben.}} + + + {from, date, d MMM} – {to, date, d MMM} + + + {d, date, d MMM} + + + {range}: {count, plural, one {# dag} other {# dagen}} {word} + + + {range}: {count, plural, one {# dag} other {# dagen}} {word} ({cause}) + + + Bereikbaar + + + {days, plural, one {laatste # dag} other {laatste # dagen}} + + + Bereikbaar: {percent} van de {days, plural, one {laatste # dag} other {laatste # dagen}} + + + Langste onderbreking: {duration} + + + dns loste niet op + + + verbinding geweigerd + + + tls mislukt + + + time-out + + + handshake liep vast + + + geen oorzaak vastgelegd + + + De eigenaar heeft ons gevraagd het verbindingsscherm van dit spel niet opnieuw te publiceren. + + + Van dit spel is geen verbindingsscherm vastgelegd. + + + {count, plural, one {Er kwam maar # rij terug — te weinig om te tonen.} other {Er kwamen maar # rijen terug — te weinig om te tonen.}} + + + zoals verzonden door de server + + + {columns}×{rows} + + + {columns}×{rows}, dubbele breedte + + + 16-kleuren SGR + + + geen kleur + + + gelezen als {charset} + + + vastgelegd + + + bevroren — het laatste scherm dat we zagen + + + ASCII-kunst: het verbindingsscherm van {game}. De tekst ervan staat onder ‘als tekst lezen’, hieronder. + + + ASCII-kunst: het verbindingsscherm van dit spel. De tekst ervan staat onder ‘als tekst lezen’, hieronder. + + + {count, plural, one {verbindingsscherm: # regel, alleen tekst} other {verbindingsscherm: # regels, alleen tekst}} + + + {count, plural, one {verbindingsscherm: # regel, alleen tekst, gelezen als {charset}} other {verbindingsscherm: # regels, alleen tekst, gelezen als {charset}}} + + + het spel geeft het op, en de server heeft het nooit aangeboden in een handshake. + + + de server biedt het aan, en het spel zegt in zijn eigen opgave van niet. + + + Meestal een verouderd, met de hand ingetypt veld, geen leugen. Getoond omdat een client niet moet vertrouwen op datgene waarover de twee van elkaar afwijken. + + + Niet gevonden + + + Geen spel op dit adres. Controleer de spelling. + + + laatst geantwoord + + + {count, plural, one {# is voorbij zijn verversingstermijn. Oud, niet fout.} other {# zijn voorbij hun verversingstermijn. Oud, niet fout.}} + + + De eigen verwijzingslijst van dit spel noemt: + + + Genoemd door de verwijzingslijst van: + + + sinds {date} + + + niet meer vermeld, laatst gezien + + + Laatst geantwoord op {date}, {ago} geleden. + + + Wordt nog wekelijks gepeild; deze pagina wordt bijgewerkt op de dag dat het antwoordt. + + + {span} bekend live + + + Niet in de lijst, de ranglijsten of het dagcijfer: we denken niet dat dit adres een spel is dat iemand kan spelen. Alles hieronder is wat het ons vertelde, ongewijzigd. + + + Onze reden: {why} + + + Niet in de lijst, de ranglijsten of het dagcijfer, op verzoek van de mensen die het draaien. Alles hieronder is bewaard zoals het was, deze pagina en elk adres dat het ooit gehad heeft blijven antwoorden, en we kloppen er niet aan. + + + Spelers nu: {count} + + + Mogelijkheden ({disagreeing} van {total} wijken af) + + + Opgegeven door het spel + + + Verbindingsscherm + + + Wat er veranderd is + + + gemeten + + + opgegeven + + + ** wijkt af + + + verbonden + + + bereikt + + + zoeken + + + gearchiveerd + + + voor volwassenen + + + opgenomen + + + geen telling + + + geen actuele telling, en niets hier zegt waarom + + + Spelers nu: geen telling (niets hier zegt waarom) + + + crawler actief · laatste peiling {age} + + + crawler wacht · laatste peiling {age} + + + hier is nog geen peiling afgerond + + + niets aan de beurt deze cyclus + + + {considered, plural, one {# aan de beurt} other {# aan de beurt}} · {answered, plural, one {# beantwoord} other {# beantwoord}} · {failed, plural, one {# mislukt} other {# mislukt}} + + + {targets, plural, one {# adres in het register} other {# adressen in het register}}, {due, plural, one {# nu aan de beurt} other {# nu aan de beurt}} + + + {page} — {site} + + + Een gids voor de MU*-hobby — MUSHes, MUDs, MUCKs, MOOs — waar elk feit vermeldt hoe het verkregen is en hoe oud het is. + + + Demogegevens — niets hiervan is gemeten. {description} + + + {site} — gemeten, niet beweerd + + + {title} op {site} + + + Spellen + + + Het archief + + + Ranglijsten + + + Het ecosysteem + + + Naslag + + + Over + + + Niet gevonden + + + Willekeurig spel + + + Je spellen + + + {game} claimen + + + Elke MU* die we bereikt hebben, gefacetteerd op onze metingen: codebase, protocollen uit de handshake, TLS, codering, taal, laatst gezien. + + + Stilgevallen spellen, bewaard. Elk houdt zijn pagina, geschiedenis en URL, wordt wekelijks gepeild en keert terug in de lijst bij één geslaagde verbinding. + + + Drukst, best bereikbaar, langst draaiend — alleen berekend uit metingen. Nergens op deze site stemmen, sterren of beoordelingen. + + + Aandeel per codebase en protocoladoptie over de spellen die we meten, met wat servers aanbieden naast wat ze opgeven. Aandelen, nooit totalen. + + + Met de hand geschreven pagina’s over de codebases, clients en protocollen van de MU*-hobby, met verwijzingen naar tellingen uit de crawl. + + + Hoe deze catalogus gebouwd wordt: wat de crawler doet, wat hij weigert te doen, en hoe hij te stoppen is. + + + Geen spel op dit adres. Hier wordt nooit iets verwijderd, dus een spel dat ooit op deze URL stond, staat er nog — controleer de spelling. + + + Eén spel uit de catalogus, willekeurig gekozen en nooit twee keer hetzelfde. + + + De vermeldingen die je geclaimd hebt, en wat je met een claim kunt wijzigen. + + + Bewijs dat je dit spel draait door een token te publiceren waar alleen wie het draait het kan neerzetten. + + + Gearchiveerd — laatst bereikbaar {age}, en wordt nog gepeild + + + Gearchiveerd, en wordt nog gepeild + + + Spelerstelling onbekend — het spel antwoordt en publiceert geen getal dat wij kunnen lezen + + + {count, plural, one {# speler} other {# spelers}}, {how} {age} + + + Nieuw ontdekt + + + Stilgevallen + + + Weer terug + + + {count, plural, one {# bekend spel} other {# bekende spellen}} + + + {count, plural, one {# nu verbonden (gemeten)} other {# nu verbonden (gemeten)}} + + + {count, plural, one {# antwoordt, ongeteld} other {# antwoorden, ongeteld}} + + + {count, plural, one {# gearchiveerd, wordt nog gepeild} other {# gearchiveerd, worden nog gepeild}} + + + Naslag + + + Wat de codebases zijn, wat de clients doen, en wat de protocollen betekenen. Met de hand geschreven en in de repository naast de crawler bewaard — geen wiki, en er valt op deze pagina niets te bewerken. Elk {number} hier is iets anders: het komt uit de catalogus en wordt opnieuw berekend telkens als de pagina geladen wordt. + + + getal + + + Met de hand geschreven, door één auteur, en van versies voorzien in git. De tekst hier is van ons; elk getal ernaast is door de crawler gemeten en wordt bij elk verzoek opnieuw berekend. Dit is geen wiki, en vanaf deze pagina is er niets te bewerken. + + + Begin hier + + + Codebases + + + Clients + + + Protocollen + + + oriëntatie + + + codebase + + + client + + + protocol + + + Niet gevonden + + + Hier staat geen naslagpagina. Dit onderdeel is met de hand geschreven, dus een gat is werk dat niemand gedaan heeft en niet iets wat verwijderd is — {index}. + + + kijk wat er wel is + + + Spellen die erop draaien + + + We hebben er nog geen vastgesteld. Dat is een feit over wat deze crawler gemeten heeft, niet over wat er bestaat — een spel dat we niet bereikt hebben, of waarvan we de codebase niet konden lezen, telt hier niet mee. + + + {count, plural, one {# vermeld} other {# vermeld}} + + + {count, plural, one {# gearchiveerd} other {# gearchiveerd}} + + + gemeten, nooit beweerd + + + Geteld uit de catalogus bij dit verzoek, over hetzelfde filter als de link hierboven — dus dit getal en die lijst zijn één zoekopdracht en kunnen niet uit elkaar lopen. + + + aangeboden in hun handshakes: {protocols} + + + Gemeten adoptie + + + Nog niets gemeten. + + + van {listed, plural, one {# vermeld spel} other {# vermelde spellen}} — {percent} + + + De spellen die hier niet meetellen zijn geen spellen zonder het protocol. Een spel telt mee wanneer we zijn server de optie in een handshake hebben zien aanbieden; de rest zijn servers die het ons niet aangeboden hebben en servers waarvan we de handshake niet gelezen hebben, en welke van beide het is kunnen we niet zeggen. + + + Spellen die we {protocol} in een handshake hebben zien aanbieden, naar de codebase waarvan we vaststelden dat ze erop draaien. + + + codebase + + + bood het aan + + + vastgesteld + + + Zie ook + + + Mogelijkheden + + + {count, plural, one {# van de {total} vastgesteld uit de eigen documentatie van het project} other {# van de {total} vastgesteld uit de eigen documentatie van het project}} + + + Afgelezen uit de eigen documentatie van elk project, niet door ons gemeten — een client heeft geen handshake die wij kunnen waarnemen. ‘{unknown}’ betekent dat we gekeken hebben en het niet vastgesteld hebben. Het betekent nooit nee. + + + Mogelijkheden van clients, elk afgelezen uit de eigen documentatie van het project. Onbekend betekent dat we het niet vastgesteld hebben, en nooit dat de client het mist. + + + gedocumenteerd + + + bron + + + we hebben er geen gevonden + + + ja + + + nee + + + onbekend + + + Draait op: {platforms} + + + Spellen waarvan we vastgesteld hebben dat ze op deze codebase draaien + + + Nog geen. Dat is een uitspraak over wat we gemeten hebben, niet over wat er bestaat — een spel dat we niet bereikt hebben, of waarvan we de codebase niet konden lezen, telt hier niet mee. + + + {listed, plural, one {# vermeld} other {# vermeld}}, {archived, plural, one {# gearchiveerd} other {# gearchiveerd}} + + + Gemeten in hun handshakes: {protocols} + + + In geen enkele handshake die we van hen gelezen hebben, is er iets aangeboden. + + + {offering} van {listed, plural, one {# vermeld spel} other {# vermelde spellen}} hebben we het zien aanbieden ({percent}) + + + Naar codebase, van de spellen die we hebben vastgesteld + + + {offering} van {identified} boden het aan + + + {count, plural, one {# van de {total} rijen is onbekend} other {# van de {total} rijen zijn onbekend}}: we hebben in de eigen documentatie van het project niets gevonden dat het een of het ander zegt. Een korte eerlijke tabel is beter dan een lange geraden. + + + Je spellen + + + Accounts vereisen een database, en deze site draait op de demofixture. + + + Inloggen + + + Ingelogd als {name}. + + + Uitloggen + + + Je hebt nog niets geclaimd. Zoek je spel op in {listing} en druk op zijn pagina op {claimControl}. + + + de lijst + + + dit spel claimen + + + Afstand gedaan. + + + Het blijft vastgelegd, en je kunt altijd opnieuw aantonen dat je de controle hebt door een nieuw token te publiceren. + + + Opgeslagen. + + + Dat spel + + + De pagina van {game} toont het nu als opgegeven door de eigenaar. + + + We publiceren het verbindingsscherm van {game} niet meer opnieuw. De pagina zegt dat met zoveel woorden in plaats van een gat te laten. + + + Het verbindingsscherm van {game} staat weer op zijn pagina. + + + We kloppen niet meer aan bij {game}, op geen enkel adres dat we ervan hebben. Zijn pagina houdt alles wat vóór je verzoek gemeten is. + + + We kloppen weer aan bij {game}, vanaf zijn volgende beurt in het schema. + + + {game} staat niet meer in de lijst, de ranglijsten en het dagcijfer. Zijn pagina en elk adres dat het ooit gehad heeft blijven antwoorden. + + + {game} staat weer in de lijst. Eén peiling die antwoord krijgt is genoeg om het weer te meten. + + + {field} is niet gewijzigd. + + + Dit zijn antwoorden van één regel; {max} tekens is het meeste dat we opslaan. + + + Dat veld is gemeten. Met een claim kun je toevoegen waar MSSP geen ruimte voor heeft; hij laat niemand wijzigen wat wij waargenomen hebben, ons ook niet. + + + Geclaimd + + + geverifieerd {date} + + + geverifieerd {date}, token laatst gezien {seen} + + + je MSSP controleren + + + {count, plural, one {Ook eigendom van {names} — die een eigen token geverifieerd heeft.} other {Ook eigendom van {names} — die elk een eigen token geverifieerd hebben.}} + + + een ander account + + + je spelerstelling op je eigen site zetten + + + De badge draagt de telling én wanneer we die gemeten hebben, want een getal zonder ouderdom is precies wat deze site wil vervangen. + + + Hij zegt {unknown} in plaats van nul wanneer we niet konden tellen, en {archived} als het spel stopt met antwoorden. + + + Er is ook {json}, als je hem liever zelf tekent. + + + geschiedenis + + + token uitgegeven + + + token opnieuw uitgegeven + + + geverifieerd — we hebben je token gelezen + + + token nog steeds gepubliceerd + + + token deze keer niet gelezen + + + afstand gedaan van de claim + + + token ongebruikt verlopen + + + een ander account heeft de controle aangetoond en het spel overgenomen + + + controle aangevraagd + + + afstand doen van deze claim + + + Typ {word} om te bevestigen. Er wordt niets verwijderd en je kunt opnieuw aantonen dat je de controle hebt door een nieuw token te publiceren; het spel blijft geclaimd als iemand anders het ook in eigendom heeft. + + + Afstand doen van {game} + + + Wachten op een token + + + token uitgegeven {issued}, geldig tot {expires} + + + Passkeys + + + naamloos + + + toegevoegd {date} + + + toegevoegd {date} · slechts op één apparaat + + + Deze passkey staat op één apparaat. Raak je hem kwijt, dan kom je er nog steeds in door een nieuw token op je spel te publiceren, maar met een tweede passkey gaat het sneller. + + + Nog een passkey toevoegen + + + Zo’n spel is er niet + + + {game} claimen + + + Claimen vereist een database, en deze site draait op de demofixture. + + + Je hebt eerst een account nodig. Daar zijn een passkey en een naam voor nodig. + + + Inloggen of een account aanmaken + + + Je spellen + + + {count, plural, one {Dit spel heeft al een eigenaar die de controle over de server heeft aangetoond.} other {Dit spel heeft al # eigenaren die de controle over de server hebben aangetoond.}} Jij kunt dat ook aantonen — de toets is in beide gevallen dezelfde — maar we moeten weten wat je ermee bedoelt, want aan het token kunnen we dat niet zien. + + + Ik draai het ook — voeg me toe als eigenaar + + + Iedereen houdt zijn claim. Dit zijn twee mensen die één spel draaien. + + + Ik heb het overgenomen — draag het aan mij over + + + {count, plural, one {Zodra je token geverifieerd is, wordt de bestaande claim ingetrokken en is het spel van jou.} other {Zodra je token geverifieerd is, worden de bestaande claims ingetrokken en is het spel van jou.}} Zij zien in hun eigen geschiedenis waarom. Er wordt niets verwijderd, en zij kunnen op dezelfde manier als jij nu opnieuw de controle aantonen. + + + Geverifieerd. + + + We hebben je token op {date} uit het MSSP-rapport van het spel gelezen. + + + We hebben je token op {date} van het verbindingsscherm gelezen. + + + Laat het token staan waar het staat. Het dient ook als identiteitssignaal, zodat dit spel herkenbaar blijft als het van host of van naam verandert. Het weghalen maakt je claim niet ongedaan. + + + Publiceer dit token overal waar het spel het aan een anonieme verbinding laat zien. De volgende peiling pikt het op, en daarmee is aangetoond dat je naar die server kunt schrijven. + + + Dit is een overdracht. + + + {count, plural, one {Zodra we dit token lezen, wordt de claim van de huidige eigenaar op dit spel ingetrokken en wordt het van jou.} other {Zodra we dit token lezen, worden de claims van de huidige eigenaren op dit spel ingetrokken en wordt het van jou.}} + + + Een van deze twee volstaat + + + Een MSSP-variabele + + + In {codebase} is dat een regel in {file}; elke codebase met MSSP heeft een equivalent. + + + {aliases} worden ook geaccepteerd. + + + Een regel op het verbindingsscherm + + + Overal op het scherm, en kleurcodes eromheen zijn geen probleem. + + + Daarna + + + We kijken op het gewone crawlschema. Dit token is geldig tot {date}. Kom gerust later terug; er hoeft niets opgeschreven te worden. + + + Eerder kijken + + + Zet je spel vooraan in de rij. We kloppen aan op ons eigen schema, dus dit is eerder en niet nu. + + + Net gevraagd. Probeer het over een paar minuten opnieuw — het is gerantsoeneerd omdat er eerder bij een echte server wordt aangeklopt dan wij anders gedaan hadden. + + + Wat alleen jij ons over {game} kunt vertellen + + + Dit zijn de dingen waar MSSP geen veld voor heeft. Ze verschijnen op de pagina van je spel als {declared}, met de datum waarop je ze voor het laatst bevestigd hebt, naast wat wij gemeten hebben — nooit in plaats daarvan. Niets wat gemeten is kan hiervandaan gewijzigd worden, niet door jou en niet door ons. + + + opgegeven {age}. Maak dit vak leeg om het in te trekken — wat erin stond blijft hoe dan ook vastgelegd. + + + Opslaan wat je opgegeven hebt + + + Wat {game} meldt, en wat jij liever getoond ziet + + + Je MSSP is wat elke crawler leest, en we blijven het tonen naast alles wat je hier neerzet — een antwoord van jou verbergt er geen van je spel. + + + Niets wat gemeten is kan hiervandaan gewijzigd worden: geen spelerstelling, geen mogelijkheid, geen uur bereikbaarheid. + + + Klopt een regel hieronder niet in je {file}, dan lost het daar oplossen het overal op. + + + je spel meldt {value}, bevestigd {age} + + + je spel meldt hier niets + + + De naam wijzigen verandert waaronder {game} vermeld staat en het adres van zijn pagina. Het oude adres blijft voor altijd werken — elk adres dat dit spel ooit gehad heeft verwijst door naar het huidige — en het vak leegmaken geeft de naam terug aan wat je MSSP zegt. + + + Je verbindingsscherm + + + We publiceren het niet opnieuw. De pagina zegt dat met zoveel woorden in plaats van een gat te laten, en de crawler blijft het lezen — zo herkennen we je spel als het verhuist. + + + Weer tonen + + + We tonen het omdat je server het naar elke anonieme verbinding stuurt. Wil je dat liever niet, zeg het dan en we stoppen ermee. We vragen niet waarom. + + + Toon ons verbindingsscherm niet meer + + + Gecrawld worden + + + We zijn gestopt. Er wordt bij {game} nergens meer aangeklopt, en de pagina houdt alles wat vóór je verzoek gemeten is — de lege uren noemen geen oorzaak, want {ourFact} is een feit over ons en geen meting van je spel. + + + dat je ons gevraagd hebt te stoppen + + + Crawl ons weer + + + Deze kwam van je eigen server en niet van hier — {routes}. Wil je weer gecrawld worden, stop dan met het publiceren ervan; dat horen we in de volgende cyclus. + + + je MSSP-rapport publiceert {variable} + + + een TXT-record op {label} vraagt ons te stoppen + + + een verzoek dat we vastgelegd hebben + + + We zijn gestopt op {stopped} en kloppen nog aan bij {dialling}. Dat is meestal een poort die na de afmelding is toegevoegd. + + + Stop ook op elk adres + + + We kloppen volgens een schema aan bij {game} en lezen wat elke anonieme verbinding te zien krijgt. Wil je dat liever niet, zeg het dan en we stoppen — binnen één cyclus, op elk adres dat we van je hebben, en we vragen niet waarom. + + + Wat al gemeten is wordt niet verwijderd: je pagina houdt zijn geschiedenis en zijn adres, en één peiling nadat je dit intrekt begint het weer. Je kunt het ook zonder ons zeggen, in je eigen configuratie — {mssp} in MSSP, of een TXT-record op {dns} — en die honoreren we, of iemand het spel hier nu ooit geclaimd heeft of niet. + + + Crawl ons niet meer + + + In de lijst staan + + + {game} staat niet meer in de lijst, niet in de ranglijsten en niet in het dagcijfer. Zijn pagina en elk adres dat het ooit gehad heeft blijven antwoorden, en alles wat vóór je verzoek gemeten is staat er nog op. Er is niets verwijderd; het is alleen geen plek meer waar een lezer al bladerend aankomt. + + + Zet ons terug in de lijst + + + Eén peiling die antwoord krijgt doet dit ook. Zolang je afmelding staat kloppen we niet aan, dus gebeurt dat niet — maar de dag dat je haar intrekt, komt het adres binnen een week aan de beurt en zet de peiling die antwoord krijgt je terug. Je hoeft het ons geen tweede keer te vragen. + + + We kloppen niet meer bij je aan, en je pagina staat nog in de lijst met wat we daarvóór gemeten hebben. Wil je dat liever niet, zeg het dan en hij gaat eruit — uit de lijst, de ranglijsten en het dagcijfer. + + + Er wordt niets verwijderd en er gaat niets stuk: de pagina antwoordt, elk adres dat hij ooit gehad heeft verwijst er nog naar door, en iedereen die je erheen stuurt ziet hem. Hij houdt op een plek te zijn waar een lezer al bladerend aankomt. Vanaf hier terug te draaien, en door elke peiling die antwoord krijgt nadat je je afmelding intrekt. + + + Haal ons ook uit de lijst + + + wat we konden meten + + + Deze verbergen haalt ze uit deze lijst; het betekent niet dat het spel leeg is. + + + konden niet tellen + + + konden niet bereiken + + + niet getoond in deze lijst + + + niet getoond in deze lijst + + + In de linkerkolom is * een waarde waarop deze lijst gefilterd is en - een waarde die is uitgesloten. Beide zijn keuzes in de zoekopdracht, geen feiten over een spel. + + diff --git a/src/MUI.Web/Resources/Messages.resx b/src/MUI.Web/Resources/Messages.resx new file mode 100644 index 0000000..0e39845 --- /dev/null +++ b/src/MUI.Web/Resources/Messages.resx @@ -0,0 +1,3823 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + {count, plural, one {# game} other {# games}} + counts, which is where the concatenations were + + + {value}, {count, plural, one {# game} other {# games}}, only + counts, which is where the concatenations were + + + {value}, {count, plural, one {# game} other {# games}}, excluded + counts, which is where the concatenations were + + + {value}, {count, plural, one {# game} other {# games}} + counts, which is where the concatenations were + + + any {facet}, {count, plural, one {# game} other {# games}} + counts, which is where the concatenations were + + + {count, plural, =0 {No games listed here.} one {# game, each fact carrying how it was obtained.} other {# games, each fact carrying how it was obtained.}} + counts, which is where the concatenations were + + + {days, plural, one {# day} other {# days}} measured · {probes, plural, one {# probe} other {# probes}} + counts, which is where the concatenations were + + + {days, plural, one {#d} other {#d}} · {count, plural, one {# count} other {# counts}} + counts, which is where the concatenations were + + + {disagreeing, plural, =0 {None of the {total} disagree.} one {# of {total} disagrees with what the game declares.} other {# of {total} disagree with what the game declares.}} + counts, which is where the concatenations were + + + {count, plural, =0 {No games match every answer.} one {# game matches every answer.} other {# games match every answer.}} + find a game, where the count is the whole point of the page + + + of {listed, plural, one {# listed game} other {# listed games}} · {answers, plural, =0 {no answers given} one {# answer given} other {# answers given}} + find a game, where the count is the whole point of the page + + + {count, plural, one {Show the one game} other {Show these # games}} + find a game, where the count is the whole point of the page + + + drop "{answer}" — {count, plural, one {# game} other {# games}} + find a game, where the count is the whole point of the page + + + clear answer to: {question} + find a game, where the count is the whole point of the page + + + Find a game + find a game, where the count is the whole point of the page + + + matching all answers + find a game, where the count is the whole point of the page + + + {count, plural, one {game} other {games}} + find a game, where the count is the whole point of the page + + + clear all answers + find a game, where the count is the whole point of the page + + + start again + find a game, where the count is the whole point of the page + + + {count, plural, one {# more} other {# more}} + find a game, where the count is the whole point of the page + + + answers given + find a game, where the count is the whole point of the page + + + the whole listing + find a game, where the count is the whole point of the page + + + that query was refused + find a game, where the count is the whole point of the page + + + a name, if you have one + find a game, where the count is the whole point of the page + + + name, or part of one + find a game, where the count is the whole point of the page + + + Search by name + find a game, where the count is the whole point of the page + + + Is anyone playing right now? + find a game, where the count is the whole point of the page + + + What do you want to play? + find a game, where the count is the whole point of the page + + + What kind of game? + find a game, where the count is the whole point of the page + + + In which language? + find a game, where the count is the whole point of the page + + + Anything your client needs? + find a game, where the count is the whole point of the page + + + Include games that have gone dark? + find a game, where the count is the whole point of the page + + + doesn't matter + find a game, where the count is the whole point of the page + + + any genre + find a game, where the count is the whole point of the page + + + any kind + find a game, where the count is the whole point of the page + + + any language + find a game, where the count is the whole point of the page + + + doesn't matter + find a game, where the count is the whole point of the page + + + no, only live games + find a game, where the count is the whole point of the page + + + yes, show me those too + find a game, where the count is the whole point of the page + + + games that have gone dark + find a game, where the count is the whole point of the page + + + TLS — encrypted, handshake completed by us + the client question's options + + + MSSP — server self-description + the client question's options + + + MCCP — compressed output + the client question's options + + + MXP — clickable links + the client question's options + + + GMCP — structured client data + the client question's options + + + MSDP — structured client data + the client question's options + + + CHARSET — encoding negotiation + the client question's options + + + UTF-8 — non-Latin text renders + the client question's options + + + TTYPE — client tells its type + the client question's options + + + ATCP — structured client data + the client question's options + + + MSP — sound triggers + the client question's options + + + EOR — prompt marking + the client question's options + + + {token} — measured in the handshake + the client question's options + + + measured + the locked provenance words, one id per context + + + measured + the locked provenance words, one id per context + + + measured + the locked provenance words, one id per context + + + measured + the locked provenance words, one id per context + + + measured + the locked provenance words, one id per context + + + declared + the locked provenance words, one id per context + + + declared + the locked provenance words, one id per context + + + declared + the locked provenance words, one id per context + + + declared + the locked provenance words, one id per context + + + derived + the locked provenance words, one id per context + + + derived + the locked provenance words, one id per context + + + not measured + the four kinds of absence + + + uncounted + the four kinds of absence + + + unreachable + the four kinds of absence + + + not counted + the four kinds of absence + + + no count + the listing's own absences, which are not the game page's + + + from here + the listing's own absences, which are not the game page's + + + archived + the listing's own absences, which are not the game page's + + + claimed + the listing's own absences, which are not the game page's + + + Nothing to pick from + the listing's own absences, which are not the game page's + + + No game matches that filter. Try {listing}, or {archive}. + the listing's own absences, which are not the game page's + + + the whole listing + the listing's own absences, which are not the game page's + + + include the archive + the listing's own absences, which are not the game page's + + + connected + the words the product rests on + + + unclaimed + the words the product rests on + + + claimed by its owner + the words the product rests on + + + still probed + the words the product rests on + + + typical + the words the product rests on + + + peak + the words the product rests on + + + read as text + the accessibility promises + + + plain text + the accessibility promises + + + skip to content + the accessibility promises + + + ASCII banner: the connect screen of {game}. + the accessibility promises + + + Catalogues + site chrome + + + This site and your account + site chrome + + + browse + site chrome + + + learn + site chrome + + + this site + site chrome + + + menu + site chrome + + + games + site chrome + + + find + site chrome + + + random + site chrome + + + archive + site chrome + + + reference + site chrome + + + ecosystem + site chrome + + + rankings + site chrome + + + about + site chrome + + + submit + site chrome + + + submit a game + site chrome + + + sign in + site chrome + + + your games + site chrome + + + theme + site chrome + + + auto + site chrome + + + light + site chrome + + + dark + site chrome + + + Demo data. + site chrome + + + No database is configured, so this is a fixture. Nothing here was measured. + site chrome + + + all games + site chrome + + + archive + site chrome + + + declared by the game + site chrome + + + what changed + site chrome + + + A directory of the MU* hobby + home + + + Every fact carries how it was obtained and how old it is: measured by our crawler, or declared by the game and marked as such. + home + + + Search games by name, theme, codebase or host + home + + + search by name, theme, codebase or host + home + + + search + home + + + games known + home + + + connected now + home + + + answering, uncounted + home + + + archived + home + + + newly discovered + home + + + went dark — still probed + home + + + came back + home + + + Nothing new. + home + + + Nothing went dark. + home + + + Nothing came back. We keep knocking. + home + + + live + home + + + Games + the listing + + + sorted by {order} + the listing + + + random + the listing + + + connected · reached + the listing + + + from here + the listing + + + Nothing matched. + the listing + + + Try fewer words, or drop a filter. + the listing + + + clear filters + the listing + + + about {codebase} + the listing + + + never + the listing + + + claimed by its owner + the listing + + + Unknown Codebase + the listing + + + we could not identify the codebase this game runs + the listing + + + and {count, plural, one {# more} other {# more}}: {names} + the listing + + + Order + the order switch + + + Window + the order switch + + + now + the order switch + + + typical + the order switch + + + peak + the order switch + + + name + the order switch + + + reached + the order switch + + + 7 days + the order switch + + + 30 days + the order switch + + + 90 days + the order switch + + + Search games + the filter panel + + + search games + the filter panel + + + filters + the filter panel + + + showing + the filter panel + + + clear all + the filter panel + + + — stop filtering by this + the filter panel + + + any + the filter panel + + + more filters ({count}) + the filter panel + + + {count, plural, one {# more} other {# more}} + the filter panel + + + also show + the filter panel + + + Off by default. Neither is a judgement about the game. + the filter panel + + + archived + the filter panel + + + adult + the filter panel + + + archived games, {shown, select, true {shown} other {hidden}} + the filter panel + + + games declaring adult content, {shown, select, true {shown} other {hidden}} + the filter panel + + + Counts are games we measured, never estimates. + the filter panel + + + what the badges and the blanks mean + the filter panel + + + A blank is a gap in our measurement, not a no. Each facet spells its own: not identified, not declared, nothing negotiated. + the filter panel + + + A measured zero is a count. An unknown count is not a zero and never sorts as one. + the filter panel + + + Open-ended facets list their {count} commonest values. The rest are reachable by search and by URL. + the filter panel + + + Unticked means not measured — not that the game lacks it. + the filter panel + + + activity + facet groups + + + last seen + facet groups + + + protocols offered + facet groups + + + encrypted + facet groups + + + encoding + facet groups + + + codebase + facet groups + + + version + facet groups + + + lineage + facet groups + + + family + facet groups + + + genre + facet groups + + + language + facet groups + + + connected now + facet values + + + active this week + facet values + + + quiet — no count above 0 + facet values + + + dark — not reached in a month + facet values + + + archived + facet values + + + in the last 24 hours + facet values + + + in the last 7 days + facet values + + + in the last 30 days + facet values + + + longer ago + facet values + + + never reached + facet values + + + nothing negotiated + facet values + + + not identified + facet values + + + not declared + facet values + + + connected over TLS + facet values + + + not {value} + facet values + + + something negotiated + facet values + + + identified at all + facet values + + + declared at all + facet values + + + we watched this happen + evidence, and what each word means + + + the game says so, and we did not check + evidence, and what each word means + + + we grouped what the game told us + evidence, and what each word means + + + name + sort orders + + + connected now + sort orders + + + last reached + sort orders + + + typically on · 7 days + sort orders + + + typically on · 30 days + sort orders + + + typically on · 90 days + sort orders + + + most on at once · 7 days + sort orders + + + most on at once · 30 days + sort orders + + + most on at once · 90 days + sort orders + + + on the row now + sort orders + + + typical + sort orders + + + peak + sort orders + + + Unknown count + sort orders + + + never once reached — not reached long ago + sort orders + + + fewer than {minimum} counts in the window, or none at all — not a typical count of zero + sort orders + + + nothing we could count in the window — not a game nobody was on + sort orders + + + median {value} · {days, plural, one {#d} other {#d}} · {count, plural, one {# count} other {# counts}} + sort orders + + + most {value} at once · {days, plural, one {#d} other {#d}} · {count, plural, one {# count} other {# counts}} + sort orders + + + Connect screen + the game page's own headings + + + Connections by hour + the game page's own headings + + + How many, over time + the game page's own headings + + + Reachable + the game page's own headings + + + What changed + the game page's own headings + + + Capabilities + the game page's own headings + + + Declared by the game + the game page's own headings + + + Referrals + the game page's own headings + + + Unclaimed — everything here was measured. + the game page's own headings + + + Claimed by its owner — measured facts below are still ours. + the game page's own headings + + + Claim this game + the game page's own headings + + + answering since {date} + the game page's own headings + + + read as text — {count, plural, one {# row} other {# rows}} + the game page's own headings + + + capability + the game page's own headings + + + age + the game page's own headings + + + offered + the game page's own headings + + + silent + the game page's own headings + + + absent + the game page's own headings + + + denied + the game page's own headings + + + claimed + the game page's own headings + + + disagrees + the game page's own headings + + + where they disagree ({count}) + the game page's own headings + + + {day} {time} — {count, plural, =0 {0 players, measured} one {# player on average} other {# players on average}} + the week of hours, said in words + + + {day} {time} — probed, no count could be read + the week of hours, said in words + + + {day} {time} — no measurement in this hour + the week of hours, said in words + + + We have not measured this game's activity yet. + the week of hours, said in words + + + No hour of the week has produced a player count. + the week of hours, said in words + + + Measured every hour and nobody has been on in any of them. + the week of hours, said in words + + + {count, plural, one {# hour on {day} has no measurement yet.} other {# hours on {day} have no measurement yet.}} + the week of hours, said in words + + + {count, plural, one {# hour across the week has no measurement yet.} other {# hours across the week have no measurement yet.}} + the week of hours, said in words + + + {count, plural, one {# hour on {day} answered but produced no count.} other {# hours on {day} answered but produced no count.}} + the week of hours, said in words + + + {count, plural, one {# hour across the week answered but produced no count.} other {# hours across the week answered but produced no count.}} + the week of hours, said in words + + + Busiest every day, {window}. + the week of hours, said in words + + + Busiest every day, {part}, {window}. + the week of hours, said in words + + + Busiest {days} {window}. + the week of hours, said in words + + + Busiest {days} {part}, {window}. + the week of hours, said in words + + + Reliably quiet {who}, {window}. + the week of hours, said in words + + + Reliably quiet {who} in the {part}, {window}. + the week of hours, said in words + + + every day + the week of hours, said in words + + + every day we could measure + the week of hours, said in words + + + on weekdays + the week of hours, said in words + + + on {days} + the week of hours, said in words + + + morning + the week of hours, said in words + + + afternoon + the week of hours, said in words + + + evening + the week of hours, said in words + + + small hours + the week of hours, said in words + + + mornings + the week of hours, said in words + + + afternoons + the week of hours, said in words + + + evenings + the week of hours, said in words + + + small hours + the week of hours, said in words + + + {list}, {next} + the week of hours, said in words + + + {first} and {second} + the week of hours, said in words + + + not enough measurements yet + the week of hours, said in words + + + No hour of the week has a measurement yet. + the week of hours, said in words + + + {count, plural, one {# hour answered and produced no count.} other {# hours answered and produced no count.}} + the week of hours, said in words + + + The grid appears once every day of the week has one. + the week of hours, said in words + + + {days, plural, one {Measured on # of the seven days so far; the grid appears once every day has an hour in it.} other {Measured on # of the seven days so far; the grid appears once every day has an hour in it.}} + the week of hours, said in words + + + {count, plural, one {# hour measured, all of it at nobody on.} other {# hours measured, all of them at nobody on.}} + the week of hours, said in words + + + {count, plural, one {# hour measured, the busiest {peak} on {day} at {time} UTC.} other {# hours measured, the busiest {peak} on {day} at {time} UTC.}} + the week of hours, said in words + + + {count, plural, one {# more hour answered and produced no count.} other {# more hours answered and produced no count.}} + the week of hours, said in words + + + {day} — {facts} + the week of hours, said in words + + + {first}, {second} + the week of hours, said in words + + + measured at zero all day + the week of hours, said in words + + + peak {count} at {time} + the week of hours, said in words + + + nobody on {window} + the week of hours, said in words + + + no count in any hour + the week of hours, said in words + + + {count, plural, one {# hour not measured} other {# hours not measured}} + the week of hours, said in words + + + {count, plural, one {# hour probed but uncountable} other {# hours probed but uncountable}} + the week of hours, said in words + + + day + the week of hours, said in words + + + quietest + the week of hours, said in words + + + busiest + the week of hours, said in words + + + at + the week of hours, said in words + + + no count + the week of hours, said in words + + + Players on by day, in UTC. {window}. + the week of hours, said in words + + + times in UTC · {window} + the week of hours, said in words + + + {weeks, plural, one {#-week rolling average} other {#-week rolling average}} + the week of hours, said in words + + + counted, including a measured zero + the week of hours, said in words + + + probed, no count could be read + the week of hours, said in words + + + no measurement in that hour + the week of hours, said in words + + + When people are on (UTC) + the week of hours, said in words + + + counted + the week of hours, said in words + + + we got in and read a number, including a measured zero + the week of hours, said in words + + + we got in and no number could be read + the week of hours, said in words + + + we have no measurement for that hour + the week of hours, said in words + + + language + the switcher's own chrome, which has to read in the locale being left + + + change language + the switcher's own chrome, which has to read in the locale being left + + + About mu*index + about: the page, and what a fact here is + + + Every game here was measured by a machine that connected to it, and every value says where it came from and when. This page covers what that proves, what we get wrong, whose directories we read, and how to make the crawler stop. + about: the page, and what a fact here is + + + What a fact here is + about: the page, and what a fact here is + + + Measured beats declared, and both are shown. + about: the page, and what a fact here is + + + A game's MSSP report is the game describing itself. The telnet handshake is what we watched it do. Both appear on its page, labelled with how and when. Where they disagree, we show the disagreement. + about: the page, and what a fact here is + + + A player count says where it came from. + about: the page, and what a fact here is + + + Either a WHO or DOING read at the connect screen, which we counted, or the game's own MSSP PLAYERS field, which it published. Never merged. + about: the page, and what a fact here is + + + An answer we cannot read is unknown, never zero. + about: the page, and what a fact here is + + + Servers customise their WHO headers freely, and past a point our parser cannot read one. That is uncountable, its own state. A measured zero — we got in, nobody was there — is a count, and prints as one. + about: the page, and what a fact here is + + + Reachable, never uptime. + about: the page, and what a fact here is + + + We open a socket from one host at intervals. A game we cannot route to is unreachable and perfectly alive. Nothing here claims a game's uptime, because nothing here measured it. + about: the page, and what a fact here is + + + An hour is counted, uncountable, or not measured. + about: the page, and what a fact here is + + + The activity grid has three states. The third is empty and names no cause: an hour we could not reach and an hour we never probed are the same absence, and neither is that server's downtime. + about: the page, and what a fact here is + + + What we know we get wrong + about: what we know we get wrong + + + Archive grace is measured from the day we found you. + about: what we know we get wrong + + + A game that stops answering leaves the default listing after its grace period: a quarter of the reachable time we probed, floored at 60 days and capped at 365. A game running since 1995 starts at the floor on the day we discover it. We import nothing to fill in the years before we arrived. + about: what we know we get wrong + + + We do not credit MSSP CREATED toward that grace. + about: what we know we get wrong + + + It is one hand-typed line in a config file, so crediting it would make the archive threshold gameable. It is shown as a declaration and buys nothing. + about: what we know we get wrong + + + Claiming a game earns the ceiling. + about: what we know we get wrong + + + Proving server access is worth the full year of grace, however long we have been watching. + about: what we know we get wrong + + + Everything here is one host, looking at intervals. + about: what we know we get wrong + + + A percentage of reachable time is a fraction of the window we observed, never of one we did not. No graphic here fills in the rest. + about: what we know we get wrong + + + Nothing is ever deleted. + about: what we know we get wrong + + + Archiving takes a game out of the default listing, the rankings and the active-today figure, and nothing else. Its page, URL, history and address survive, it keeps being probed, and one successful probe puts it back. + about: what we know we get wrong + + + What this site will not do + about: what this site will not do + + + No votes, stars, ratings or recommendations. + about: what this site will not do + + + Rankings are computed from measured data only. A directory ranked by who can mobilise the most clicks describes the campaigning, not the hobby, and that is what killed the incumbents. + about: what this site will not do + + + No forums, reviews, wikis, comments or player profiles. + about: what this site will not do + + + Orientation material — what a MUSH is, which codebase suits collaborative roleplay — is written, signed and versioned like the rest of the site. + about: what this site will not do + + + Player names are never persisted. + about: what this site will not do + + + A WHO reply is parsed in memory for a count and the shape of the header. The names are not written down; aggregates use a salted hash with a rotating salt. + about: what this site will not do + + + No absolute population figure is published. + about: what this site will not do + + + Per-codebase and per-protocol shares ship: a ratio over the measured set survives the games we cannot count. "How many people play MU*" does not, because that number would not survive being quoted. + about: what this site will not do + + + The crawler, and how to make it stop + about: the crawler, and how to make it stop + + + A probe is one connection that never logs in. + about: the crawler, and how to make it stop + + + It opens a socket, negotiates telnet options, reads the connect screen, asks for MSSP by negotiating option 70, sends {commands}, and disconnects. No character, no login, nothing changed on the far side. A timeout bounds the session so a wedged probe cannot sit on a connection slot. + about: the crawler, and how to make it stop + + + CRAWL DELAY wins. + about: the crawler, and how to make it stop + + + A game that states a preferred minimum gap in its MSSP report gets it, over our own schedule in both directions: 720 hours means monthly, not weekly. A dark game is still tried for ever at the longer interval, which is how it re-lists itself when it comes back. + about: the crawler, and how to make it stop + + + A referred address is verified, never trusted. + about: the crawler, and how to make it stop + + + MSSP lets a game name other games. Every name is resolved before anything is dialled, and refused unless every address it resolves to is globally routable. A mixed answer refuses the whole target. Our refusal is filed as ours and never appears in a game's record as downtime. + about: the crawler, and how to make it stop + + + Connect screens are shown because they are sent to everybody. + about: the crawler, and how to make it stop + + + A server paints its connect screen, unauthenticated, to every anonymous connection. We display it as evidence and label it. Ask and it comes down. + about: the crawler, and how to make it stop + + + Say stop, and we stop — three ways. + about: the crawler, and how to make it stop + + + Publish {variable} 1 in your MSSP report, and the probe that reads it is the last one. Or publish a TXT record at {label}.your.host reading "{value}", which needs no MSSP support and no account here. Or write to a person. All three are honoured within one crawl cycle, recorded with the date and what we read, and enforced on the submission form too. + about: the crawler, and how to make it stop + + + The MSSP field stops that listener; the record stops the host. + about: the crawler, and how to make it stop + + + MSSP is published by the port that answered, so it speaks for that port — MU* hosting routinely runs unrelated games on one domain, and one must not silence its neighbour. A TXT record covers every port unless it names one, as "{value}=4201". Anything there we cannot read as a port list means the whole host, so "{value}=all" works. + about: the crawler, and how to make it stop + + + The DNS route is the one you can undo without asking us. + about: the crawler, and how to make it stop + + + A TXT record is readable without connecting to a server that told us not to, so we re-read it before every dial. Delete it and we dial again within a week. An MSSP field cannot be re-read without doing the thing you asked us to stop, so MSSP opt-outs and written requests stand until you say otherwise. That TXT lookup is all an opted-out address gets: it touches your nameserver, never your game. + about: the crawler, and how to make it stop + + + Stopping is not deleting, and it is not downtime. + about: the crawler, and how to make it stop + + + A game that opts out keeps its page, its address and everything we measured before it asked. Only new data stops: the activity grid stops gaining hours and names no cause, because our decision to stop knocking is a fact about us. It is recorded on the crawl that did not happen, and in the register of who asked. + about: the crawler, and how to make it stop + + + If stopping is not enough, the listing can go too. + about: the crawler, and how to make it stop + + + Once we have stopped on every address your game answers on, your dashboard offers one more thing: take it out of the listing, the rankings and the daily figure. The page and every address it has ever had still answer, and nothing is deleted — it stops being somewhere a reader arrives by browsing. It needs a verified claim, because it is a decision about your game and we record who made it. And a probe undoes it: take your opt-out back, and the next dial that gets an answer puts you back in the listing without asking us twice. + about: the crawler, and how to make it stop + + + The crawler names itself {name} when a server asks what it is. + about: who is knocking + + + The crawler is configured to call itself {name} but cannot yet say so. Its telnet library gives a client no way to set the terminal type, so your logs see that library's default, and NEW-ENVIRON is answered from the crawler host's environment. Both are gaps in the library and ours to fix there. Until then, recognise a probe by its shape: one connection, no login, a short read-only command set, gone. + about: who is knocking + + + crawler + about: who is knocking + + + contact + about: who is knocking + + + Crawler: {name} + about: who is knocking + + + Contact: {url} + about: who is knocking + + + — placeholder; this deployment set no contact address + about: who is knocking + + + No contact address is configured, so the one above is a placeholder and answers nobody. + about: who is knocking + + + Where the list of games came from + about: where the list of games came from + + + We take addresses. Nothing else. + about: where the list of games came from + + + A backfill takes a host and a port. No player counts, no reachability history, no descriptions, no fields, and no note of which site an address came from. + about: where the list of games came from + + + Deliberately less than those sites can give. + about: where the list of games came from + + + Several hold years of dated player counts. Importing that would fill the heatmaps of the games somebody else was already watching, and rest this site's central claim on another party's prober. + about: where the list of games came from + + + A game's origin is not one fact. + about: where the list of games came from + + + Any game worth listing appears in several of these directories, so "imported from" would name whichever fetch ran first. That a game exists is public information; where we read it adds nothing and is the part of somebody else's work with the least claim to be ours. + about: where the list of games came from + + + Reading somebody's site is still reading somebody's site. + about: where the list of games came from + + + We ask for a bulk export or a documented endpoint before scraping, read robots.txt first, and rate-limit scrapes hard. A source that needs its maintainer's say-so is not fetched until a person can state they were asked. + about: where the list of games came from + + + read — addresses only + about: where the list of games came from + + + not read — awaiting permission + about: where the list of games came from + + + One page, one request. Published by a crawler that connects to each game and prints what it read. + about: where the list of games came from + + + The same crawler's MSDP listing. Nearly a subset of its MSSP sibling, read for the few addresses it reaches that the other does not. + about: where the list of games came from + + + Publishes its whole catalogue on one page, so reading it costs a single request. Our largest source of addresses, and of no measurements. + about: where the list of games came from + + + One index page and one page per world, so a scrape rather than an export. On 30 July 2026 we fetched 143 of their pages, fifteen seconds apart and honouring robots.txt, but before anyone had written to them. That should not have happened. The gate now takes a person willing to state the maintainer was asked. + about: where the list of games came from + + + Implemented, tested, never run. The strongest source here on every axis except permission, and nothing will be fetched until somebody has written to them. + about: where the list of games came from + + + Licence + about: licence + + + The code is MIT. + about: licence + + + The site, the crawler and the parsers are open source under the MIT licence. + about: licence + + + The licence for the data is an open question. + about: licence + + + A separate decision from the code's, and not yet taken. Treat the terms below as this deployment's current answer, not the project's settled position. A rival directory taking the whole catalogue is a success condition here, so whatever is settled will not stand in the way of one. + about: licence + + + code + about: licence + + + data, as this deployment serves it + about: licence + + + credit as + about: licence + + + Code: {licence} + about: licence + + + Data: {licence} + about: licence + + + Credit as: {credit} + about: licence + + + (what this deployment serves. The project's own answer is still open.) + about: licence + + + Submit a game + submit a game + + + Tell us where a game is. A host and a port is the whole form; everything else on this site is measured by our own crawler. + submit a game + + + Host + submit a game + + + Port + submit a game + + + mud.example.org, or paste mud.example.org:4201 and leave the port empty + submit a game + + + Submit + submit a game + + + Submitting needs a database, and this site is running on the demo fixture. There is no crawl registry to write into, so the form is absent rather than quietly doing nothing. + submit a game + + + Not here + submit a game + + + What happens to an address + submit a game + + + We resolve the address before dialling it, and refuse anything that resolves off the public internet. That is a decision about our own socket, never a fact about a game. + submit a game + + + If whoever runs that host has asked us not to crawl it, we will not take the address, whoever submits it. A stranger cannot put your game back on this site. + submit a game + + + If it answers, we read what the server says for itself and keep reading it on its own schedule, for ever. An address only has to be given once. + submit a game + + + Nothing appears on the site until somebody proves they run it. Claiming takes a passkey and one line published on the game itself. + submit a game + + + An address we already have collapses onto the existing entry. Sending it twice makes no second listing and brings no probe forward. + submit a game + + + that address + submit a game + + + In the registry. + submit a game + + + {address} will be dialled on the next crawl cycle, then on its own schedule for ever. It appears here once somebody proves they run it — come back to this form with the same address and it will hand you the link. + submit a game + + + We have it, unclaimed. + submit a game + + + {address} is one we already measure. It stays off the site until somebody proves they run it. If that is you, this is the way in. + submit a game + + + We already have that one. + submit a game + + + {address} is a game we already measure. Nothing was created and nothing was changed. + submit a game + + + We already have that address. + submit a game + + + {address} is already known to us. Nothing was created and nothing was changed. + submit a game + + + Already waiting. + submit a game + + + {address} is in the crawl registry and has not answered yet. Sending it again does not bring it forward: a target keeps its own schedule, so nobody can hurry us at somebody else's server. + submit a game + + + Not an address we can dial. + submit a game + + + A host needs a dot or a colon in it, and a port is a number between 1 and 65535. Fill in both boxes, or paste mud.example.org:4201 into the first. + submit a game + + + We cannot dial that. + submit a game + + + Three things produce this answer for {address}: the name may not resolve, it may resolve off the public internet, or whoever runs that host may have asked us to stay away. We deliberately do not say which, because answering that for a stranger maps a network from outside it. Nothing was recorded about the address; the decision was ours and it is filed as ours. + submit a game + + + Enough for now. + submit a game + + + This form is rate-limited by sender, and you have hit the bound. Come back in an hour. Nothing was lost — anything we took is already in the registry. + submit a game + + + claim this game + submit a game + + + Sign in + signing in, which is a passkey and nothing else + + + Sign in with a passkey to claim a game you run. There is no password to lose and none to steal. + signing in, which is a passkey and nothing else + + + Claiming needs a database, and this site is running on the demo fixture. There is nothing to sign in to. + signing in, which is a passkey and nothing else + + + Sign-in is a passkey. + signing in, which is a passkey and nothing else + + + Your device or password manager holds the private key; we hold only the public half. No password, no email. + signing in, which is a passkey and nothing else + + + Sign in with a passkey + signing in, which is a passkey and nothing else + + + The one page here that needs JavaScript. Passkeys cannot work without it. + signing in, which is a passkey and nothing else + + + No account yet? + signing in, which is a passkey and nothing else + + + You need one only to claim a game you run. Pick a name to be known by — a label beside your claim, not a real name. + signing in, which is a passkey and nothing else + + + Name + signing in, which is a passkey and nothing else + + + e.g. corvid-admin + signing in, which is a passkey and nothing else + + + Create an account with a passkey + signing in, which is a passkey and nothing else + + + What we store + signing in, which is a passkey and nothing else + + + The name you chose. + signing in, which is a passkey and nothing else + + + The public key of each passkey you register, and what your device called it. + signing in, which is a passkey and nothing else + + + Which games you have claimed, and when. + signing in, which is a passkey and nothing else + + + No email address, no password, no IP log tied to your account. Lose every passkey and you can publish a fresh claim token on your game and start again: the game is the proof, not the account. + signing in, which is a passkey and nothing else + + + {day} {month} {year} + dates, ages and provenance — the two shapes on nearly every page + + + {date} {time} UTC + dates, ages and provenance — the two shapes on nearly every page + + + now + the age ladder, in three registers + + + {count, plural, one {#m} other {#m}} + the age ladder, in three registers + + + {count, plural, one {#h} other {#h}} + the age ladder, in three registers + + + {count, plural, one {#d} other {#d}} + the age ladder, in three registers + + + {count, plural, one {#w} other {#w}} + the age ladder, in three registers + + + {count, plural, one {#mo} other {#mo}} + the age ladder, in three registers + + + {count, plural, one {#y} other {#y}} + the age ladder, in three registers + + + just now + the age ladder, in three registers + + + {count, plural, one {#m ago} other {#m ago}} + the age ladder, in three registers + + + {count, plural, one {#h ago} other {#h ago}} + the age ladder, in three registers + + + {count, plural, one {#d ago} other {#d ago}} + the age ladder, in three registers + + + {count, plural, one {#w ago} other {#w ago}} + the age ladder, in three registers + + + {count, plural, one {#mo ago} other {#mo ago}} + the age ladder, in three registers + + + {count, plural, one {#y ago} other {#y ago}} + the age ladder, in three registers + + + just now + the age ladder, in three registers + + + {count, plural, one {#m ago} other {#m ago}} + the age ladder, in three registers + + + {count, plural, one {#h ago} other {#h ago}} + the age ladder, in three registers + + + {count, plural, one {#d ago} other {#d ago}} + the age ladder, in three registers + + + {count, plural, one {#w ago} other {#w ago}} + the age ladder, in three registers + + + {count, plural, one {#mo ago} other {#mo ago}} + the age ladder, in three registers + + + {count, plural, one {#y ago} other {#y ago}} + the age ladder, in three registers + + + {age}, {stamp} + the age ladder, in three registers + + + , {stamp} + the age ladder, in three registers + + + {value} — {how} via {source}, last confirmed {date} + the provenance chip's tooltip + + + {value} — {how} via {source}, last confirmed {date} (past its expected refresh) + the provenance chip's tooltip + + + ({how}, {age}) + the provenance chip's tooltip + + + ({how}, {age}, stale) + the provenance chip's tooltip + + + owner-declared + the provenance chip's tooltip + + + staff + how a value reached us, one id per source + + + the telnet handshake + how a value reached us, one id per source + + + the owner + how a value reached us, one id per source + + + WHO + how a value reached us, one id per source + + + I3 + how a value reached us, one id per source + + + MSSP + how a value reached us, one id per source + + + INFO + how a value reached us, one id per source + + + the I3 mudlist + how a value reached us, one id per source + + + the connect screen + how a value reached us, one id per source + + + The ecosystem + the ecosystem dashboard: shares, never totals, and never a share without its set + + + Shares, never totals. We do not publish a figure for how many people play MU*: a ratio over the games we measured survives the ones we cannot reach, and a headcount does not. + the ecosystem dashboard: shares, never totals, and never a share without its set + + + {count, plural, one {{value} game listed} other {{value} games listed}} + the ecosystem dashboard: shares, never totals, and never a share without its set + + + {count, plural, one {{value} game whose handshake we completed} other {{value} games whose handshake we completed}} + the ecosystem dashboard: shares, never totals, and never a share without its set + + + {count, plural, one {{value} game whose MSSP report we hold} other {{value} games whose MSSP report we hold}} + the ecosystem dashboard: shares, never totals, and never a share without its set + + + Oldest handshake here: confirmed {age} ago. + the ecosystem dashboard: shares, never totals, and never a share without its set + + + {count, number} of {total, number} ({fraction, number, ::percent .0}) + the ecosystem dashboard: shares, never totals, and never a share without its set + + + {count, number} of {total, number} — nothing measured yet + the ecosystem dashboard: shares, never totals, and never a share without its set + + + {fraction, number, ::percent .0} + the ecosystem dashboard: shares, never totals, and never a share without its set + + + Codebases + the ecosystem dashboard: shares, never totals, and never a share without its set + + + Of the {listed, plural, one {# game} other {# games}} listed, {identified, number} told us what they run, and every share below is over those {identified, number}. A codebase we could not read is left out of the denominator, never counted as something else. + the ecosystem dashboard: shares, never totals, and never a share without its set + + + No listed game has told us its codebase yet. + the ecosystem dashboard: shares, never totals, and never a share without its set + + + {share} run a codebase no other listed game runs — one game each, which is a name rather than a share. They are inside the denominator above and folded out of the bars, not dropped: + the ecosystem dashboard: shares, never totals, and never a share without its set + + + Lineages + the ecosystem dashboard: shares, never totals, and never a share without its set + + + The same games, grouped by the tradition their server descends from — our reading of the codebase, not anything a game published. No game reports "MUSH": MSSP has no such value, and most of the MUSH world publishes no MSSP at all, so this is the only way the question can be asked. + the ecosystem dashboard: shares, never totals, and never a share without its set + + + No listed game runs a codebase we place in a lineage yet. + the ecosystem dashboard: shares, never totals, and never a share without its set + + + {count, plural, one {# of those games runs a codebase} other {# of those games run codebases}} we do not place in any lineage — several say as much themselves, publishing {family}. They are inside the denominator above and in nobody's share. + the ecosystem dashboard: shares, never totals, and never a share without its set + + + Protocols + the ecosystem dashboard: shares, never totals, and never a share without its set + + + Read every measured figure below as a floor. We ask for MSSP by name, so silence there is an answer. Nothing else here is requested, and a server may support a protocol without ever offering it. + the ecosystem dashboard: shares, never totals, and never a share without its set + + + {instrument} is the one row below that is not a floor: we ask every server for it by name, so the games that did not offer it were asked and declined. It is also the only one with no declared figure, because every game whose report we hold supports it by demonstration and a count of the ones that also listed it would measure a habit against that. + the ecosystem dashboard: shares, never totals, and never a share without its set + + + We hold {reports, number} reports and {offered, plural, one {# game} other {# games}} offer MSSP today: the other {gap, number} stopped publishing one after we read it, and a report is not thrown away because it stopped being reissued. + the ecosystem dashboard: shares, never totals, and never a share without its set + + + Protocol adoption. Measured is what a server offered in a completed handshake; declared is what its MSSP claims. Two sets of games, so two denominators. + the ecosystem dashboard: shares, never totals, and never a share without its set + + + protocol + the ecosystem dashboard: shares, never totals, and never a share without its set + + + measured — of {basis} + the ecosystem dashboard: shares, never totals, and never a share without its set + + + declared — of {basis} + the ecosystem dashboard: shares, never totals, and never a share without its set + + + not measured — never observed + the ecosystem dashboard: shares, never totals, and never a share without its set + + + {share} · {declined, plural, one {# game} other {# games}} declined when asked + the ecosystem dashboard: shares, never totals, and never a share without its set + + + {share} · {unobserved, plural, one {# game} other {# games}} neither offered nor asked + the ecosystem dashboard: shares, never totals, and never a share without its set + + + {share} · {declined, plural, one {# game} other {# games}} declined when asked · {unobserved, plural, one {# game} other {# games}} neither offered nor asked + the ecosystem dashboard: shares, never totals, and never a share without its set + + + not asked — every report here is the answer + the ecosystem dashboard: shares, never totals, and never a share without its set + + + Adoption over time + the ecosystem dashboard: shares, never totals, and never a share without its set + + + Each point is a share over the games we had measured that day, so this line moves for two reasons: a game changing what it offers, and the set of games we can measure changing around it. Only the first is adoption. The transition count below is the part that is purely games changing their minds. + the ecosystem dashboard: shares, never totals, and never a share without its set + + + Measured share of each protocol, oldest reading first + the ecosystem dashboard: shares, never totals, and never a share without its set + + + then + the ecosystem dashboard: shares, never totals, and never a share without its set + + + now + the ecosystem dashboard: shares, never totals, and never a share without its set + + + not measured + the ecosystem dashboard: shares, never totals, and never a share without its set + + + A snapshot, not a curve + the ecosystem dashboard: shares, never totals, and never a share without its set + + + A snapshot of what we can measure now. An adoption curve plots games changing their minds, and we record a change when it happens, so the curve becomes drawable once enough have been recorded. Plotting when we first reached each game would measure the crawl, not the hobby. + the ecosystem dashboard: shares, never totals, and never a share without its set + + + {count, plural, one {# capability change} other {# capability changes}} recorded so far — the material a curve is drawn from. + the ecosystem dashboard: shares, never totals, and never a share without its set + + + No measured capability has changed yet, so there is nothing to plot. + the ecosystem dashboard: shares, never totals, and never a share without its set + + + Measured is of {measured}; declared is of {declared}. Two sets of games, so two denominators. + the ecosystem dashboard: shares, never totals, and never a share without its set + + + {listed} · {handshakes} · {mssp}. + the ecosystem dashboard: shares, never totals, and never a share without its set + + + The oldest handshake in this picture was last confirmed {age} ago. + the ecosystem dashboard: shares, never totals, and never a share without its set + + + The same games, grouped by the tradition their server descends from. This is {evidence} — {meaning} — and not anything a game published: no game reports "MUSH", because MSSP has no such value and most of the MUSH world publishes no MSSP at all. + the ecosystem dashboard: shares, never totals, and never a share without its set + + + measured: {value} + the ecosystem dashboard: shares, never totals, and never a share without its set + + + declared: {value} + the ecosystem dashboard: shares, never totals, and never a share without its set + + + Rankings + the rankings: computed from measured data only, and it says so first + + + Computed from measured data only. No votes, stars or ratings, ever. Nothing here ranks quality. We have not measured it. + the rankings: computed from measured data only, and it says so first + + + Busiest, by measured concurrent players + the rankings: computed from measured data only, and it says so first + + + Ranking window + the rankings: computed from measured data only, and it says so first + + + {days, plural, one {# day} other {# days}} + the rankings: computed from measured data only, and it says so first + + + Median of the player counts we measured over the last {days, plural, one {# day} other {# days}}. + the rankings: computed from measured data only, and it says so first + + + No game yet has the {samples, number} samples across {days, plural, one {# day} other {# days}} that a median needs. + the rankings: computed from measured data only, and it says so first + + + {eligible, plural, one {# game} other {# games}} of {listed, number} have the {samples, number} samples across {days, plural, one {# day} other {# days}} it needs. + the rankings: computed from measured data only, and it says so first + + + A measured zero counts; an unreadable count does not. + the rankings: computed from measured data only, and it says so first + + + A week says who is busy now; a quarter says who has been busy. They are different questions and a game can lead one and not the other. Days are whole days, UTC. + the rankings: computed from measured data only, and it says so first + + + No listed game has enough counted samples to rank yet — a statement about how long we have been measuring, not about how busy anybody is. + the rankings: computed from measured data only, and it says so first + + + Games ranked by the median of the player counts we measured over the last {days, plural, one {# day} other {# days}}. Games on the same median share a place; nothing here breaks the tie. + the rankings: computed from measured data only, and it says so first + + + # + the rankings: computed from measured data only, and it says so first + + + game + the rankings: computed from measured data only, and it says so first + + + median + the rankings: computed from measured data only, and it says so first + + + peak + the rankings: computed from measured data only, and it says so first + + + counted samples + the rankings: computed from measured data only, and it says so first + + + days measured + the rankings: computed from measured data only, and it says so first + + + Longest unbroken reachable spell + the rankings: computed from measured data only, and it says so first + + + Every probe since the date given found the game reachable. Reachable, not up: we measure a socket from one host, and a game we cannot route to is perfectly alive. A spell cannot be longer than we have been watching, so the date is the fact and the duration follows. + the rankings: computed from measured data only, and it says so first + + + No listed game is in an unbroken reachable spell right now. + the rankings: computed from measured data only, and it says so first + + + Games whose every probe since the date given found them reachable. Games reachable since the same date share a place; nothing here breaks the tie. + the rankings: computed from measured data only, and it says so first + + + reachable since + the rankings: computed from measured data only, and it says so first + + + that is + the rankings: computed from measured data only, and it says so first + + + Archived games are out of both tables and nothing else; one successful probe puts them back. + the rankings: computed from measured data only, and it says so first + + + Busiest — median measured players, last {days, plural, one {# day} other {# days}} + the rankings: computed from measured data only, and it says so first + + + windows: + the rankings: computed from measured data only, and it says so first + + + this one + the rankings: computed from measured data only, and it says so first + + + median {median, number} · peak {peak, number} · {samples, plural, one {# counted sample} other {# counted samples}} over {days, number} of {window, number} days + the rankings: computed from measured data only, and it says so first + + + reachable on every probe since {date} · {duration} + the rankings: computed from measured data only, and it says so first + + + The archive + the archive: removed from the default listing, and from nothing else + + + Games that have stopped answering. Nothing was deleted. Still probed weekly, and one successful probe puts a game back in the listing the same day. + the archive: removed from the default listing, and from nothing else + + + search the archive + the archive: removed from the default listing, and from nothing else + + + Search archived games + the archive: removed from the default listing, and from nothing else + + + name, codebase or description + the archive: removed from the default listing, and from nothing else + + + show + the archive: removed from the default listing, and from nothing else + + + {count, plural, =0 {No archived games} one {# archived game} other {# archived games}} + the archive: removed from the default listing, and from nothing else + + + archived + the archive: removed from the default listing, and from nothing else + + + last reachable + the archive: removed from the default listing, and from nothing else + + + known live + the archive: removed from the default listing, and from nothing else + + + ({age} ago) + the archive: removed from the default listing, and from nothing else + + + Nothing matched. + the archive: removed from the default listing, and from nothing else + + + never, in anything we measured + the archive: removed from the default listing, and from nothing else + + + no reachable time measured + the archive: removed from the default listing, and from nothing else + + + unknown + the archive: removed from the default listing, and from nothing else + + + {years, number, ::.#} years + the archive: removed from the default listing, and from nothing else + + + {days, plural, one {# day} other {# days}} + the archive: removed from the default listing, and from nothing else + + + {from} – {to} · {span} + the archive: removed from the default listing, and from nothing else + + + {count, plural, one {# month} other {# months}} + the archive: removed from the default listing, and from nothing else + + + {count, plural, one {# year} other {# years}} + the archive: removed from the default listing, and from nothing else + + + {count, plural, one {# game} other {# games}} matching "{query}" + the archive: removed from the default listing, and from nothing else + + + {count, plural, one {# game} other {# games}} + the archive: removed from the default listing, and from nothing else + + + Last reachable: + the archive: removed from the default listing, and from nothing else + + + Known live: + the archive: removed from the default listing, and from nothing else + + + {value} of measured reachable time + the archive: removed from the default listing, and from nothing else + + + Run: + the archive: removed from the default listing, and from nothing else + + + Codebase: + the archive: removed from the default listing, and from nothing else + + + random game + /find's footer, which the rebuild left in English + + + {d, date, medium} — {typical} on average, {low}–{high} across {probes, plural, one {# probe} other {# probes}} + the trend chart's per-day title, one per column + + + {d, date, medium} — {count, plural, =0 {0 players} one {# player} other {# players}}, every one of {probes, plural, one {# probe} other {# probes}} + the trend chart's per-day title, one per column + + + {d, date, medium} — probed, no count could be read + the trend chart's per-day title, one per column + + + {d, date, medium} — no measurement + the trend chart's per-day title, one per column + + + Typically {typical} on, peaking at {peak}, over {counted} of {days, plural, one {# day} other {# days}}. + the sentence above the chart + + + Steady across the range. + the sentence above the chart + + + Up about {change, number, percent} from the start of the range to the end. + the sentence above the chart + + + Down about {change, number, percent} from the start of the range to the end. + the sentence above the chart + + + Probed in this range, and no player count could be read from any of it. + the sentence above the chart + + + No measurement in this range. + the sentence above the chart + + + Nothing counted in this range. + the sentence above the chart + + + {from, date, d MMM}–{to, date, d MMM} + the week lines, which are the text alternative and the plain surface both + + + {d, date, d MMM} + the week lines, which are the text alternative and the plain surface both + + + {span}: typically {typical}, peak {peak}, {days, plural, one {# day} other {# days}} counted + the week lines, which are the text alternative and the plain surface both + + + {span}: probed, no count could be read + the week lines, which are the text alternative and the plain surface both + + + {span}: not measured + the week lines, which are the text alternative and the plain surface both + + + {count, plural, one {# day probed without a count} other {# days probed without a count}} + the week lines, which are the text alternative and the plain surface both + + + {count, plural, one {# day not measured} other {# days not measured}} + the week lines, which are the text alternative and the plain surface both + + + {line}, {clause} + the week lines, which are the text alternative and the plain surface both + + + days in UTC · {from, date, medium} – {to, date, medium} + the chart's own chrome + + + {value} at the top + the chart's own chrome + + + {counted} of {days, plural, one {# day} other {# days}} counted + the chart's own chrome + + + {d, date, MMM} + the chart's own chrome + + + {d, date, MMM yyyy} + the chart's own chrome + + + mean of the counts we read that day + the chart's own chrome + + + up to the busiest count that day + the chart's own chrome + + + lowest to highest count that day + the chart's own chrome + + + probed, no count could be read + the chart's own chrome + + + not measured — no bar at all + the chart's own chrome + + + not measured — a break in the line + the chart's own chrome + + + Trend range + seeking and switching, which are links because the range is in the address + + + Trend shape + seeking and switching, which are links because the range is in the address + + + {days, plural, one {# day} other {# days}} + seeking and switching, which are links because the range is in the address + + + ← earlier + seeking and switching, which are links because the range is in the address + + + later → + seeking and switching, which are links because the range is in the address + + + line + seeking and switching, which are links because the range is in the address + + + bars + seeking and switching, which are links because the range is in the address + + + How many, over time + seeking and switching, which are links because the range is in the address + + + {from, date, medium} – {to, date, medium}, UTC + seeking and switching, which are links because the range is in the address + + + earlier + seeking and switching, which are links because the range is in the address + + + a week is summarised over the days in it we counted; a week with none says so + seeking and switching, which are links because the range is in the address + + + reachable · last {days, plural, one {# day} other {# days}} + the reachability strip + + + reachable + the reachability strip + + + longest outage + the reachability strip + + + last cause + the reachability strip + + + none in the window + the reachability strip + + + nothing recorded + the reachability strip + + + {days, plural, one {# day ago} other {# days ago}} + the reachability strip + + + today + the reachability strip + + + reachable + the reachability strip + + + degraded — answered, could not finish + the reachability strip + + + unreachable + the reachability strip + + + not measured + the reachability strip + + + reachable + the reachability strip + + + degraded + the reachability strip + + + unreachable + the reachability strip + + + not measured + the reachability strip + + + {d, date, d MMM} — reachable all day + the reachability strip + + + {d, date, d MMM} — degraded ({cause}): answered, could not finish + the reachability strip + + + {d, date, d MMM} — unreachable ({cause}) + the reachability strip + + + {d, date, d MMM} — not measured; we were not watching this game yet + the reachability strip + + + {days, plural, one {Not yet measured over the last # day.} other {Not yet measured over the last # days.}} + the reachability strip + + + Reachable {percent} of the last {days, plural, one {# day} other {# days}}. + the reachability strip + + + Reachable {percent} of the {days, plural, one {# day} other {# days}} we have measured. + the reachability strip + + + {days, plural, one {Reachability over the last # day is not yet measured.} other {Reachability over the last # days is not yet measured.}} + the reachability strip + + + No day in the window was unreachable. + the reachability strip + + + No day we measured was unreachable. + the reachability strip + + + {count, plural, one {# day unreachable.} other {# days unreachable.}} + the reachability strip + + + {count, plural, one {# day degraded — we got in and could not finish.} other {# days degraded — we got in and could not finish.}} + the reachability strip + + + Longest outage {duration}. + the reachability strip + + + Longest outage {duration} ({cause}). + the reachability strip + + + {count, plural, one {# day predates anything we measured.} other {# days predate anything we measured.}} + the reachability strip + + + {from, date, d MMM} – {to, date, d MMM} + the reachability strip + + + {d, date, d MMM} + the reachability strip + + + {range}: {count, plural, one {# day} other {# days}} {word} + the reachability strip + + + {range}: {count, plural, one {# day} other {# days}} {word} ({cause}) + the reachability strip + + + Reachable + the reachability strip + + + {days, plural, one {last # day} other {last # days}} + the reachability strip + + + Reachable: {percent} of the {days, plural, one {last # day} other {last # days}} + the reachability strip + + + Longest outage: {duration} + the reachability strip + + + dns did not resolve + why a dial did not complete, in a person's words + + + connection refused + why a dial did not complete, in a person's words + + + tls failed + why a dial did not complete, in a person's words + + + timed out + why a dial did not complete, in a person's words + + + handshake stalled + why a dial did not complete, in a person's words + + + no cause recorded + why a dial did not complete, in a person's words + + + The owner asked us not to republish this game's connect screen. + the ANSI capture's frame + + + No connect screen has been captured from this game. + the ANSI capture's frame + + + {count, plural, one {Only # row came back — too little to show.} other {Only # rows came back — too little to show.}} + the ANSI capture's frame + + + as sent by the server + the ANSI capture's frame + + + {columns}×{rows} + the ANSI capture's frame + + + {columns}×{rows}, double-width + the ANSI capture's frame + + + 16-colour SGR + the ANSI capture's frame + + + no colour + the ANSI capture's frame + + + read as {charset} + the ANSI capture's frame + + + captured + the ANSI capture's frame + + + frozen — the last screen we saw + the ANSI capture's frame + + + ASCII art: the connect screen of {game}. Its text is under "read as text", below. + the ANSI capture's frame + + + ASCII art: this game's connect screen. Its text is under "read as text", below. + the ANSI capture's frame + + + {count, plural, one {connect screen: # line, text only} other {connect screen: # lines, text only}} + the ANSI capture's frame + + + {count, plural, one {connect screen: # line, text only, read as {charset}} other {connect screen: # lines, text only, read as {charset}}} + the ANSI capture's frame + + + the game declares it, and the server has never offered it in a handshake. + the capability matrix's disagreements, said in prose + + + the server offers it, and the game's own record says it does not. + the capability matrix's disagreements, said in prose + + + Usually a stale hand-typed field, not a lie. Shown because a client should not rely on what the two disagree about. + the capability matrix's disagreements, said in prose + + + Not found + the rest of the game page + + + No game at this address. Check the spelling. + the rest of the game page + + + last answered + the rest of the game page + + + {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 + + + This game's own referral list names: + the rest of the game page + + + Named by the referral list of: + the rest of the game page + + + since {date} + the rest of the game page + + + no longer listed, last seen + the rest of the game page + + + Last answered {date}, {ago} ago. + the rest of the game page + + + Still probed weekly; this page updates the day it answers. + the rest of the game page + + + {span} known live + the rest of the game page + + + Not in the listing, the rankings or the daily figure: we do not think this address is a game somebody can play. Everything below is what it told us, unchanged. + the rest of the game page + + + Our reason: {why} + the rest of the game page + + + Not in the listing, the rankings or the daily figure, at the request of the people who run it. Everything below is preserved as it was, this page and every address it has ever had go on answering, and we are not dialling it. + the rest of the game page + + + Players now: {count} + the plain surface's own headings for this page + + + Capabilities ({disagreeing, plural, one {# of {total} disagrees} other {# of {total} disagree}}) + the plain surface's own headings for this page + + + Declared by the game + the plain surface's own headings for this page + + + Connect screen + the plain surface's own headings for this page + + + What changed + the plain surface's own headings for this page + + + measured + the plain surface's own headings for this page + + + declared + the plain surface's own headings for this page + + + ** disagree + the plain surface's own headings for this page + + + connected + appended block: the listing row's own labels and the last three chips + + + reached + appended block: the listing row's own labels and the last three chips + + + search + appended block: the listing row's own labels and the last three chips + + + archived + appended block: the listing row's own labels and the last three chips + + + adult + appended block: the listing row's own labels and the last three chips + + + included + appended block: the listing row's own labels and the last three chips + + + no count + added by the accessibility review, second pass + + + no current count, and nothing here says why + added by the accessibility review, second pass + + + Players now: no count (nothing here says why) + added by the accessibility review, second pass + + + crawler live · last probe {age} + the crawler strip, whose sentence was English around a localized age + + + crawler quiet · last probe {age} + the crawler strip, whose sentence was English around a localized age + + + no probe has finished here yet + the crawler strip, whose sentence was English around a localized age + + + nothing due this cycle + the crawler strip, whose sentence was English around a localized age + + + {considered, plural, one {# due} other {# due}} · {answered, plural, one {# answered} other {# answered}} · {failed, plural, one {# failed} other {# failed}} + the crawler strip, whose sentence was English around a localized age + + + {targets, plural, one {# address in the registry} other {# addresses in the registry}}, {due, plural, one {# due now} other {# due now}} + the crawler strip, whose sentence was English around a localized age + + + {page} — {site} + what this site says about itself where it is not this site + + + A directory of the MU* hobby — MUSHes, MUDs, MUCKs, MOOs — where every fact carries how it was obtained and how old it is. + what this site says about itself where it is not this site + + + Demo data — nothing here was measured. {description} + what this site says about itself where it is not this site + + + {site} — measured, not asserted + what this site says about itself where it is not this site + + + {title} on {site} + what this site says about itself where it is not this site + + + Games + what this site says about itself where it is not this site + + + The archive + what this site says about itself where it is not this site + + + Rankings + what this site says about itself where it is not this site + + + The ecosystem + what this site says about itself where it is not this site + + + Reference + what this site says about itself where it is not this site + + + About + what this site says about itself where it is not this site + + + Not found + what this site says about itself where it is not this site + + + Random game + what this site says about itself where it is not this site + + + Your games + what this site says about itself where it is not this site + + + Claim {game} + what this site says about itself where it is not this site + + + Every MU* we have reached, faceted on what we measured: codebase, the protocols a server offered in the handshake, TLS, charset, language, and when we last got in. + what this site says about itself where it is not this site + + + The games that went dark, kept. Each keeps its page, history and URL, is still probed weekly, and returns to the listing on one successful connection. + what this site says about itself where it is not this site + + + Busiest, most reachable, longest running — computed from measurements only. No votes, stars or ratings anywhere on this site. + what this site says about itself where it is not this site + + + Codebase share and protocol adoption across the games we measure, with what servers offer set beside what they declare. Shares, never totals. + what this site says about itself where it is not this site + + + Hand-written pages on the codebases, clients and protocols of the MU* hobby, cross-linked to counts taken from the crawl. + what this site says about itself where it is not this site + + + How this catalogue is built: what the crawler does, what it refuses to do, and how to make it stop. + what this site says about itself where it is not this site + + + No game at this address. Nothing here is ever deleted, so a game that once lived at this URL still does — check the spelling. + what this site says about itself where it is not this site + + + One game from the catalogue, chosen at random and never the same one twice. + what this site says about itself where it is not this site + + + The listings you have claimed, and what a claim lets you change. + what this site says about itself where it is not this site + + + Prove you run this game by publishing a token where only its operator could put it. + what this site says about itself where it is not this site + + + Archived — last reachable {age}, and still probed + what this site says about itself where it is not this site + + + Archived, and still probed + what this site says about itself where it is not this site + + + Player count unknown — the game answers, and publishes no number we can read + what this site says about itself where it is not this site + + + {count, plural, one {# player} other {# players}}, {how} {age} + what this site says about itself where it is not this site + + + Newly discovered + the plain mirror's feed headings and home counts + + + Went dark + the plain mirror's feed headings and home counts + + + Came back + the plain mirror's feed headings and home counts + + + {count, plural, one {# game known} other {# games known}} + the plain mirror's feed headings and home counts + + + {count, plural, one {# connected now (measured)} other {# connected now (measured)}} + the plain mirror's feed headings and home counts + + + {count, plural, one {# answering, uncounted} other {# answering, uncounted}} + the plain mirror's feed headings and home counts + + + {count, plural, one {# archived, still probed} other {# archived, still probed}} + the plain mirror's feed headings and home counts + + + Reference + THE REFERENCE SECTION'S CHROME + + + What the codebases are, what the clients do, and what the protocols mean. Written by hand and kept in the repository beside the crawler — not a wiki, and there is nothing on this page to edit. Every {number} here is a different thing: it comes from the catalogue and is recomputed each time you load the page. + THE REFERENCE SECTION'S CHROME + + + number + THE REFERENCE SECTION'S CHROME + + + Hand-written, single-author, and versioned in git. The prose here is ours; every number beside it was measured by the crawler and is recomputed on each request. This is not a wiki, and there is no way to edit it from this page. + THE REFERENCE SECTION'S CHROME + + + Start here + THE REFERENCE SECTION'S CHROME + + + Codebases + THE REFERENCE SECTION'S CHROME + + + Clients + THE REFERENCE SECTION'S CHROME + + + Protocols + THE REFERENCE SECTION'S CHROME + + + orientation + THE REFERENCE SECTION'S CHROME + + + codebase + THE REFERENCE SECTION'S CHROME + + + client + THE REFERENCE SECTION'S CHROME + + + protocol + THE REFERENCE SECTION'S CHROME + + + Not found + THE REFERENCE SECTION'S CHROME + + + No reference page here. This section is hand-written, so a gap is work nobody has done rather than something that was removed — {index}. + THE REFERENCE SECTION'S CHROME + + + see what there is + THE REFERENCE SECTION'S CHROME + + + Games running it + a codebase page's measured half + + + We have not identified any yet. That is a fact about what this crawler has measured, not about what exists — a game we have not reached, or whose codebase we could not read, is not counted here. + a codebase page's measured half + + + {count, plural, one {# listed} other {# listed}} + a codebase page's measured half + + + {count, plural, one {# archived} other {# archived}} + a codebase page's measured half + + + measured, never asserted + a codebase page's measured half + + + Counted from the catalogue on this request, over the same filter the link above carries — so this number and that listing are one query and cannot drift apart. + a codebase page's measured half + + + offered in their handshakes: {protocols} + a codebase page's measured half + + + Measured adoption + a protocol page's measured half + + + Nothing measured yet. + a protocol page's measured half + + + of {listed, plural, one {# listed game} other {# listed games}} — {percent} + a protocol page's measured half + + + The games not counted here are not games without the protocol. A game is counted when we observed its server offering the option in a handshake; the rest are servers that did not offer it to us and servers whose handshake we have not read, and we cannot tell you which. + a protocol page's measured half + + + Games observed offering {protocol} in a handshake, by the codebase we identified them as running. + a protocol page's measured half + + + codebase + a protocol page's measured half + + + offered it + a protocol page's measured half + + + identified + a protocol page's measured half + + + See also + a protocol page's measured half + + + Capabilities + the client capability matrix, which is documentation rather than measurement + + + {count, plural, one {# of {total} established from the project's own documentation} other {# of {total} established from the project's own documentation}} + the client capability matrix, which is documentation rather than measurement + + + Read off each project's own documentation, not measured by us — a client has no handshake for us to observe. "{unknown}" means we looked and did not establish it. It never means no. + the client capability matrix, which is documentation rather than measurement + + + Client capabilities, each read off the project's own documentation. Unknown means we did not establish it, and never that the client lacks it. + the client capability matrix, which is documentation rather than measurement + + + documented + the client capability matrix, which is documentation rather than measurement + + + source + the client capability matrix, which is documentation rather than measurement + + + we did not find one + the client capability matrix, which is documentation rather than measurement + + + yes + the client capability matrix, which is documentation rather than measurement + + + no + the client capability matrix, which is documentation rather than measurement + + + unknown + the client capability matrix, which is documentation rather than measurement + + + Runs on: {platforms} + the plain mirror's own wording + + + Games we have identified as running this codebase + the plain mirror's own wording + + + None yet. That is a statement about what we have measured, not about what exists — a game we have not reached, or whose codebase we could not read, is not counted here. + the plain mirror's own wording + + + {listed, plural, one {# listed} other {# listed}}, {archived, plural, one {# archived} other {# archived}} + the plain mirror's own wording + + + Measured in their handshakes: {protocols} + the plain mirror's own wording + + + Nothing was offered in any handshake we have read from them. + the plain mirror's own wording + + + {offering} of {listed, plural, one {# listed game} other {# listed games}} were observed offering it ({percent}) + the plain mirror's own wording + + + By codebase, of the games we identified + the plain mirror's own wording + + + {offering} of {identified} offered it + the plain mirror's own wording + + + {count, plural, one {# of {total} rows is unknown} other {# of {total} rows are unknown}}: we did not find the project's own documentation saying either way. A short honest table beats a long guessed one. + the plain mirror's own wording + + + Your games + the dashboard's frame + + + Accounts need a database behind them, and this site is running on the demo fixture. + the dashboard's frame + + + Sign in + the dashboard's frame + + + Signed in as {name}. + the dashboard's frame + + + Sign out + the dashboard's frame + + + You have not claimed anything yet. Find your game in {listing} and press {claimControl} on its page. + the dashboard's frame + + + the listing + the dashboard's frame + + + claim this game + the dashboard's frame + + + Given up. + the one banner a POST comes back with + + + The record of it is kept, and you can prove control again any time by publishing a fresh token. + the one banner a POST comes back with + + + Saved. + the one banner a POST comes back with + + + That game + the one banner a POST comes back with + + + {game}'s page now shows it as owner-declared. + the one banner a POST comes back with + + + We have stopped republishing {game}'s connect screen. The page says so plainly rather than leaving a hole. + the one banner a POST comes back with + + + {game}'s connect screen is on its page again. + the one banner a POST comes back with + + + We have stopped dialling {game}, on every address we have for it. Its page keeps everything measured before you asked. + the one banner a POST comes back with + + + We are dialling {game} again, from its next turn in the schedule. + the one banner a POST comes back with + + + {game} is out of the listing, the rankings and the daily figure. Its page and every URL it has ever had go on answering. + the one banner a POST comes back with + + + {game} is back in the listing. One probe that answers is all it needs to be measured again. + the one banner a POST comes back with + + + {field} was not changed. + the one banner a POST comes back with + + + These are one-line answers; {max} characters is the most we store. + the one banner a POST comes back with + + + That field is measured. A claim lets you add what MSSP has no room for; it never lets anybody edit what we observed, and that includes us. + the one banner a POST comes back with + + + Claimed + a claimed game's block + + + verified {date} + a claimed game's block + + + verified {date}, token last seen {seen} + a claimed game's block + + + check your MSSP + a claimed game's block + + + {count, plural, one {Also owned by {names} — who verified a token of their own.} other {Also owned by {names} — each having verified a token of their own.}} + a claimed game's block + + + another account + a claimed game's block + + + put your player count on your own site + a claimed game's block + + + The badge carries the count and when we measured it, because a number with no age is the thing this site exists to replace. + a claimed game's block + + + It says {unknown} rather than nought when we could not count, and {archived} if the game stops answering. + a claimed game's block + + + There is {json} too, if you would rather draw your own. + a claimed game's block + + + history + the audit log + + + token issued + the audit log + + + token issued again + the audit log + + + verified — we read your token + the audit log + + + token still published + the audit log + + + token not read this time + the audit log + + + claim given up + the audit log + + + token expired unused + the audit log + + + another account proved control and took the game over + the audit log + + + check requested + the audit log + + + give up this claim + the audit log + + + Type {word} to confirm. Nothing is deleted and you can prove control again by publishing a fresh token; the game stays claimed if anybody else owns it. + the audit log + + + Give up {game} + the audit log + + + Waiting on a token + waiting on a token, and the passkeys + + + token issued {issued}, good until {expires} + waiting on a token, and the passkeys + + + Passkeys + waiting on a token, and the passkeys + + + unnamed + waiting on a token, and the passkeys + + + added {date} + waiting on a token, and the passkeys + + + added {date} · on one device only + waiting on a token, and the passkeys + + + This passkey lives on one device. If you lose it you can still get back in by publishing a fresh token on your game, but a second passkey is quicker. + waiting on a token, and the passkeys + + + Add another passkey + waiting on a token, and the passkeys + + + No such game + claiming a game you run + + + Claim {game} + claiming a game you run + + + Claiming needs a database behind it, and this site is running on the demo fixture. + claiming a game you run + + + You need an account first. It takes a passkey and a name. + claiming a game you run + + + Sign in or create an account + claiming a game you run + + + Your games + claiming a game you run + + + {count, plural, one {This game already has an owner who proved control of the server.} other {This game already has # owners who proved control of the server.}} You can prove it too — the test is the same either way — but we need to know what you mean by it, because we cannot tell from the token. + claiming a game you run + + + I run it too — add me as an owner + claiming a game you run + + + Everyone keeps their claim. This is two people running one game. + claiming a game you run + + + I have taken it over — transfer it to me + claiming a game you run + + + {count, plural, one {When your token verifies, the existing claim is revoked and the game is yours.} other {When your token verifies, the existing claims are revoked and the game is yours.}} They will see why in their own history. Nothing is deleted, and they can prove control again the same way you are about to. + claiming a game you run + + + Verified. + claiming a game you run + + + We read your token from the game's MSSP report on {date}. + claiming a game you run + + + We read your token from the connect screen on {date}. + claiming a game you run + + + Leave the token where it is. It doubles as an identity signal, so this game stays recognisable if it moves host or changes name. Removing it will not un-claim you. + claiming a game you run + + + Publish this token anywhere the game shows it to an anonymous connection. The next probe picks it up, which proves you can write to that server. + claiming a game you run + + + This is a transfer. + claiming a game you run + + + {count, plural, one {When we read this token, the current owner's claim on this game is revoked and it becomes yours.} other {When we read this token, the current owners' claims on this game are revoked and it becomes yours.}} + claiming a game you run + + + Either of these will do + claiming a game you run + + + An MSSP variable + claiming a game you run + + + In {codebase} that is a line in {file}; every codebase with MSSP has an equivalent. + claiming a game you run + + + {aliases} are accepted too. + claiming a game you run + + + A line on the connect screen + claiming a game you run + + + Anywhere in the screen, and colour codes around it are fine. + claiming a game you run + + + Then + claiming a game you run + + + We check on the ordinary crawl schedule. This token is good until {date}. Come back any time; nothing needs writing down. + claiming a game you run + + + Look sooner + claiming a game you run + + + Brings your game to the front of the queue. We dial on our own schedule, so this is sooner rather than now. + claiming a game you run + + + Just asked. Try again in a few minutes — it is rationed because it dials a real server sooner than we would have. + claiming a game you run + + + What only you can tell us about {game} + what a claim actually grants (the owner panel, §8.5 and §11) + + + These are the things MSSP has no field for. They appear on your game's page as {declared}, with the date you last confirmed them, beside what we measured — never instead of it. Nothing measured can be edited from here, by you or by us. + what a claim actually grants (the owner panel, §8.5 and §11) + + + declared {age}. Empty this box to withdraw it — the record of what it said is kept either way. + what a claim actually grants (the owner panel, §8.5 and §11) + + + Save what you declared + what a claim actually grants (the owner panel, §8.5 and §11) + + + What {game} reports, and what you would rather we showed + what a claim actually grants (the owner panel, §8.5 and §11) + + + Your MSSP is what every crawler reads, and we go on showing it beside anything you put here — an answer of yours does not hide one of your game's. + what a claim actually grants (the owner panel, §8.5 and §11) + + + Nothing measured can be edited from here: not a player count, not a capability, not an hour of reachability. + what a claim actually grants (the owner panel, §8.5 and §11) + + + If a line below is wrong in your {file}, fixing it there fixes it everywhere. + what a claim actually grants (the owner panel, §8.5 and §11) + + + your game reports {value}, confirmed {age} + what a claim actually grants (the owner panel, §8.5 and §11) + + + your game reports nothing here + what a claim actually grants (the owner panel, §8.5 and §11) + + + Changing the name changes what {game} is listed as and the address of its page. The old address goes on working for ever — every URL this game has ever had redirects to its current one — and clearing the box hands the name back to whatever your MSSP says. + what a claim actually grants (the owner panel, §8.5 and §11) + + + Your connect screen + what a claim actually grants (the owner panel, §8.5 and §11) + + + We are not republishing it. The page says so plainly rather than leaving a hole, and the crawler goes on reading it — it is how we recognise your game if it moves. + what a claim actually grants (the owner panel, §8.5 and §11) + + + Show it again + what a claim actually grants (the owner panel, §8.5 and §11) + + + We show it because your server sends it to every anonymous connection. If you would rather we did not, say so and we stop. We will not ask why. + what a claim actually grants (the owner panel, §8.5 and §11) + + + Stop showing our connect screen + what a claim actually grants (the owner panel, §8.5 and §11) + + + Being crawled + what a claim actually grants (the owner panel, §8.5 and §11) + + + We have stopped. Nothing on {game} is dialled, and the page keeps everything measured before you asked — the empty hours name no cause, because {ourFact} is our fact and not a measurement of your game. + what a claim actually grants (the owner panel, §8.5 and §11) + + + you asked us to stop + what a claim actually grants (the owner panel, §8.5 and §11) + + + Start crawling us again + what a claim actually grants (the owner panel, §8.5 and §11) + + + This one came from your own server rather than from here — {routes}. To be crawled again, stop publishing it; we will hear that on the next cycle. + what a claim actually grants (the owner panel, §8.5 and §11) + + + your MSSP report publishes {variable} + what a claim actually grants (the owner panel, §8.5 and §11) + + + a {label} TXT record asks us to stop + what a claim actually grants (the owner panel, §8.5 and §11) + + + a request we recorded + what a claim actually grants (the owner panel, §8.5 and §11) + + + We have stopped on {stopped} and are still dialling {dialling}. That is usually a port added after the opt-out. + what a claim actually grants (the owner panel, §8.5 and §11) + + + Stop on every address too + what a claim actually grants (the owner panel, §8.5 and §11) + + + We dial {game} on a schedule and read what any anonymous connection is shown. If you would rather we did not, say so and we stop — within one cycle, on every address we have for you, and we will not ask why. + what a claim actually grants (the owner panel, §8.5 and §11) + + + Nothing already measured is deleted: your page keeps its history and its URL, and one probe after you take this back starts it again. You can also say it without us, in your own config — {mssp} in MSSP, or a {dns} TXT record — and we honour those whether or not anybody has ever claimed the game here. + what a claim actually grants (the owner panel, §8.5 and §11) + + + Stop crawling us + what a claim actually grants (the owner panel, §8.5 and §11) + + + Being listed + what a claim actually grants (the owner panel, §8.5 and §11) + + + {game} is out of the listing, out of the rankings and out of the daily figure. Its page and every URL it has ever had go on answering, and everything measured before you asked is still on it. Nothing was deleted; it is simply not somewhere a reader arrives by browsing. + what a claim actually grants (the owner panel, §8.5 and §11) + + + Put us back in the listing + what a claim actually grants (the owner panel, §8.5 and §11) + + + One probe that answers does this too. While your opt-out stands we do not dial, so nothing will — but the day you take it back, the address comes up within a week and the probe that gets an answer puts you back. You do not have to ask us twice. + what a claim actually grants (the owner panel, §8.5 and §11) + + + We have stopped dialling you, and your page is still in the listing with what we measured before that. If you would rather it were not, say so and it comes out — of the listing, the rankings and the daily figure. + what a claim actually grants (the owner panel, §8.5 and §11) + + + Nothing is deleted and nothing breaks: the page answers, every URL it has ever had still redirects to it, and anyone you send there sees it. It stops being somewhere a reader can arrive by browsing. Reversible from here, and by any probe that answers after you take your opt-out back. + what a claim actually grants (the owner panel, §8.5 and §11) + + + Take us out of the listing too + what a claim actually grants (the owner panel, §8.5 and §11) + + + what we could measure + what we could measure (the two measurement switches, spec §9) + + + Hiding these takes them out of your listing; it does not mean the game is empty. + what we could measure (the two measurement switches, spec §9) + + + could not count + what we could measure (the two measurement switches, spec §9) + + + could not reach + what we could measure (the two measurement switches, spec §9) + + + hidden from this listing + what we could measure (the two measurement switches, spec §9) + + + hidden from this listing + what we could measure (the two measurement switches, spec §9) + + + In the left column, * is a value this listing is filtered to and - is one it is filtered against. Both are choices in the query, not facts about a game. + what we could measure (the two measurement switches, spec §9) + + diff --git a/src/MUI.Web/Resources/Messages.zh-Hans.resx b/src/MUI.Web/Resources/Messages.zh-Hans.resx new file mode 100644 index 0000000..a303b42 --- /dev/null +++ b/src/MUI.Web/Resources/Messages.zh-Hans.resx @@ -0,0 +1,2877 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + {count, plural, other {#个游戏}} + + + {value},{count, plural, other {#个游戏}},仅此项 + + + {value},{count, plural, other {#个游戏}},已排除 + + + {value},{count, plural, other {#个游戏}} + + + 任意{facet},{count, plural, other {#个游戏}} + + + {count, plural, =0 {此处未列出任何游戏。} other {#个游戏,每项事实都标明它的来源。}} + + + {days, plural, other {#天}}实测 · {probes, plural, other {#次探测}} + + + {days, plural, other {#天}} · {count, plural, other {#次计数}} + + + {disagreeing, plural, =0 {全部{total}项均一致。} other {在{total}项中,有#项与游戏自述不符。}} + + + {count, plural, =0 {没有游戏符合全部回答。} other {有#个游戏符合全部回答。}} + + + 在{listed, plural, other {#个已列出的游戏}}中 · {answers, plural, =0 {未作回答} other {已回答#项}} + + + {count, plural, other {显示这#个游戏}} + + + 去掉“{answer}”——{count, plural, other {#个游戏}} + + + 清除该问题的回答:{question} + + + 查找游戏 + + + 符合全部回答 + + + {count, plural, other {游戏}} + + + 清除全部回答 + + + 重新开始 + + + {count, plural, other {另有#项}} + + + 已回答 + + + 全部列表 + + + 该查询已被拒绝 + + + 名称(若有) + + + 名称或部分名称 + + + 按名称搜索 + + + 现在有人在玩吗? + + + 你想玩什么? + + + 哪种游戏? + + + 使用哪种语言? + + + 你的客户端有什么需求吗? + + + 是否包含已失联的游戏? + + + 不限 + + + 任意题材 + + + 任意类型 + + + 任意语言 + + + 不限 + + + 否,仅未失联的游戏 + + + 是,也一并显示 + + + 已失联的游戏 + + + TLS——已加密,握手由我们完成 + + + MSSP——服务器自述 + + + MCCP——输出压缩 + + + MXP——可点击链接 + + + GMCP——结构化客户端数据 + + + MSDP——结构化客户端数据 + + + CHARSET——编码协商 + + + UTF-8——非拉丁文本可正常显示 + + + TTYPE——客户端自述类型 + + + ATCP——结构化客户端数据 + + + MSP——声音触发 + + + EOR——提示符标记 + + + {token}——握手中实测 + + + 实测 + + + 实测 + + + 实测 + + + 实测 + + + 实测 + + + 自述 + + + 自述 + + + 自述 + + + 自述 + + + 推算 + + + 推算 + + + 未实测 + + + 未能计数 + + + 无法连通 + + + 未计数 + + + 无计数 + + + 自本站 + + + 已归档 + + + 已认领 + + + 没有可挑的游戏 + + + 没有游戏符合该筛选。试试{listing},或者{archive}。 + + + 全部列表 + + + 把归档也算上 + + + 连接数 + + + 无人认领 + + + 已由所有者认领 + + + 仍在探测 + + + 典型值 + + + 峰值 + + + 以文本形式阅读 + + + 纯文本 + + + 跳至主要内容 + + + ASCII 横幅:{game} 的连接画面。 + + + 目录 + + + 本站与账户 + + + 浏览 + + + 了解 + + + 本站 + + + 菜单 + + + 游戏 + + + 查找 + + + 随机 + + + 归档 + + + 参考 + + + 生态 + + + 排行 + + + 关于 + + + 提交 + + + 提交游戏 + + + 登录 + + + 我的游戏 + + + 主题 + + + 自动 + + + 浅色 + + + 深色 + + + 演示数据。 + + + 未配置数据库,因此这是一份样例。此处没有任何内容经过实测。 + + + 全部游戏 + + + 归档 + + + 游戏自述 + + + 变更记录 + + + MU* 爱好者的游戏目录 + + + 每一项事实都标明它的来源与时间:由我们的爬虫实测,或由游戏自述并明确标出。 + + + 按名称、主题、代码库或主机搜索游戏 + + + 按名称、主题、代码库或主机搜索 + + + 搜索 + + + 已知游戏 + + + 当前连接数 + + + 有应答,未能计数 + + + 已归档 + + + 新发现 + + + 已失联——仍在探测 + + + 已恢复 + + + 没有新发现。 + + + 没有游戏失联。 + + + 没有游戏恢复。我们仍会继续叩门。 + + + 实时 + + + 游戏 + + + 按{order}排序 + + + 随机 + + + 连接数 · 连通 + + + 自本站 + + + 没有匹配结果。 + + + 试试减少关键词,或去掉一个筛选条件。 + + + 清除筛选 + + + 关于 {codebase} + + + 从未 + + + 已由所有者认领 + + + 未知代码库 + + + 我们无法识别该游戏运行的代码库 + + + 另有{count, plural, other {#项}}:{names} + + + 排序 + + + 时间窗 + + + 当前 + + + 典型值 + + + 峰值 + + + 名称 + + + 连通 + + + 7天 + + + 30天 + + + 90天 + + + 搜索游戏 + + + 搜索游戏 + + + 筛选 + + + 当前显示 + + + 全部清除 + + + ——取消此项筛选 + + + 任意 + + + 更多筛选({count}) + + + {count, plural, other {另有#项}} + + + 同时显示 + + + 默认关闭。两者都不是对游戏的评判。 + + + 已归档 + + + 成人 + + + 已归档游戏,{shown, select, true {显示} other {隐藏}} + + + 自述含成人内容的游戏,{shown, select, true {显示} other {隐藏}} + + + 计数均为我们实测到的游戏,绝非估算。 + + + 徽标与留空的含义 + + + 留空是我们测量中的缺口,而不是否定。每个分面各有说法:未识别、未自述、无协商结果。 + + + 实测到的零是一个计数。未知的计数不是零,排序时也绝不按零处理。 + + + 开放式分面只列出最常见的 {count} 个值。其余的可通过搜索和 URL 访问。 + + + 未勾选表示未实测,而不是游戏不具备该项。 + + + 活跃度 + + + 最近可见 + + + 提供的协议 + + + 加密 + + + 编码 + + + 代码库 + + + 版本 + + + 谱系 + + + 家族 + + + 题材 + + + 语言 + + + 当前有连接 + + + 本周活跃 + + + 冷清——没有高于 0 的计数 + + + 失联——一个月未连通 + + + 已归档 + + + 24 小时内 + + + 7 天内 + + + 30 天内 + + + 更早 + + + 从未连通 + + + 无协商结果 + + + 未识别 + + + 未自述 + + + 已通过 TLS 连接 + + + 非{value} + + + 有协商结果 + + + 已识别 + + + 有自述 + + + 我们亲眼观测到 + + + 游戏这样说,我们未加核实 + + + 我们对游戏所述内容做了归类 + + + 名称 + + + 当前连接数 + + + 最近连通 + + + 典型连接数 · 7天 + + + 典型连接数 · 30天 + + + 典型连接数 · 90天 + + + 同时最多连接 · 7天 + + + 同时最多连接 · 30天 + + + 同时最多连接 · 90天 + + + 当前行内 + + + 典型值 + + + 峰值 + + + 未知计数 + + + 从未连通过——并非只是很久未连通 + + + 窗口内计数少于 {minimum} 次,或完全没有——并非典型连接数为零 + + + 窗口内没有任何我们能计到的数——并非无人连接的游戏 + + + 中位 {value} · {days, plural, other {#天}} · {count, plural, other {#次计数}} + + + 同时最多 {value} · {days, plural, other {#天}} · {count, plural, other {#次计数}} + + + 连接画面 + + + 按小时的连接数 + + + 人数随时间变化 + + + 可连通 + + + 变更记录 + + + 能力 + + + 游戏自述 + + + 转介 + + + 无人认领——此处的一切均为实测。 + + + 已由所有者认领——下方的实测事实仍出自我们。 + + + 认领此游戏 + + + 自 {date} 起有应答 + + + 以文本形式阅读——{count, plural, other {#行}} + + + 能力 + + + 距今 + + + 已提供 + + + 静默 + + + 缺失 + + + 已拒绝 + + + 自述有 + + + 不一致 + + + 不一致之处({count}) + + + {day} {time}——{count, plural, =0 {0人连接,实测} other {平均#人连接}} + + + {day} {time}——已探测,未能读取计数 + + + {day} {time}——该小时未实测 + + + 我们尚未实测该游戏的活跃度。 + + + 一周中没有任何一个小时得出过连接人数。 + + + 每个小时都已实测,其中没有任何一个小时有人连接。 + + + {count, plural, other {{day}有#小时尚未实测。}} + + + {count, plural, other {一周中有#小时尚未实测。}} + + + {count, plural, other {{day}有#小时有应答,但未能计数。}} + + + {count, plural, other {一周中有#小时有应答,但未能计数。}} + + + 每天最热闹,{window}。 + + + 每天{part}最热闹,{window}。 + + + {days}最热闹,{window}。 + + + {days}{part}最热闹,{window}。 + + + {who}一贯冷清,{window}。 + + + {who}的{part}一贯冷清,{window}。 + + + 每天 + + + 我们实测到的每一天 + + + 工作日 + + + {days} + + + 上午 + + + 下午 + + + 晚上 + + + 凌晨 + + + 上午 + + + 下午 + + + 晚上 + + + 凌晨 + + + {list}、{next} + + + {first}和{second} + + + 实测次数尚不足 + + + 一周中尚无任何一个小时有过实测。 + + + {count, plural, other {#小时有应答,但未能计数。}} + + + 待一周七天每天都有实测后,网格才会显示。 + + + {days, plural, other {七天中已实测#天;待每天都有实测小时后,网格才会显示。}} + + + {count, plural, other {已实测#小时,其中始终无人连接。}} + + + {count, plural, other {已实测#小时,最热闹的是{day} {time} UTC,{peak}人连接。}} + + + {count, plural, other {另有#小时有应答,但未能计数。}} + + + {day}——{facts} + + + {first},{second} + + + 全天实测为零 + + + {time}峰值{count}人 + + + {window}无人连接 + + + 各小时均无计数 + + + {count, plural, other {#小时未实测}} + + + {count, plural, other {#小时已探测但未能计数}} + + + 星期 + + + 最冷清 + + + 最热闹 + + + 时间 + + + 无计数 + + + 按天显示的连接人数,时间为 UTC。{window}。 + + + 时间为 UTC · {window} + + + {weeks, plural, other {#周滚动平均}} + + + 已计数,含实测为零 + + + 已探测,未能读取计数 + + + 该小时未实测 + + + 有人连接的时段(UTC) + + + 已计数 + + + 我们连上了并读到了数字,包括实测为零 + + + 我们连上了,但读不到数字 + + + 我们没有该小时的实测数据 + + + 语言 + + + 切换语言 + + + 关于 mu*index + + + 这里的每一个游戏,都由一台连入其中的机器实测得出,每一项数值都标明它的来源与时间。本页说明这能证明什么、我们哪里会出错、我们读了谁的目录,以及如何让爬虫停下。 + + + 本站所说的事实是什么 + + + 实测优先于自述,两者都会呈现。 + + + 游戏的 MSSP 报告是它对自己的描述,telnet 握手则是我们亲眼看到它做了什么。两者都会出现在它的页面上,并标注来源与时间。两者不一致时,我们把这份不一致本身呈现出来。 + + + 每一项人数计数都标明它从何而来。 + + + 要么是我们在连接画面上读到并数出的 WHO 或 DOING,要么是游戏自己发布的 MSSP PLAYERS 字段。二者从不合并。 + + + 读不出来的回应是未知,绝不是零。 + + + 服务器可以随意定制自己的 WHO 表头,超过一定程度,我们的解析器就读不出来了。那是无法计数,自成一种状态。而实测到的零——我们连上了,里面没有人——是一个计数,也按计数显示。 + + + 只说可连通,从不说在线率。 + + + 我们从一台主机上按间隔打开一个套接字。路由不到的游戏是无法连通,而它完全可能好好活着。本站不宣称任何游戏的在线率,因为本站没有测过它。 + + + 一个小时要么已计数,要么无法计数,要么未实测。 + + + 活跃度网格有三种状态。第三种是空白,并且不指明原因:我们连不上的那个小时,和我们从未探测过的那个小时,是同一种缺失,两者都不是那台服务器的停机时间。 + + + 我们知道自己哪里会出错 + + + 归档宽限期从我们发现你的那天算起。 + + + 游戏停止应答后,会在宽限期结束时离开默认列表:宽限期是我们探测到的可连通时长的四分之一,下限 60 天,上限 365 天。一个从 1995 年就在运行的游戏,在我们发现它的那天也只从下限起算。我们不会导入任何数据,去填补我们到来之前的那些年。 + + + MSSP CREATED 不计入这段宽限期。 + + + 它只是配置文件里手打的一行,若予采信,归档门槛就会变得可以操纵。它以自述的身份显示,换不来任何东西。 + + + 认领游戏可以拿到上限。 + + + 只要证明拥有服务器访问权,无论我们观察了多久,都值整整一年的宽限期。 + + + 本站的一切,都出自一台主机的间隔观测。 + + + 可连通时间的百分比,是我们观测过的那段窗口内的比例,绝不是我们没观测过的窗口的比例。本站的任何图形都不会替其余部分补上数据。 + + + 任何内容都不会被删除。 + + + 归档只是把游戏移出默认列表、排行和今日活跃数字,仅此而已。它的页面、URL、历史和地址都保留,我们仍会继续探测,一次成功的探测就能让它回来。 + + + 本站不会做的事 + + + 没有投票、星级、评分或推荐。 + + + 排行仅由实测数据算出。一个按谁能动员最多点击来排名的目录,描述的是拉票活动,而不是这个爱好本身——前辈们正是这样倒下的。 + + + 没有论坛、评测、维基、评论或玩家资料页。 + + + 入门资料——什么是 MUSH、哪种代码库适合协作扮演——和本站其余内容一样,由人撰写、署名,并纳入版本管理。 + + + 玩家名称从不留存。 + + + WHO 的回复只在内存中解析,取出一个计数和表头的形状。名称不会被记录下来;汇总使用加盐哈希,盐值会轮换。 + + + 不发布任何绝对人口数字。 + + + 我们发布按代码库和按协议的占比:在实测集合上算出的比例,即使还有我们数不出来的游戏也依然成立。“有多少人在玩 MU*”则不然,因为那个数字一经引用就站不住脚。 + + + 爬虫,以及如何让它停下 + + + 一次探测就是一个从不登录的连接。 + + + 它打开一个套接字,协商 telnet 选项,读取连接画面,通过协商选项 70 索取 MSSP,发送 {commands},然后断开。不建角色,不登录,对端没有任何改动。会话有超时上限,卡住的探测不会一直占着一个连接位。 + + + CRAWL DELAY 说了算。 + + + 游戏若在 MSSP 报告中给出希望的最小间隔,我们就照办,并在两个方向上都优先于我们自己的排期:720 小时就是每月一次,而不是每周一次。失联的游戏仍会按这个更长的间隔被一直尝试下去,它恢复之后,正是这样重新回到列表的。 + + + 转介来的地址只做核验,从不轻信。 + + + MSSP 允许一个游戏点名其他游戏。任何拨号之前,每个名称都先做解析;除非它解析出的每一个地址都是全球可路由的,否则一律拒绝。解析结果混杂时,整个目标都被拒绝。这种拒绝记在我们自己名下,绝不会作为停机出现在某个游戏的记录里。 + + + 展示连接画面,是因为它本来就发给所有人。 + + + 服务器会向每一个匿名连接、无需认证地绘出自己的连接画面。我们把它作为证据展示,并加以标注。你若开口,我们就撤下。 + + + 说停我们就停——有三种方式。 + + + 在你的 MSSP 报告中发布 {variable} 1,读到它的那次探测就是最后一次。或者在 {label}.your.host 上发布一条内容为“{value}”的 TXT 记录,这既不需要支持 MSSP,也不需要在本站注册账户。再或者,写信给一个人。这三种方式都会在一个爬取周期之内生效,并连同日期和我们读到的内容一起记录,提交表单上同样照此执行。 + + + MSSP 字段停的是那个监听端口,TXT 记录停的是整台主机。 + + + MSSP 由应答的那个端口发布,因此它只代表那个端口——MU* 托管商常常在同一个域名下运行互不相关的游戏,谁也不该替邻居闭嘴。TXT 记录默认覆盖所有端口,除非它指名某一个,写作“{value}=4201”。凡是我们读不出端口清单的写法,都按整台主机处理,所以“{value}=all”也是有效的。 + + + DNS 这条路,你不用问我们就能撤销。 + + + 读一条 TXT 记录,不必连上那台叫我们别来的服务器,所以我们在每次拨号前都会重新读它。你把它删掉,一周之内我们就会再次拨号。而 MSSP 字段不做你叫我们别做的那件事就无法重读,所以通过 MSSP 提出的退出和书面请求会一直有效,直到你另行告知。已退出的地址只会得到这一次 TXT 查询:它触及的是你的域名服务器,绝不会触及你的游戏。 + + + 停下不是删除,也不是停机。 + + + 选择退出的游戏保留它的页面、它的地址,以及它开口之前我们实测到的一切。停下的只有新数据:活跃度网格不再增加小时,并且不指明原因,因为不再叩门是我们的决定,那是关于我们的事实。它记录在那次没有发生的爬取上,也记录在提出请求者的名册里。 + + + 如果只是停下还不够,列表条目也可以撤下。 + + + 当我们在你游戏应答的每一个地址上都已停手之后,你的控制台会多出一项:把它移出列表、排行和每日数字。页面和它曾用过的每一个地址仍然照常应答,任何内容都不会被删除——它只是不再是读者靠浏览就能走到的地方。这需要一次经过验证的认领,因为这是关于你游戏的决定,我们会记录是谁做的。而一次探测就能撤销它:收回你的退出声明,下一次拨号只要得到应答,你就会重新回到列表,不必再来问我们第二次。 + + + 服务器询问来者何人时,爬虫自报为 {name}。 + + + 爬虫已配置为自称 {name},但目前还说不出口。它使用的 telnet 库没有给客户端设置终端类型的途径,所以你的日志里看到的是那个库的默认值,而 NEW-ENVIRON 是用爬虫主机的环境作答的。这两处都是那个库的缺口,该由我们去那里修补。在此之前,请靠形态辨认一次探测:一个连接,不登录,一组简短的只读命令,然后就走。 + + + 爬虫 + + + 联系方式 + + + 爬虫:{name} + + + 联系方式:{url} + + + ——占位值;本部署未设置联系地址 + + + 未配置联系地址,因此上面那个只是占位值,不会有人回应。 + + + 游戏名单从何而来 + + + 我们只取地址,别的都不取。 + + + 一次回填只取一个主机和一个端口。不取人数计数,不取连通历史,不取简介,不取字段,也不记录某个地址来自哪个站点。 + + + 刻意少于那些站点能给的。 + + + 其中有几家存着多年的带日期人数计数。把它们导进来,会用别人早已在观测的游戏去填满热力图,也会把本站最核心的主张,架在别人的探测器上。 + + + 一个游戏的出处不是单一事实。 + + + 凡是值得列出的游戏,都会同时出现在其中好几个目录里,所以“导入自”只会写上先跑完的那次抓取。某个游戏存在,这是公开信息;我们从哪里读到的,既不增添任何东西,也是别人劳动成果里最没理由算作我们的那一部分。 + + + 读别人的站点,终究是在读别人的站点。 + + + 抓取之前,我们先索要批量导出或有文档的接口,先读 robots.txt,并对抓取严格限速。凡是需要维护者点头的来源,在有人能声明已经征询过之前,一律不抓。 + + + 已读取——仅地址 + + + 未读取——等待许可 + + + 一个页面,一次请求。由一个逐个连入游戏、并打印所读内容的爬虫发布。 + + + 同一个爬虫的 MSDP 列表。几乎是其 MSSP 版本的子集,读取它只为那少数几个另一份没有覆盖到的地址。 + + + 在一个页面上发布整份目录,因此读一次只花一个请求。它是我们最大的地址来源,而不是任何实测数据的来源。 + + + 一个索引页,外加每个世界一个页面,所以只能抓取,而不能导出。2026 年 7 月 30 日,我们以十五秒的间隔、遵守 robots.txt 抓取了他们的 143 个页面,但当时还没有人写信联系过他们。这本不该发生。现在这道闸门需要有人愿意声明已经征询过维护者才会打开。 + + + 已实现,已测试,从未运行。除许可这一项之外,它在各方面都是这里最强的来源;在有人写信联系他们之前,我们不会抓取任何内容。 + + + 许可协议 + + + 代码采用 MIT 许可协议。 + + + 本站、爬虫和各个解析器,均在 MIT 许可协议下开源。 + + + 数据的许可协议尚无定论。 + + + 这是与代码分开的一项决定,目前尚未作出。请把下面的条款当作本部署当前的答复,而不是本项目已定的立场。竞争的目录把整份名录拿走,在这里算作一项成功,所以无论最终如何定夺,都不会挡它的路。 + + + 代码 + + + 数据,按本部署提供的形式 + + + 署名为 + + + 代码:{licence} + + + 数据:{licence} + + + 署名为:{credit} + + + (这是本部署提供的形式。本项目自己的答复仍未定。) + + + 提交游戏 + + + 告诉我们某个游戏在哪里。一个主机加一个端口就是整张表单;本站其余的一切,都由我们自己的爬虫实测得出。 + + + 主机 + + + 端口 + + + mud.example.org,或者粘贴 mud.example.org:4201 并把端口留空 + + + 提交 + + + 提交需要数据库,而本站正运行在演示数据上。没有可写入的爬取登记表,所以这里干脆不放表单,而不是放一个悄悄什么也不做的表单。 + + + 此处没有 + + + 一个地址会经历什么 + + + 拨号之前我们先解析地址,凡是解析到公网之外的一律拒绝。那是关于我们自己套接字的决定,绝不是关于某个游戏的事实。 + + + 如果运行那台主机的人已经要求我们不要爬取,那么无论谁提交,我们都不会接受这个地址。陌生人无法把你的游戏重新放上本站。 + + + 如果它有应答,我们就读取服务器对自己的说法,并按它自己的节奏一直读下去。一个地址只需要给我们一次。 + + + 在有人证明自己是运营者之前,站上不会出现任何内容。认领需要一个通行密钥,以及在游戏本身上发布的一行文字。 + + + 我们已经有的地址会并入现有条目。重复提交不会产生第二个条目,也不会让探测提前。 + + + 那个地址 + + + 已进入登记表。 + + + {address} 会在下一个爬取周期被拨号,此后一直按它自己的节奏进行。等有人证明自己是运营者,它就会出现在这里——带着同一个地址回到这张表单,它会把链接交给你。 + + + 我们有它,尚无人认领。 + + + {address} 是我们已经在实测的地址。在有人证明自己是运营者之前,它不会出现在站上。如果那个人就是你,这里就是入口。 + + + 这个我们已经有了。 + + + {address} 是我们已经在实测的游戏。没有创建任何内容,也没有改动任何内容。 + + + 这个地址我们已经有了。 + + + {address} 我们已经知道了。没有创建任何内容,也没有改动任何内容。 + + + 已经在等了。 + + + {address} 已在爬取登记表中,尚未应答。再次提交不会让它提前:每个目标都按自己的节奏来,所以谁也不能借我们去催别人的服务器。 + + + 这不是我们能拨号的地址。 + + + 主机名里需要有一个点或一个冒号,端口是 1 到 65535 之间的数字。请把两个框都填上,或者把 mud.example.org:4201 粘贴到第一个框里。 + + + 我们无法拨号到那里。 + + + 对 {address} 给出这个答复有三种可能:名称可能解析不了,可能解析到公网之外,也可能运行那台主机的人已经要求我们别去。我们刻意不说是哪一种,因为向陌生人回答这个问题,等于从外部把一张网络画出来。关于该地址没有记录任何内容;这个决定是我们做的,也记在我们自己名下。 + + + 暂时够了。 + + + 这张表单按提交者限速,你已经到上限了。请一小时后再来。没有丢失任何东西——我们收下的内容都已经在登记表里。 + + + 认领此游戏 + + + 登录 + + + 用通行密钥登录,即可认领你运营的游戏。没有密码可丢,也没有密码可偷。 + + + 认领需要数据库,而本站正运行在演示数据上。这里没有可登录的东西。 + + + 登录就是一个通行密钥。 + + + 私钥保存在你的设备或密码管理器里;我们只保存公钥那一半。没有密码,没有电子邮箱。 + + + 用通行密钥登录 + + + 本站唯一需要 JavaScript 的页面。没有它,通行密钥无法工作。 + + + 还没有账户? + + + 只有在认领你运营的游戏时才需要账户。取一个对外显示的名字——它是认领旁边的一个标签,不是真名。 + + + 名称 + + + 例如 corvid-admin + + + 用通行密钥创建账户 + + + 我们存什么 + + + 你选择的名称。 + + + 你注册的每个通行密钥的公钥,以及你的设备给它取的名字。 + + + 你认领了哪些游戏,以及认领的时间。 + + + 不存电子邮箱,不存密码,也不把 IP 日志与你的账户关联。即使丢失了全部通行密钥,你也可以在自己的游戏上发布一个新的认领令牌,重新来过:凭据是游戏,不是账户。 + + + {year}年{month}{day}日 + + + {date} {time} UTC + + + 刚刚 + + + {count, plural, other {#分钟}} + + + {count, plural, other {#小时}} + + + {count, plural, other {#天}} + + + {count, plural, other {#周}} + + + {count, plural, other {#个月}} + + + {count, plural, other {#年}} + + + 刚刚 + + + {count, plural, other {#分钟前}} + + + {count, plural, other {#小时前}} + + + {count, plural, other {#天前}} + + + {count, plural, other {#周前}} + + + {count, plural, other {#个月前}} + + + {count, plural, other {#年前}} + + + 刚刚 + + + {count, plural, other {#分钟前}} + + + {count, plural, other {#小时前}} + + + {count, plural, other {#天前}} + + + {count, plural, other {#周前}} + + + {count, plural, other {#个月前}} + + + {count, plural, other {#年前}} + + + {age},{stamp} + + + ,{stamp} + + + {value}——经{source}{how},最近确认于 {date} + + + {value}——经{source}{how},最近确认于 {date}(已过预期刷新期) + + + ({how},{age}) + + + ({how},{age},已过刷新期) + + + 所有者自述 + + + 本站工作人员 + + + telnet 握手 + + + 游戏所有者 + + + WHO + + + I3 + + + MSSP + + + INFO + + + I3 的游戏列表 + + + 连接画面 + + + 生态 + + + 只讲占比,绝不讲总量。我们不发布有多少人在玩 MU* 的数字:在我们实测过的游戏上算出的比例,即使还有连不上的游戏也依然成立,人头总数则不然。 + + + {count, plural, other {已列出的游戏 {value} 个}} + + + {count, plural, other {已完成握手的游戏 {value} 个}} + + + {count, plural, other {已持有 MSSP 报告的游戏 {value} 个}} + + + 此处最早的握手:{age}前确认。 + + + {total, number} 个中的 {count, number}({fraction, number, ::percent .0}) + + + {total, number} 个中的 {count, number}——尚无实测 + + + {fraction, number, ::percent .0} + + + 代码库 + + + 在已列出的{listed, plural, other {#个游戏}}中,有 {identified, number} 个告诉了我们它们运行的是什么,下面每一项占比都是相对这 {identified, number} 个算出来的。读不出代码库的游戏被排除在分母之外,绝不会被算作别的东西。 + + + 还没有已列出的游戏告诉过我们它的代码库。 + + + {share} 运行的代码库,没有任何其他已列出的游戏在用——每种各一个游戏,那是一个名字,而不是一份占比。它们计入上面的分母,只是从条形图中折叠起来,并没有被丢弃: + + + 谱系 + + + 还是这些游戏,按其服务器所承袭的传统分组——这是我们对代码库的判读,而不是任何游戏发布过的东西。没有游戏会报告“MUSH”:MSSP 里没有这个取值,而 MUSH 世界的大多数根本不发布 MSSP,所以只有这样,这个问题才问得出来。 + + + 还没有已列出的游戏运行我们能归入某个谱系的代码库。 + + + {count, plural, other {其中#个游戏运行的代码库}}我们没有归入任何谱系——有几个自己也这么说,它们发布的是 {family}。它们计入上面的分母,但不属于任何一份占比。 + + + 协议 + + + 下面每一个实测数字都应读作下限。MSSP 是我们指名索取的,所以在这一项上,沉默本身就是一种回答。其余各项我们都不索取,服务器可能支持某个协议,却从不主动提供。 + + + {instrument} 是下面唯一不算下限的一行:我们向每台服务器指名索取它,所以没有提供它的游戏,是被问过之后拒绝的。它也是唯一没有自述数字的一项,因为凡是我们持有其报告的游戏,都已用行动证明支持它,再去数其中有多少同时把它列了出来,量到的只是一种习惯。 + + + 我们持有 {reports, number} 份报告,而今天提供 MSSP 的有{offered, plural, other {#个游戏}}:另外 {gap, number} 个在我们读过之后就不再发布了,一份报告不会因为不再重发而被丢弃。 + + + 协议采用情况。实测是服务器在一次完成的握手中提供的内容,自述是它的 MSSP 所声称的内容。两组不同的游戏,因此有两个分母。 + + + 协议 + + + 实测——占 {basis} + + + 自述——占 {basis} + + + 未实测——从未观测到 + + + {share} · {declined, plural, other {#个游戏}}被问及时拒绝 + + + {share} · {unobserved, plural, other {#个游戏}}既未提供也未被问及 + + + {share} · {declined, plural, other {#个游戏}}被问及时拒绝 · {unobserved, plural, other {#个游戏}}既未提供也未被问及 + + + 未索取——这里的每一份报告本身就是回答 + + + 采用情况随时间的变化 + + + 每个点都是当天我们已实测的那些游戏上的占比,所以这条线会因两件事而移动:某个游戏改变了它提供的内容,以及我们能实测的游戏集合在它周围发生了变化。只有前者才是采用。下面的变更次数,才是纯粹由游戏改变主意构成的那一部分。 + + + 各协议的实测占比,最早的读数在前 + + + 当时 + + + 现在 + + + 未实测 + + + 这是快照,不是曲线 + + + 这是我们现在能实测到的情况的一张快照。采用曲线画的是游戏改变主意,而我们在变化发生时才记录它,所以等记录得足够多,曲线才画得出来。若改用我们首次连通各个游戏的时间来作图,量到的将是这次爬取,而不是这个爱好。 + + + {count, plural, other {迄今已记录#次能力变更}}——曲线正是用这些材料画出来的。 + + + 还没有任何实测能力发生过变化,因此无从作图。 + + + 实测的分母是 {measured},自述的分母是 {declared}。两组不同的游戏,因此有两个分母。 + + + {listed} · {handshakes} · {mssp}。 + + + 这张图里最早的握手,最近一次确认是在 {age}前。 + + + 还是这些游戏,按其服务器所承袭的传统分组。这是{evidence}——{meaning}——而不是任何游戏发布过的东西:没有游戏会报告“MUSH”,因为 MSSP 里没有这个取值,而 MUSH 世界的大多数根本不发布 MSSP。 + + + 实测:{value} + + + 自述:{value} + + + 排行 + + + 仅由实测数据算出。永远没有投票、星级或评分。这里没有任何东西是对质量的排名。我们没有测过质量。 + + + 最热闹——按实测的同时连接人数 + + + 排行时间窗 + + + {days, plural, other {#天}} + + + 最近{days, plural, other {#天}}内我们实测到的人数的中位数。 + + + 还没有游戏在{days, plural, other {#天}}内取得中位数所需的 {samples, number} 个样本。 + + + {listed, number} 个已列出的游戏中,有{eligible, plural, other {#个}}在{days, plural, other {#天}}内取得了所需的 {samples, number} 个样本。 + + + 实测到的零算数;读不出来的计数不算。 + + + 一周说的是现在谁热闹,一个季度说的是一直以来谁热闹。这是两个不同的问题,一个游戏可能在其中一项领先,而在另一项不然。天数按整天计,时间为 UTC。 + + + 还没有已列出的游戏拥有足以参与排行的已计数样本——这说的是我们测了多久,而不是谁有多热闹。 + + + 按最近{days, plural, other {#天}}内我们实测到的人数的中位数为游戏排名。中位数相同的游戏并列同一名次,这里不做任何附加排序来分先后。 + + + # + + + 游戏 + + + 中位数 + + + 峰值 + + + 已计数样本 + + + 实测天数 + + + 连续可连通时间最长 + + + 自所给日期起的每一次探测,都发现该游戏可连通。是可连通,不是在线:我们从一台主机上测一个套接字,路由不到的游戏完全可能好好活着。一段连续时长不可能超过我们观察的时长,所以日期才是事实,时长由它推出。 + + + 目前没有已列出的游戏处在连续可连通的时段中。 + + + 自所给日期起每一次探测都可连通的游戏。自同一日期起可连通的游戏并列同一名次,这里不做任何附加排序来分先后。 + + + 可连通起始 + + + 即 + + + 已归档的游戏只是不在这两张表里,仅此而已;一次成功的探测就能让它们回来。 + + + 最热闹——最近{days, plural, other {#天}}实测人数中位数 + + + 时间窗: + + + 当前这个 + + + 中位数 {median, number} · 峰值 {peak, number} · {window, number} 天中的 {days, number} 天里共有{samples, plural, other {#个已计数样本}} + + + 自 {date} 起每次探测均可连通 · {duration} + + + 归档 + + + 已经停止应答的游戏。没有删除任何内容。我们仍每周探测,一次成功的探测当天就能把游戏放回列表。 + + + 搜索归档 + + + 搜索已归档的游戏 + + + 名称、代码库或简介 + + + 显示 + + + {count, plural, =0 {没有已归档的游戏} other {#个已归档的游戏}} + + + 已归档 + + + 最近连通 + + + 已知存活 + + + ({age}前) + + + 没有匹配结果。 + + + 在我们实测过的范围内从未连通 + + + 未实测到可连通时长 + + + 未知 + + + {years, number, ::.#} 年 + + + {days, plural, other {#天}} + + + {from} – {to} · {span} + + + {count, plural, other {#个月}} + + + {count, plural, other {#年}} + + + {count, plural, other {有#个游戏}}匹配“{query}” + + + {count, plural, other {#个游戏}} + + + 最近连通: + + + 已知存活: + + + 实测可连通时长 {value} + + + 运行期: + + + 代码库: + + + 随机游戏 + + + {d, date, medium}——平均 {typical},{probes, plural, other {#次探测}}中介于 {low}–{high} + + + {d, date, medium}——{count, plural, =0 {0 人} other {#人}},{probes, plural, other {#次探测}}次次如此 + + + {d, date, medium}——已探测,未能读取计数 + + + {d, date, medium}——无实测 + + + 在{days, plural, other {#天}}中的 {counted} 天里,典型有 {typical} 人在线,峰值 {peak}。 + + + 整个区间内保持平稳。 + + + 从区间开始到结束,约上升 {change, number, percent}。 + + + 从区间开始到结束,约下降 {change, number, percent}。 + + + 本区间内有过探测,其中任何一次都未能读出在线人数。 + + + 本区间内没有实测。 + + + 本区间内没有计到任何数。 + + + {from, date, d MMM}–{to, date, d MMM} + + + {d, date, d MMM} + + + {span}:典型 {typical},峰值 {peak},已计数{days, plural, other {#天}} + + + {span}:已探测,未能读取计数 + + + {span}:未实测 + + + {count, plural, other {#天已探测但没有计数}} + + + {count, plural, other {#天未实测}} + + + {line},{clause} + + + 按 UTC 计日 · {from, date, medium} – {to, date, medium} + + + 顶端为 {value} + + + {days, plural, other {#天}}中已计数 {counted} 天 + + + {d, date, MMM} + + + {d, date, MMM yyyy} + + + 当天我们读到的各次计数的平均值 + + + 直到当天最热闹的那次计数 + + + 当天最低到最高的计数 + + + 已探测,未能读取计数 + + + 未实测——完全没有条形 + + + 未实测——线条断开一段 + + + 趋势区间 + + + 趋势图形 + + + {days, plural, other {#天}} + + + ← 更早 + + + 更近 → + + + 折线 + + + 条形 + + + 人数随时间变化 + + + {from, date, medium} – {to, date, medium},UTC + + + 更早 + + + 一周的概况按其中我们计到数的那些天汇总;一天也没有的那一周会明确说明 + + + 可连通 · 最近{days, plural, other {#天}} + + + 可连通 + + + 最长中断 + + + 最近原因 + + + 窗口内没有 + + + 未记录 + + + {days, plural, other {#天前}} + + + 今天 + + + 可连通 + + + 降级——有应答,未能完成 + + + 无法连通 + + + 未实测 + + + 可连通 + + + 降级 + + + 无法连通 + + + 未实测 + + + {d, date, d MMM}——全天可连通 + + + {d, date, d MMM}——降级({cause}):有应答,未能完成 + + + {d, date, d MMM}——无法连通({cause}) + + + {d, date, d MMM}——未实测;当时我们还没有开始观测这个游戏 + + + {days, plural, other {最近#天尚未实测。}} + + + 最近{days, plural, other {#天}}中,有 {percent} 的时间可连通。 + + + 在我们已实测的{days, plural, other {#天}}中,有 {percent} 的时间可连通。 + + + {days, plural, other {最近#天的可连通情况尚未实测。}} + + + 窗口内没有哪一天是无法连通的。 + + + 我们实测过的日子里,没有哪一天是无法连通的。 + + + {count, plural, other {#天无法连通。}} + + + {count, plural, other {#天降级——我们连上了,但未能完成。}} + + + 最长中断 {duration}。 + + + 最长中断 {duration}({cause})。 + + + {count, plural, other {有#天早于我们的任何实测。}} + + + {from, date, d MMM} – {to, date, d MMM} + + + {d, date, d MMM} + + + {range}:{word}{count, plural, other {#天}} + + + {range}:{word}{count, plural, other {#天}}({cause}) + + + 可连通 + + + {days, plural, other {最近#天}} + + + 可连通:{days, plural, other {最近#天}}中的 {percent} + + + 最长中断:{duration} + + + dns 未能解析 + + + 连接被拒绝 + + + tls 失败 + + + 超时 + + + 握手停滞 + + + 未记录原因 + + + 所有者要求我们不要再发布这个游戏的连接画面。 + + + 尚未从这个游戏捕获到连接画面。 + + + {count, plural, other {只回来了#行——太少,无法显示。}} + + + 按服务器发送的原样 + + + {columns}×{rows} + + + {columns}×{rows},双倍宽 + + + 16 色 SGR + + + 无颜色 + + + 按 {charset} 解读 + + + 已捕获 + + + 已冻结——我们最后看到的画面 + + + ASCII 图画:{game} 的连接画面。它的文本在下方的“以文本形式阅读”中。 + + + ASCII 图画:这个游戏的连接画面。它的文本在下方的“以文本形式阅读”中。 + + + {count, plural, other {连接画面:#行,仅文本}} + + + {count, plural, other {连接画面:#行,仅文本,按 {charset} 解读}} + + + 游戏自述具备该项,而服务器在握手中从未提供过它。 + + + 服务器提供该项,而游戏自己的记录说它没有。 + + + 通常只是一个久未更新的手打字段,而不是谎言。之所以显示出来,是因为客户端不该依赖两者不一致的部分。 + + + 未找到 + + + 这个地址上没有游戏。请检查拼写。 + + + 最近应答 + + + {count, plural, other {有#项已超过刷新窗口。是旧,不是错。}} + + + 这个游戏自己的转介列表点名了: + + + 被以下游戏的转介列表点名: + + + 自 {date} 起 + + + 已不再列出,最后一次见于 + + + 最近一次应答是 {date},距今 {ago}。 + + + 仍每周探测;它一应答,本页当天就会更新。 + + + 已知存活 {span} + + + 不在列表、排行或每日数字中:我们认为这个地址不是一个可供人游玩的游戏。下面的一切都是它告诉我们的内容,原样未改。 + + + 我们的理由:{why} + + + 应运营者的要求,不在列表、排行或每日数字中。下面的一切按原样保留,本页和它曾用过的每一个地址都继续应答,而我们不再向它拨号。 + + + 当前人数:{count} + + + 能力({total} 项中有 {disagreeing} 项不一致) + + + 游戏自述 + + + 连接画面 + + + 变更记录 + + + 实测 + + + 自述 + + + ** 不一致 + + + 连接数 + + + 连通 + + + 搜索 + + + 已归档 + + + 成人 + + + 已包含 + + + 无计数 + + + 当前无计数,此处也不指明原因 + + + 当前人数:无计数(此处不指明原因) + + + 爬虫运行中 · 最近探测 {age} + + + 爬虫空闲 · 最近探测 {age} + + + 此处尚未完成过任何探测 + + + 本周期无到期目标 + + + {considered, plural, other {#个到期}} · {answered, plural, other {#个应答}} · {failed, plural, other {#个失败}} + + + 登记表中有{targets, plural, other {#个地址}},{due, plural, other {其中#个已到期}} + + + {page} — {site} + + + MU* 爱好的游戏目录——MUSH、MUD、MUCK、MOO——每一项事实都标明它的来源与时间。 + + + 演示数据——此处没有任何内容经过实测。{description} + + + {site}——实测,而非自述 + + + {site} 上的 {title} + + + 游戏 + + + 归档 + + + 排行 + + + 生态 + + + 参考 + + + 关于 + + + 未找到 + + + 随机游戏 + + + 我的游戏 + + + 认领 {game} + + + 我们连通过的每一个 MU*,按实测结果分面:代码库、服务器在握手中提供的协议、TLS、编码、语言,以及我们最近一次连入的时间。 + + + 已经失联的游戏,都保留下来。每个都保有自己的页面、历史和 URL,仍每周探测,一次成功连接即可回到列表。 + + + 最热闹、最可连通、运行最久——仅由实测数据算出。本站任何地方都没有投票、星级或评分。 + + + 我们实测的游戏中,代码库的占比与协议的采用情况,服务器提供的内容与它自述的内容并列呈现。只讲占比,绝不讲总量。 + + + 关于 MU* 爱好中的代码库、客户端与协议的手写页面,并与爬取所得的计数相互链接。 + + + 这份目录是如何建成的:爬虫做什么、拒绝做什么,以及如何让它停下。 + + + 这个地址上没有游戏。本站任何内容都不会被删除,所以曾经在这个 URL 上的游戏如今依然在——请检查拼写。 + + + 从目录中随机挑出的一个游戏,绝不会连着两次相同。 + + + 你已认领的条目,以及一次认领能让你改动什么。 + + + 在只有运营者才能放置的地方发布一个令牌,以此证明这个游戏由你运营。 + + + 已归档——最近连通 {age},仍在探测 + + + 已归档,仍在探测 + + + 人数未知——游戏有应答,但没有发布我们能读出的数字 + + + {count, plural, other {#人连接}},{age}{how} + + + 新发现 + + + 已失联 + + + 已恢复 + + + {count, plural, other {已知#个游戏}} + + + {count, plural, other {当前#人连接(实测)}} + + + {count, plural, other {#个游戏有应答,未能计数}} + + + {count, plural, other {#个已归档,仍在探测}} + + + 参考 + + + 代码库是什么,客户端做什么,协议又意味着什么。由人手写,与爬虫一同存放在代码仓库里——这不是维基,本页上也没有任何可编辑的东西。这里的每一个{number}都是另一回事:它取自目录,每次加载页面时都重新算出。 + + + 数字 + + + 手写,单一作者,并用 git 做版本管理。这里的文字出自我们;旁边的每一个数字都由爬虫实测得出,并在每次请求时重新算出。这不是维基,本页也没有任何编辑它的途径。 + + + 从这里开始 + + + 代码库 + + + 客户端 + + + 协议 + + + 入门 + + + 代码库 + + + 客户端 + + + 协议 + + + 未找到 + + + 这里没有参考页面。本节由人手写,所以缺口是还没人做的工作,而不是被移除的内容——{index}。 + + + 看看现有的内容 + + + 运行它的游戏 + + + 我们尚未识别到任何一个。这是关于这台爬虫实测到了什么的事实,而不是关于存在什么的事实——我们没有连通过的游戏,或者代码库读不出来的游戏,都不算进这里。 + + + {count, plural, other {已列出#个}} + + + {count, plural, other {已归档#个}} + + + 实测,绝非断言 + + + 这是本次请求时从目录中数出的,用的正是上方链接所带的同一个筛选条件——所以这个数字和那份列表出自同一个查询,不可能对不上。 + + + 在它们的握手中提供:{protocols} + + + 实测采用情况 + + + 尚无实测。 + + + 在{listed, plural, other {#个已列出的游戏}}中——{percent} + + + 未算进这里的游戏,并不是没有这个协议的游戏。一个游戏被算进这里,条件是我们观测到它的服务器在一次握手中提供了这个选项;其余的既包括没有向我们提供它的服务器,也包括我们还没有读到过握手的服务器,而我们说不出是哪一种。 + + + 在一次握手中被观测到提供 {protocol} 的游戏,按我们识别出的其运行代码库分组。 + + + 代码库 + + + 已提供 + + + 已识别 + + + 另见 + + + 能力 + + + {count, plural, other {在{total}项中,有#项依据项目自己的文档确定}} + + + 逐项读自各项目自己的文档,而非由我们实测——客户端没有握手可供我们观测。“{unknown}”表示我们查过,没能确定。它绝不表示“否”。 + + + 客户端能力,每一项都读自项目自己的文档。未知表示我们没能确定,而绝不表示这个客户端不具备该项。 + + + 有文档 + + + 来源 + + + 我们没有找到 + + + 有 + + + 否 + + + 未知 + + + 运行平台:{platforms} + + + 我们识别为运行该代码库的游戏 + + + 尚无。这是关于我们实测到了什么的说法,而不是关于存在什么的说法——我们没有连通过的游戏,或者代码库读不出来的游戏,都不算进这里。 + + + {listed, plural, other {已列出#个}},{archived, plural, other {已归档#个}} + + + 在它们的握手中实测到:{protocols} + + + 在我们读到过的它们的任何一次握手中,都没有提供过任何东西。 + + + 在{listed, plural, other {#个已列出的游戏}}中,有 {offering} 个被观测到提供了它({percent}) + + + 按代码库,在我们已识别的游戏中 + + + {identified} 个中有 {offering} 个提供了它 + + + {count, plural, other {在{total}行中,有#行是未知}}:我们没有找到项目自己的文档给出任何一种说法。一张诚实的短表胜过一张靠猜的长表。 + + + 我的游戏 + + + 账户需要数据库支撑,而本站正运行在演示数据上。 + + + 登录 + + + 已以 {name} 登录。 + + + 退出登录 + + + 你还没有认领任何游戏。在{listing}中找到你的游戏,在它的页面上点击{claimControl}。 + + + 列表 + + + 认领此游戏 + + + 已放弃。 + + + 相关记录会保留,你随时可以再发布一个新令牌,重新证明控制权。 + + + 已保存。 + + + 那个游戏 + + + {game} 的页面现在把它标为所有者自述。 + + + 我们已停止发布 {game} 的连接画面。页面会明确说明,而不是留一个空洞。 + + + {game} 的连接画面又回到了它的页面上。 + + + 我们已停止向 {game} 拨号,我们掌握的它的每一个地址都停了。它的页面保留你开口之前实测到的一切。 + + + 我们又开始向 {game} 拨号,从它在排期中的下一轮起。 + + + {game} 已不在列表、排行和每日数字中。它的页面和它曾用过的每一个 URL 仍然照常应答。 + + + {game} 已回到列表中。一次得到应答的探测,就足以让它重新被实测。 + + + {field} 未作改动。 + + + 这些是一行以内的回答;我们最多保存 {max} 个字符。 + + + 该字段是实测的。认领让你补上 MSSP 放不下的内容;它绝不让任何人改动我们观测到的内容,我们自己也不例外。 + + + 已认领 + + + 已于 {date} 验证 + + + 已于 {date} 验证,令牌最近一次见于 {seen} + + + 检查你的 MSSP + + + {count, plural, other {另有 {names} 共同拥有——各自验证过自己的令牌。}} + + + 另一个账户 + + + 把你的在线人数放到自己的网站上 + + + 这个徽标同时带上计数和我们实测它的时间,因为一个没有时间的数字,正是本站要取而代之的东西。 + + + 我们未能计数时,它显示{unknown}而不是零;游戏停止应答时,它显示{archived}。 + + + 还有{json},如果你更愿意自己画。 + + + 历史 + + + 已签发令牌 + + + 已再次签发令牌 + + + 已验证——我们读到了你的令牌 + + + 令牌仍在发布 + + + 本次未读到令牌 + + + 已放弃认领 + + + 令牌未使用即过期 + + + 另一个账户证明了控制权,接管了这个游戏 + + + 已请求提前查看 + + + 放弃此认领 + + + 输入{word}以确认。任何内容都不会被删除,你可以再发布一个新令牌重新证明控制权;如果还有别人拥有它,这个游戏仍是已认领状态。 + + + 放弃 {game} + + + 等待令牌 + + + 令牌签发于 {issued},有效期至 {expires} + + + 通行密钥 + + + 未命名 + + + 添加于 {date} + + + 添加于 {date} · 仅在一台设备上 + + + 这个通行密钥只存在于一台设备上。万一丢了,你仍可以在自己的游戏上发布一个新令牌重新进来,但再加一个通行密钥更快。 + + + 再添加一个通行密钥 + + + 没有这个游戏 + + + 认领 {game} + + + 认领需要数据库,而本站正运行在演示数据上。 + + + 你需要先有一个账户。只需一个通行密钥和一个名称。 + + + 登录或创建账户 + + + 我的游戏 + + + {count, plural, other {这个游戏已经有#位所有者证明了对该服务器的控制权。}}你也可以证明——两种情况下的检验方式相同——但我们需要知道你的意思是什么,因为从令牌上看不出来。 + + + 我也在运营它——把我加为所有者 + + + 每个人都保留自己的认领。这是两个人一起运营一个游戏。 + + + 我已经接手了它——把它转给我 + + + {count, plural, other {你的令牌通过验证后,现有的认领会被撤销,这个游戏归你。}}他们会在自己的历史里看到原因。任何内容都不会被删除,他们也可以用你即将采用的同一种方式重新证明控制权。 + + + 已验证。 + + + 我们在 {date} 从这个游戏的 MSSP 报告中读到了你的令牌。 + + + 我们在 {date} 从连接画面上读到了你的令牌。 + + + 把令牌留在原处。它同时也是一个身份标记,这样即使这个游戏换了主机或改了名字,我们仍认得出它。把它撤掉不会取消你的认领。 + + + 把这个令牌发布在游戏会展示给匿名连接的任何地方。下一次探测就会读到它,这就证明了你能写入那台服务器。 + + + 这是一次转移。 + + + {count, plural, other {我们读到这个令牌时,当前所有者对这个游戏的认领会被撤销,它归你。}} + + + 两种方式都可以 + + + 一个 MSSP 变量 + + + 在 {codebase} 中那是 {file} 里的一行;每个支持 MSSP 的代码库都有对应的做法。 + + + {aliases} 同样可用。 + + + 连接画面上的一行 + + + 画面中任何位置都行,周围有颜色代码也没关系。 + + + 然后 + + + 我们按平常的爬取排期来查看。这个令牌有效期至 {date}。你随时可以再来;没有什么需要记下来。 + + + 提前查看 + + + 把你的游戏排到队列前面。我们按自己的排期拨号,所以这只是提前,而不是立刻。 + + + 刚刚请求过。请过几分钟再试——之所以限量,是因为它会让我们比原定更早去拨一台真实的服务器。 + + + 关于 {game},只有你能告诉我们的事 + + + 这些是 MSSP 没有字段可放的东西。它们会以{declared}的身份出现在你游戏的页面上,带着你最后一次确认的日期,与我们实测到的内容并列——绝不取而代之。实测到的任何内容都无法从这里编辑,你不能,我们也不能。 + + + {age}自述。清空此框即可撤回——不论如何,它曾写过什么的记录都会保留。 + + + 保存你的自述 + + + {game} 报告了什么,以及你更希望我们显示什么 + + + 你的 MSSP 是每个爬虫都会读到的内容,无论你在这里填了什么,我们都会把它并列显示出来——你给的答案不会遮住你游戏给的答案。 + + + 实测到的任何内容都无法从这里编辑:人数计数不行,能力不行,可连通的某个小时也不行。 + + + 如果下面某一行在你的 {file} 里就是错的,在那里改掉,处处都会跟着改。 + + + 你的游戏报告 {value},{age}确认 + + + 你的游戏在这里什么也没有报告 + + + 改名会同时改变 {game} 在列表中的名称和它页面的地址。旧地址永远继续有效——这个游戏曾用过的每一个 URL 都会重定向到它当前的地址——而清空此框,名称就交还给你的 MSSP 所说的内容。 + + + 你的连接画面 + + + 我们不再发布它。页面会明确说明,而不是留一个空洞;爬虫仍会继续读它——你的游戏搬了家,我们靠的就是它认出你。 + + + 重新显示 + + + 我们展示它,是因为你的服务器本来就把它发给每一个匿名连接。你若不愿意,说一声我们就撤下。我们不会问为什么。 + + + 不要再展示我们的连接画面 + + + 被爬取 + + + 我们已经停下。{game} 上没有任何地址还在被拨号,页面保留你开口之前实测到的一切——那些空白的小时不指明原因,因为{ourFact}是关于我们的事实,而不是对你游戏的实测。 + + + 你要求我们停下 + + + 重新开始爬取我们 + + + 这一条来自你自己的服务器,而不是来自这里——{routes}。要重新被爬取,请停止发布它;下一个周期我们就会知道。 + + + 你的 MSSP 报告发布了 {variable} + + + 一条 {label} TXT 记录要求我们停下 + + + 一份我们记录在案的请求 + + + 我们已在 {stopped} 上停下,仍在拨 {dialling}。这通常是在退出声明之后又新增的端口。 + + + 在每一个地址上也都停下 + + + 我们按排期向 {game} 拨号,读取任何匿名连接都能看到的内容。你若不愿意,说一声我们就停——在一个爬取周期之内,在我们掌握的你的每一个地址上,而且我们不会问为什么。 + + + 已经实测到的内容不会被删除:你的页面保留它的历史和它的 URL,而你收回这项设置之后,一次探测就会让它重新开始。你也可以不经过我们,在自己的配置里说出来——MSSP 里的 {mssp},或者一条 {dns} TXT 记录——无论有没有人在这里认领过这个游戏,我们都照办。 + + + 不要再爬取我们 + + + 被列出 + + + {game} 已不在列表中,不在排行中,也不在每日数字中。它的页面和它曾用过的每一个 URL 仍然照常应答,你开口之前实测到的一切也都还在上面。任何内容都没有被删除;它只是不再是读者靠浏览就能走到的地方。 + + + 把我们放回列表 + + + 一次得到应答的探测同样能做到这件事。你的退出声明还立着的时候我们不会拨号,所以不会有这样的探测——但你收回它的那一天,这个地址会在一周之内轮到,得到应答的那次探测就会把你放回去。你不必再来问我们第二次。 + + + 我们已经不再向你拨号,而你的页面仍在列表中,带着在那之前我们实测到的内容。你若不愿意,说一声它就撤出来——从列表、排行和每日数字中撤出。 + + + 任何内容都不会被删除,也不会有什么坏掉:页面照常应答,它曾用过的每一个 URL 仍然重定向到它,你指引过去的人都能看到它。它只是不再是读者靠浏览就能走到的地方。这可以从这里撤销,而你收回退出声明之后,任何一次得到应答的探测也能撤销它。 + + + 把我们也移出列表 + + + 实测到了什么 + + + 隐藏这些会把它们从当前列表中移出;这并不表示该游戏里没有人。 + + + 未能计数 + + + 无法连通 + + + 已从此列表中隐藏 + + + 已从此列表中隐藏 + + + 左栏中,* 表示此列表已筛选为该值,- 表示此列表已把该值排除。两者都是查询中的选择,而不是关于某个游戏的事实。 + + diff --git a/src/MUI.Web/SiteComposition.cs b/src/MUI.Web/SiteComposition.cs index bc336b7..f502805 100644 --- a/src/MUI.Web/SiteComposition.cs +++ b/src/MUI.Web/SiteComposition.cs @@ -1,3 +1,5 @@ +using Microsoft.Extensions.Localization; + using MUI.Catalog; using MUI.Crawler; using MUI.Web.Accounts; @@ -6,6 +8,7 @@ using MUI.Web.Data; using MUI.Web.Fixtures; using MUI.Web.Icons; +using MUI.Web.Localization; using MUI.Web.Submissions; using MUI.Web.Theme; @@ -52,6 +55,9 @@ public static IServiceCollection AddMuiSite( services.AddRazorComponents(); + // The chrome's own words, before anything that renders them. + services.AddMuiLocalization(); + // The read API (spec §10) reads through the same IGameQueries the pages do, so the two // surfaces cannot disagree about a fact. What it adds of its own — the dataset licence, the // slug aliases and the attribution list — is configuration, because none of it is a @@ -112,6 +118,25 @@ public static IServiceCollection AddMuiSite( return services; } + /// + /// The resource set the chrome's strings are read from. + /// + /// + /// The arrangement SharpMUSH's portal uses for its own chrome — AddLocalization over a + /// Resources folder, resolved through a marker class, with the SDK compiling one + /// satellite assembly per culture and no <EmbeddedResource> entries anywhere. What + /// differs is the values: theirs are composite-format strings and these are ICU patterns, + /// because {0} substitutes and cannot agree. See . + /// + public static IServiceCollection AddMuiLocalization(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddLocalization(options => options.ResourcesPath = "Resources"); + + return services; + } + /// /// The middleware and the routes, in the order they have to be in. /// @@ -129,6 +154,18 @@ public static WebApplication UseMuiSite(this WebApplication app, string? connect // limit. app.UseSubmitterAddress(); + // Before anything routes, because the locale is a path segment and every @page directive is + // written without it: the middleware moves the prefix into PathBase so one route table + // serves every language. It is also before the not-found page, so a mistyped URL inside a + // locale is answered in that locale rather than in English. + app.UseMuiLocale(); + + // Explicit, and that is load-bearing rather than tidy: with no UseRouting call of its own an + // app gets one inserted at the very top of the pipeline, which resolved the endpoint before + // the middleware above had rewritten the path — so every localized URL 404'd while the + // unprefixed one worked. Naming it here is what puts routing after the rewrite. + app.UseRouting(); + // A reader who mistyped a URL, and a crawler indexing one, both got a 404 with an empty body: // the fragment inside is never rendered under static server rendering, so // the site's own "no game here" paragraph was dead copy. This answers those with the page — @@ -168,6 +205,10 @@ public static WebApplication UseMuiSite(this WebApplication app, string? connect // deployment has the same eyes as a reader of the real one. app.MapMuiTheme(); + // Beside the theme endpoint and for the same reason: it writes a cookie and redirects, and + // a reader of the demo deployment reads the same language as a reader of the real one. + app.MapMuiLocale(); + // §5.7, and before the route that would answer with "not found": a slug this game used to // have is a URL somebody is still holding, and it redirects to the page it has now — // permanently, and for an archived game exactly as for a live one. diff --git a/src/MUI.Web/Submissions/SubmissionEndpoint.cs b/src/MUI.Web/Submissions/SubmissionEndpoint.cs index e503b7d..53d60fc 100644 --- a/src/MUI.Web/Submissions/SubmissionEndpoint.cs +++ b/src/MUI.Web/Submissions/SubmissionEndpoint.cs @@ -3,6 +3,7 @@ using MUI.Catalog.Persistence; using MUI.Discovery; using MUI.Web.Components; +using MUI.Web.Localization; namespace MUI.Web.Submissions; @@ -56,7 +57,9 @@ await sources.OfAsync(SubmitterAddress.Of(context), context.RequestAborted), ? game.Slug : null; - return Results.Redirect(SubmitLinks.For(receipt.Outcome, receipt.Address, slug)); + return Results.Redirect(LocaleRouting.Link( + context.LocaleOf().Tag, + SubmitLinks.For(receipt.Outcome, receipt.Address, slug))); }); } } diff --git a/src/MUI.Web/Theme/ThemeEndpoint.cs b/src/MUI.Web/Theme/ThemeEndpoint.cs index 0904882..7b0b61b 100644 --- a/src/MUI.Web/Theme/ThemeEndpoint.cs +++ b/src/MUI.Web/Theme/ThemeEndpoint.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Mvc; +using MUI.Web.Localization; namespace MUI.Web.Theme; @@ -42,7 +43,13 @@ public static IEndpointRouteBuilder MapMuiTheme(this IEndpointRouteBuilder endpo // 303 rather than 302: the reader posted a form and what they should now be holding is // the page they came from, fetched with GET. A 302 gets there by convention on every // browser written since 1996, and this says what was meant. - context.Response.Headers.Location = ReaderTheme.Back(returnTo); + // + // And back into the language they were reading, which the return field cannot carry: it + // holds Request.Path, from which the middleware has already taken the prefix. A reader + // who followed a shared /de/… link has no cookie either, so this redirect was the whole + // of what stood between them and an English page — for choosing a light background. + context.Response.Headers.Location = + LocaleRouting.Link(context.LocaleOf().Tag, ReaderTheme.Back(returnTo)); return Results.StatusCode(StatusCodes.Status303SeeOther); }).DisableAntiforgery(); diff --git a/src/MUI.Web/wwwroot/app.css b/src/MUI.Web/wwwroot/app.css index 09d448e..0e0ae29 100644 --- a/src/MUI.Web/wwwroot/app.css +++ b/src/MUI.Web/wwwroot/app.css @@ -12,23 +12,74 @@ */ :root { - --cpad: 14px; /* the density control writes this, and only this */ - --row-pad: 9px; + /* + Spacing, radii and motion, in the relationships the design handoff's own token files carry. + The values are theirs; the colours below are ours — the handoff's palette is a different + product's and its README says so. What transfers is the scale: content padding at 20px rather + than the 14 this site had, one radius for controls and a larger one for the surfaces that hold + them, and a single easing curve at an eighth of a second. + */ + --cpad: 24px; /* the density control writes this, and only this */ + --row-pad: 13px; /* a listing row: 13px 20px, over a 44px floor */ + + /* + The one horizontal inset the whole site shares — the bar, every content band, every listing + row. Fluid rather than fixed because the page is full-bleed now: at the width the handoff was + drawn to it lands on the 24px the drawing uses, and it grows a little rather than leaving a + wide monitor's chrome pinned to the very edge of the glass. + */ + --gutter: clamp(14px, 1.6vw, 28px); + + --radius: 9px; /* controls, chips, rows, nav items */ + --radius-lg: 14px; /* cards and panels — the things controls sit in */ + --radius-pill: 99px; /* pills and badges */ + + --ease: cubic-bezier(0.4, 0, 0.2, 1); + --dur: 0.14s; --bg: #0f1113; --surface: #16191c; --raised: #1d2125; --recessed: #0b0d0f; + + /* + Two hairline steps, as the handoff's tokens carry them: --line is a control's own edge and + --line-soft is the rule between two panels. One value did both, so a filter column's edge and + a button's border were the same weight — and the panel rules, which are ten times longer, + read as the loudest thing on the page. + */ --line: #262b30; + --line-soft: #1c2024; --text: #e8eaec; /* values and names */ --dim: #9aa2a9; /* prose and secondary */ --faint: #6b747c; /* provenance, ages, meta */ --accent: #35d29a; /* measured */ + + /* + The accent as *text on an accent tint* — the selected facet row, which paints a 12% wash of + the accent behind accent-coloured words. Every ratio beside a token here is measured against + the page, and a tinted row is not the page: in light, --accent is 5.0:1 on the background it + was tuned for and 3.9:1 on this one, which is the row a reader has just clicked. Dark clears + it either way, so there the two are the same colour. + */ + --accent-ink: var(--accent); --amber: #d8a13a; /* declared / ageing */ --derived: #a394f0; /* our own classification, and nobody else's */ + /* + Refusal, and only refusal. Not a provenance colour — nothing on this site is red because of + what we did or did not measure. It marks one thing: a facet value the reader has excluded, + where the handoff draws a danger tint, a danger ring and a struck-through label. A state the + reader put the control into, never a state of the data. + */ + --danger: #f4574c; + + /* The card's own elevation, and the one glow the accent is allowed. */ + --shadow: 0 1px 0 rgb(255 255 255 / 3%) inset, 0 4px 16px rgb(0 0 0 / 40%); + --glow: 0 0 8px color-mix(in srgb, var(--accent) 55%, transparent); + --sans: "Hanken Grotesk", ui-sans-serif, system-ui, -apple-system, sans-serif; --mono: "Cascadia Mono", ui-monospace, "SFMono-Regular", Consolas, monospace; --kick: "JetBrains Mono", var(--mono); @@ -60,38 +111,69 @@ data-theme wins over the system in both directions, so a toggle can be added without moving any of this. */ +/* + ── the light theme, and why none of it is white ─────────────────────────── + The page surface was #ffffff, and once the card went full-bleed that stopped being a panel and + became the whole window. A large field of pure white glares: the halation it produces around dark + text is worst for readers with astigmatism and is one of the things dyslexic readers most often + ask to be turned down, and 21:1 body contrast is past the point where more helps and into the + point where it tires. Nobody's guideline forbids it — WCAG has a floor and no ceiling — which is + why every design system that has thought about it lands in the same place by convention instead: + GitHub Primer's canvas.subtle #f6f8fa, IBM Carbon's #f4f4f4 background under white layers, + Material 3's tinted surfaces, Solarized Light's cream. All of them are off-white. + + So the ramp is a soft cool grey and the near-white is reserved for the things that are supposed to + sit *above* it — tiles, the hover plane. That also restores a distinction the light theme had lost + entirely: --surface and --raised were both #ffffff, so a card lifted off the page by a step in the + dark theme was lifted by nothing at all here, and its border was doing all the work. + + Text is a near-black with the ramp's own hue rather than #000, at ~15:1 — comfortably past the + 4.5:1 floor and short of the glare. Every step below was measured against the new surface, and + --faint moved because it did not clear 4.5:1 against the old one: it carries ages and provenance + at 12px, which is exactly the small low-contrast text the handoff asked to have checked. +*/ @media (prefers-color-scheme: light) { :root { - --bg: #f7f8f9; - --surface: #ffffff; - --raised: #ffffff; - --recessed: #eef0f2; - --line: #dfe3e6; - - --text: #14181b; - --dim: #4d565d; - --faint: #79838a; - - --accent: #04795b; /* same hue, dropped in value to clear 4.5:1 on white */ - --amber: #8a5d00; - --derived: #5b46c4; + --bg: #eef1f2; /* behind the page; barely visible now the shell is full-bleed */ + --surface: #f4f6f7; /* the page itself */ + --raised: #fbfcfc; /* tiles, hover planes — above the page, never below it */ + --recessed: #e9edee; /* the bar, the filter column */ + --line: #d3d9dc; + --line-soft: #e2e7e9; + + --text: #1a1f23; /* 15.3:1 — past the floor, short of the glare */ + --dim: #4d585f; /* 6.7:1 */ + --faint: #646f76; /* 4.8:1 — was 3.5:1 and carried 12px ages */ + + --accent: #04795b; /* same hue, dropped in value: 5.0:1 */ + --accent-ink: #036349; /* 5.3:1 on the accent's own 12% tint, where --accent is 3.9:1 */ + --amber: #7d5400; /* 6.2:1 */ + --derived: #5240b8; /* 7.0:1 */ + --danger: #a8261e; /* 6.5:1 */ + + --shadow: 0 1px 2px rgb(16 24 32 / 4%), 0 4px 16px rgb(16 24 32 / 5%); } } :root[data-theme="light"] { - --bg: #f7f8f9; - --surface: #ffffff; - --raised: #ffffff; - --recessed: #eef0f2; - --line: #dfe3e6; + --bg: #eef1f2; + --surface: #f4f6f7; + --raised: #fbfcfc; + --recessed: #e9edee; + --line: #d3d9dc; + --line-soft: #e2e7e9; - --text: #14181b; - --dim: #4d565d; - --faint: #79838a; + --text: #1a1f23; + --dim: #4d585f; + --faint: #646f76; --accent: #04795b; - --amber: #8a5d00; - --derived: #5b46c4; + --accent-ink: #036349; + --amber: #7d5400; + --derived: #5240b8; + --danger: #a8261e; + + --shadow: 0 1px 2px rgb(16 24 32 / 4%), 0 4px 16px rgb(16 24 32 / 5%); } :root[data-theme="dark"] { @@ -100,19 +182,29 @@ --raised: #1d2125; --recessed: #0b0d0f; --line: #262b30; + --line-soft: #1c2024; --text: #e8eaec; --dim: #9aa2a9; --faint: #6b747c; --accent: #35d29a; + + /* Reset with the rest of them. The light media block sets this unconditionally, so a reader who + pinned dark on a light-preferring system kept the light ink — a dark green on a dark tint, + 2.7:1, which is worse than the value it was added to fix. Every token this block redeclares + is redeclared for that reason; this one was missed. */ + --accent-ink: var(--accent); --amber: #d8a13a; --derived: #a394f0; + --danger: #f4574c; + + --shadow: 0 1px 0 rgb(255 255 255 / 3%) inset, 0 4px 16px rgb(0 0 0 / 40%); } :root[data-density="compact"] { - --cpad: 9px; - --row-pad: 5px; + --cpad: 13px; + --row-pad: 8px; } * { box-sizing: border-box; } @@ -125,16 +217,74 @@ body { text-wrap: pretty; } -main { max-width: 1080px; margin: 0 auto; padding: calc(var(--cpad) * 2) var(--cpad); } +/* + The shell: everything the handoff draws sits in one card — the bar along its top edge, the content + under it — rather than loose on the page. It is the difference between a document and an + instrument, and this site is the second: a directory whose bar, filters and rows are one panel. +*/ +/* + ── the shell ────────────────────────────────────────────────────────────── + Full-bleed. The handoff draws the site as one card floating on a ground, and that is a drawing + convention rather than a product decision — a directory is an instrument you fill the window with, + not a document you frame. So the card's border, radius, shadow and side margins are gone and the + page IS the surface; what the card was actually carrying — one bar along the top, bands separated + by hairlines, every block sharing one left edge — is unchanged and is the part that mattered. + + The radial that used to sit on the body moves here, because the body is no longer visible behind + anything. It is the one thing keeping a full-height dark page from reading as flat black. +*/ +.shell { + min-height: 100vh; + margin: 0; + background: var(--surface) radial-gradient(80% 90% at 78% -20%, + color-mix(in srgb, var(--raised) 45%, var(--surface)) 0%, var(--surface) 100%) fixed; + + /* + Clipped sideways and open downwards. `hidden` on both axes is what a card wants and it + swallowed the nav's own menu: at the width where both groups collapse into a disclosure, the + panel is positioned under the bar and every pixel of it was being clipped away, so the one + control that carries the whole catalogue on a narrow window opened onto nothing. `clip` pairs + with `visible` — `hidden` would coerce it to `auto` and hand the page a scrollbar. + */ + overflow-x: clip; + overflow-y: visible; +} + +/* + The content column's own padding, at the handoff's 30px/24px/26px. A page whose blocks bleed to + the card's edges — the listing's two columns, the game page's hairline-separated bands — cancels + it and pads each block itself, because a full-bleed rule cannot be drawn inside a padded box. +*/ +main { padding: 30px var(--gutter) 40px; } +main:has(> .card-page) { padding: 0; } + +/* + One vertical rhythm for the whole content column, as a gap rather than as margins on each block. + Margins collapse, disagree with each other and leave a dead band wherever a block renders nothing + — which is what the absent crawler strip did to the front page. +*/ +main > .card-page, +.hero { display: flex; flex-direction: column; gap: 22px; } h1 { font-size: 26px; font-weight: 600; letter-spacing: -0.01em; margin: 0 0 6px; } -h2 { font-size: 19px; font-weight: 600; margin: calc(var(--cpad) * 2) 0 var(--cpad); } +h2 { font-size: 20px; font-weight: 600; margin: calc(var(--cpad) * 1.4) 0 10px; } a { color: inherit; } .dim { color: var(--dim); } .faint { color: var(--faint); } .mono { font-family: var(--mono); font-variant-ligatures: none; } +/* Every count and every age, so the columns scan. */ +.mono, .tabular { font-variant-numeric: tabular-nums; } + +/* + ── the kicker, and why its typography is locale-gated ────────────────────── + Uppercase plus 0.16em tracking is this site's most recognisable label, and it is Latin-specific + twice over (i18n S1). Letter-spacing severs the cursive joins in Arabic, so connected words come + apart into unrelated shapes; Devanagari and Thai lose conjuncts and mark stacks the same way. + text-transform: uppercase is a no-op in CJK, turns ß into SS, and gets Turkish dotted/dotless i + wrong. The class is the semantics; :lang() decides the typography. +*/ .kicker { font: 10px/1 var(--kick); letter-spacing: 0.16em; @@ -142,18 +292,100 @@ a { color: inherit; } color: var(--faint); } -/* ── site chrome ───────────────────────────────────────────────────────── */ +/* Cursive and complex scripts: prominence from weight and size, never from tracking. */ +:is(.kicker, .nav-label, .bar-label, .facet-name, .evidence, .kick):is( + :lang(ar), :lang(he), :lang(fa), :lang(ur), :lang(th), :lang(hi), :lang(mr), :lang(ne)) { + text-transform: none; + letter-spacing: normal; + font-weight: 600; + font-size: 11.5px; + line-height: 1.5; +} + +/* CJK: uppercase does nothing, tracking looks like a fault, and 10px is unreadable. */ +:is(.kicker, .nav-label, .bar-label, .facet-name, .evidence, .kick):is( + :lang(zh), :lang(ja), :lang(ko)) { + text-transform: none; + letter-spacing: 0.02em; + font-size: 12px; +} + +/* ── site chrome ─────────────────────────────────────────────────────────── + A fixed 60px bar that never wraps. It stretches rather than centres, so a nav item is a + full-height box and its current-page marker can land on the bar's own bottom edge. */ header.site { + /* One row is 60px and stays 60px; a bar that has wrapped is as tall as what it holds. */ + min-height: 60px; + row-gap: 4px; + flex: none; border-bottom: 1px solid var(--line); - padding: var(--cpad); + background: var(--recessed); + padding: 0 var(--gutter); display: flex; - gap: var(--cpad); - align-items: baseline; + align-items: stretch; + justify-content: space-between; + gap: 20px; + + /* + A bar spanning the window is chrome, so it stays where chrome belongs. The game page runs to + three and a half thousand pixels and the catalogue is at the top of it; scrolling back up to + reach the nav is a cost the card layout hid by being short. + */ + position: sticky; + top: 0; + z-index: 30; + + /* + Sideways only. `overflow: hidden` is what a fixed-height bar wants — nothing may spill out of + it — but it clipped the nav's own dropdown to nothing at the width where that dropdown IS the + navigation. `clip` on one axis leaves `visible` legal on the other; `hidden` would coerce it. + */ + overflow-x: clip; + overflow-y: visible; } -header.site .mark { font-family: var(--mono); font-weight: 600; text-decoration: none; } -header.site .mark .star { color: var(--accent); } +/* + The bar is one line of chrome and the demo banner above it is a statement about the whole site, so + the banner scrolls away and the bar does not. Where there is no banner the bar is simply at the + top of the document. +*/ +.demo-banner { position: relative; z-index: 31; } + +/* + The bar measures itself. + + Every threshold in the ladder below used to be a pixel count, and every one of them was really a + measurement of *English*: the seven catalogue links are 430px in English and 517px in Japanese, so + 860 was the width at which the English bar stopped fitting and German, Dutch and Japanese spent the + whole 861–1100 band with links running out of their box. 1024 is one of the four widths this design + is reviewed at, and all three failed at it. + + `max-content` is the browser's own answer to "how wide is this text, in this language, in this + font" — so the nav asks for exactly what its links need and `flex-wrap` gives it its own row the + moment the bar cannot. No number to keep in step with a translation, nothing to re-tune when a + string changes, and a language nobody has measured yet is right on arrival. + + What the reader gets in that band is a two-row bar rather than a menu, which is the trade: every + destination stays visible and in the same order, and the row that moves is the one that was being + clipped. Below the collapse width it is still one disclosure, because a menu is the right shape on + a phone whatever the language. +*/ +header.site { flex-wrap: wrap; } +header.site .mark { flex: 0 0 auto; } +header.site .site-nav { flex: 1 1 auto; min-width: max-content; } +header.site nav.account { flex: 0 0 auto; } +header.site form.theme { flex: 0 0 auto; } + +header.site .mark { + display: flex; + align-items: center; + font: 700 17px/1 var(--sans); + letter-spacing: -0.01em; + text-decoration: none; + white-space: nowrap; +} +header.site .mark .star { color: var(--accent); text-shadow: var(--glow); } /* ── provenance chip ─────────────────────────────────────────────────────── Not a box. At rest it is the value, one state glyph and a relative age — no border, no fill, @@ -178,14 +410,43 @@ header.site .mark .star { color: var(--accent); } /* ── capability matrix ─────────────────────────────────────────────────── */ -table.matrix { border-collapse: collapse; width: 100%; } +/* + A framed table, as the handoff draws it: a header row on the recessed surface, hairlines between + the rows, and the caption as a foot band inside the same frame rather than a loose line under it. + A caption that sits outside the border reads as a note about the section; inside it, it reads as + what it is — the table's own summary, with the table still in view. +*/ +table.matrix { + border-collapse: collapse; + width: 100%; + border: 1px solid var(--line-soft); + border-radius: var(--radius); + overflow: hidden; +} table.matrix th, table.matrix td { text-align: left; - padding: var(--row-pad) var(--cpad); - border-bottom: 1px solid var(--line); + /* Its own padding rather than the row token: this table is six rows of two-word states, and a + listing row's 13px by 20px turns each of them into a paragraph. */ + padding: 11px 16px; + border-top: 1px solid var(--line-soft); + white-space: nowrap; +} +table.matrix thead th { + font: 10px/1 var(--kick); letter-spacing: 0.16em; text-transform: uppercase; color: var(--faint); + background: var(--recessed); + border-top: 0; + padding: 10px 16px; } -table.matrix thead th { font: 10px/1 var(--kick); letter-spacing: 0.16em; text-transform: uppercase; color: var(--faint); } +table.matrix tbody tr:first-child th, table.matrix tbody tr:first-child td { border-top: 1px solid var(--line-soft); } table.matrix tr.disagrees { background: color-mix(in srgb, var(--amber) 9%, transparent); } +/* At the foot and inside the frame, where a caption belongs on a table whose heading is directly + above it — and stated once, here, rather than in the heading as well. */ +table.matrix caption.count { + caption-side: bottom; text-align: left; font-size: 12.5px; + padding: 10px 16px; + background: var(--recessed); + border-top: 1px solid var(--line-soft); +} .state-present { color: var(--accent); } .state-absent { color: var(--dim); } .state-unknown { color: var(--faint); } @@ -210,15 +471,30 @@ td.gap { background: transparent; outline: 1px dashed var(--faint); out Foreign colour is quoted, not hosted. Its own fixed black ground and its own locked 16-colour table in both themes, so arbitrary SGR is expected inside and nothing bleeds either way. */ -figure.ansi { margin: 0; border: 1px solid var(--line); background: #000; } +/* + One panel holding the art, the words and the caption — rather than a black box with a kicker strip + welded under it and the words in a separate disclosure below. The frame is the site's own recessed + surface at the controls' radius; the art keeps its fixed black ground inside, because the SGR it + carries is somebody else's and has to sit on the terminal it was drawn for. +*/ +figure.ansi { + margin: 0; + border: 1px solid var(--line-soft); + border-radius: var(--radius); + background: var(--recessed); + padding: 16px 18px; + display: flex; + flex-direction: column; + gap: 12px; + overflow: hidden; +} figure.ansi figcaption { - font: 10px/1 var(--kick); - letter-spacing: 0.16em; - text-transform: uppercase; + font-family: var(--mono); + font-size: 11.5px; color: var(--faint); - padding: 8px var(--cpad); - border-top: 1px solid var(--line); - background: var(--surface); + padding: 0; + background: none; + border: 0; } /* @@ -258,22 +534,259 @@ figure.ansi figcaption { .skip:focus { left: 8px; top: 8px; } -:where(a, button, input, summary):focus-visible { +:where(a, button, input, select, summary):focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } /* ── chrome ────────────────────────────────────────────────────────────── */ -/* Wrapping, because seven catalogue links do not fit a 390px phone on one line and the alternative - is a page that scrolls sideways — which the whole layout is otherwise careful never to do. */ -header.site nav { display: flex; gap: var(--cpad); flex-wrap: wrap; } -header.site nav a { color: var(--dim); text-decoration: none; } +/* + Every item is a full-height flex box, and nothing wraps. The bar's job is to be one line of + chrome; when it stops fitting it degrades through the steps below rather than reflowing. +*/ +header.site nav { display: flex; align-items: stretch; min-width: 0; } +header.site nav a, header.site nav span.nav-item { + display: flex; align-items: center; white-space: nowrap; + color: var(--dim); text-decoration: none; font-size: 13.5px; +} header.site nav a:hover { color: var(--text); text-decoration: underline; text-underline-offset: 3px; } -/* The one link in this bar that leads somewhere only you can go, so it is not dimmed like the - catalogues beside it. */ +/* ── the two groups ──────────────────────────────────────────────────────── + Places to browse, and things to read about the hobby. The label is the kicker face the rest of + the site uses for column heads, and it is aria-hidden: the group carries the same word as its + accessible name, and a screen reader announcing "browse" as a text node and again as a label is + the duplication this pass exists to remove. */ + +header.site .site-nav { display: flex; align-items: stretch; justify-content: center; gap: 14px; } +header.site .nav-group { display: flex; align-items: stretch; gap: 11px; min-width: 0; } +header.site .nav-label { + display: flex; align-items: center; white-space: nowrap; + font: 9px/1 var(--kick); letter-spacing: 0.16em; text-transform: uppercase; color: var(--faint); +} + +/* A hairline between the groups rather than a wider gap: the bar is short and the gap that would + read as a separation is one that reads as a wrap. 18px, centred in the 60px bar. */ +header.site .nav-rule { flex: 0 0 auto; width: 1px; margin: 21px 0; background: var(--line); } + +/* + The page you are on, marked *inside* the item's own box. + + The marker is an inset shadow and NOTHING else — no border, no padding change. Either of those + adds a pixel to the current item's box, and because the bar is a flex line that grows to its + tallest child, the whole row moved down one pixel the moment a marked item was on it. A bar that + shifts when you arrive is a bar you cannot aim at, and it is the defect the handoff names by hand. +*/ +header.site nav a.on { color: var(--text); box-shadow: inset 0 -2px 0 0 var(--accent); } +header.site nav a.on:hover { text-decoration: none; } + +/* The far end of the bar: what to do, rather than where to go. Submit is an action and about is + site meta, so neither is a catalogue and neither sits in a group above. */ +header.site nav.account { display: flex; align-items: center; gap: 12px; } header.site nav.account a { color: var(--text); } +header.site nav.account a.action { + border: 1px solid var(--line); border-radius: var(--radius); padding: 6px 12px; + font-size: 13px; white-space: nowrap; +} +header.site nav.account a.action:hover { border-color: var(--dim); text-decoration: none; } + +/* The accent button: ring and tint together, as the handoff draws it — the fill is what tells it + apart from the bordered one beside it at a glance. */ +header.site nav.account a.action.primary { + border-color: color-mix(in srgb, var(--accent) 40%, transparent); + background: color-mix(in srgb, var(--accent) 12%, transparent); + color: var(--accent); +} + +/* + ── the nav's degradation order (i18n S2) ────────────────────────────────── + Width triggers the steps, never language: a German reader on a wide monitor never leaves step 0 + and an English reader in a narrow window reaches step 2. Each step loses DECORATION, never a + destination — which is the whole point, and why the collapse at the end is a disclosure holding + every link rather than a shorter bar. + + step 1 group labels drop; the divider keeps the grouping + step 2 submit loses its border, becoming a plain link + step 3 both groups collapse into one `menu` disclosure + + Budget every nav string at 1.4x the English width when reviewing a locale. +*/ + +/* + Step 0 to 2 the disclosure is not a disclosure: the summary is hidden and its content forced + visible, so the two groups sit in the bar exactly as if the were not there. Guarded on + ::details-content, because a browser without it cannot be made to show a closed — there + the summary stays and the nav is a menu button at every width, which is a worse bar and a working + one. +*/ +header.site .nav-menu { display: flex; position: relative; align-items: stretch; min-width: 0; } +header.site .nav-menu > summary { + display: flex; align-items: center; gap: 6px; cursor: pointer; + color: var(--dim); font-size: 13.5px; white-space: nowrap; list-style: none; +} +header.site .nav-menu > summary::-webkit-details-marker { display: none; } +header.site .nav-menu > summary:hover { color: var(--text); } +header.site .nav-menu[open] > summary { color: var(--text); } + +/* The panel, as a dropdown. Above the collapse width the same element is the bar's own centre row. */ +header.site .nav-menu .menu-panel { + position: absolute; top: 100%; left: 0; z-index: 20; min-width: 200px; + display: flex; flex-direction: column; gap: 2px; + padding: 8px; margin-top: 1px; + background: var(--raised); border: 1px solid var(--line); + border-radius: var(--radius); box-shadow: var(--shadow); +} +header.site .nav-menu .menu-panel > .nav-group { flex-direction: column; align-items: stretch; gap: 2px; } +header.site .nav-menu .menu-panel a { padding: 6px 10px; border-radius: 6px; } +header.site .nav-menu .menu-panel a:hover { background: var(--surface); text-decoration: none; } + +/* + The current page, marked for a list rather than for a bar — and only while the panel IS a list. + The inset shadow that lands on the bar's bottom edge becomes a full-width rule under one row of a + dropdown, which reads as a text field rather than as "you are here", so in the panel the marker + moves to the leading edge. Scoped to the collapsed width: unscoped it drew a vertical accent bar + and a tinted box down the side of the current item in the open bar. +*/ +@media (max-width: 880px) { + header.site .nav-menu .menu-panel a.on { + box-shadow: inset 2px 0 0 0 var(--accent); + background: color-mix(in srgb, var(--accent) 10%, transparent); + color: var(--accent); + } +} +header.site .nav-menu .menu-panel .nav-label { padding: 6px 8px 2px; } +header.site .nav-menu .menu-panel .nav-rule { display: none; } +header.site .menu-tail { display: flex; flex-direction: column; gap: 2px; } + +@supports selector(::details-content) { + @media (min-width: 881px) { + header.site .nav-menu > summary { display: none; } + + /* + The wrapper has to carry the bar's full height, not its content's. A nav item is a + full-height box so that the current-page marker — an inset shadow, drawn inside the box — + lands on the bar's own bottom edge; with the wrapper sized to its text the marker floated + eighteen pixels above it, which is the underline hanging in mid-bar rather than sitting on + it. `display: flex` is what makes the panel below a stretched flex item. + */ + header.site .nav-menu::details-content { + content-visibility: visible; + block-size: 100%; + display: flex; + align-items: stretch; + } + + /* The panel is the bar's middle row: a static flex line, not a dropdown. */ + header.site .nav-menu .menu-panel { + position: static; min-width: 0; height: 100%; + display: flex; flex-direction: row; align-items: stretch; gap: 14px; + padding: 0; margin: 0; background: none; border: 0; border-radius: 0; box-shadow: none; + } + header.site .nav-menu .menu-panel > .nav-group { flex-direction: row; align-items: stretch; gap: 11px; } + header.site .nav-menu .menu-panel a { padding: 0; border-radius: 0; } + header.site .nav-menu .menu-panel a:hover { background: none; } + header.site .nav-menu .menu-panel .nav-rule { display: block; } + header.site .nav-menu .menu-panel .nav-label { padding: 0; } + + /* Step 4's overflow home, which the bar itself still carries at these widths. */ + header.site .menu-tail { display: none; } + } + + /* + Step 1 — the labels go and the divider stays. The grouping is the fact; the words naming the + groups are a gloss on it, and the rule between them carries the same fact in one pixel. Losing + both at once would leave seven links in a row in no order anybody could name, which is the + state this bar was rebuilt out of. + */ + @media (min-width: 881px) and (max-width: 1180px) { + header.site .nav-label { display: none; } + header.site .nav-menu .menu-panel { gap: 14px; } + header.site .nav-menu .menu-panel > .nav-group { gap: 11px; } + } +} + +@media (max-width: 1000px) { /* step 2 — submit keeps the word, loses the box */ + header.site nav.account a.action:not(.primary) { + border-color: transparent; padding: 6px 0; color: var(--dim); + } + header.site nav.account a.action:not(.primary):hover { color: var(--text); } +} + +@media (max-width: 880px) { /* step 3 — one disclosure, every destination kept */ + header.site .site-nav { justify-content: flex-start; } + + /* + And it stops being the bar's shock absorber. Above this width the nav holds seven links and + `flex: 1 1 auto; min-width: 0` is what lets it give room back to the two ends; below it the + nav is one word and a chevron, and the same rule let flexbox squeeze that word to 33px — the + German bar rendered "Men" with the chevron cut off, on the only control that reaches the other + six pages. The ends are sized by their content and the middle was the only thing that could + yield, so a longer word at either end came out of the one item that must never shrink. + */ + header.site .site-nav { flex: 0 0 auto; } +} + +/* + Step 4 — the right cluster runs out too. `submit` joins the menu, which is where the markup + already carries it; `about` and `sign in` stay, the first because it is two words and the second + because it is the one item at that end that changes what the site will let a reader do. Still no + destination lost. +*/ +@media (max-width: 640px) { + header.site { padding: 0 14px; gap: 12px; } + header.site nav.account a.action:not(.primary) { display: none; } + header.site form.theme { gap: 6px; } +} + +/* + Step 5 — below a small phone the type gives way before anything else does. Nothing is dropped and + nothing moves; the two clusters at the ends are simply set a step smaller, which is the last + decoration the bar has left to spend. +*/ +@media (max-width: 460px) { + header.site { padding: 0 8px; gap: 6px; } + header.site .mark { font-size: 15px; } + header.site form.theme { gap: 4px; } + header.site form.theme button { font-size: 12px; } + header.site nav.account { gap: 6px; } + header.site nav.account a { font-size: 12.5px; } + header.site nav.account a.action { padding: 5px 8px; } +} + +/* + Step 6 — and the bar is measured in a language, not in English. + + Every step above was tuned against a bar reading "menu · about · submit · sign in · auto light + dark", which is the shortest this chrome is ever going to be. The same bar in German is "Menü · + über uns · anmelden · auto hell dunkel" and wanted seventeen more pixels than a 430px phone has; + Dutch was twelve pixels from the same wall. So the last step is a real one rather than a hedge, + and it spends the only thing left that is not a destination: the theme control's three words + become the row's tightest type, and the wordmark gives up a point with them. Nothing moves and + nothing is dropped — the German bar and the English bar hold the same seven pages either side of + it, which is the property the whole ladder exists to keep. +*/ +/* + Step 7 — and on the narrowest phone the bar stops being one line, for whichever language needs it. + + At 360px the German bar wants 412px: "mu*index · Menü ▾ · über uns · anmelden · auto hell dunkel". + Everything above this step spends decoration and the ladder has none left — and the item that fell + off the end was the theme control, clipped past `.shell`'s `overflow-x: clip` and so not merely + cramped but gone. A reader on a 360px phone in German could not change the theme at all. + + No width triggers this and no language is named in it: the bar already wraps on content, so the + theme control takes a second row exactly where it stops fitting. German and Dutch reach it at 360 + and English and Chinese do not, which is the ladder working rather than a rule about German. The + one thing set here is what happens *after* a wrap — the control keeps the trailing edge it has on + one line, so the bar does not appear to change its mind about which end it belongs to. +*/ +header.site form.theme { margin-inline-start: auto; } + +@media (max-width: 480px) { + header.site .mark { font-size: 14px; letter-spacing: 0; } + header.site form.theme { gap: 4px; } + header.site form.theme button { font-size: 11.5px; } + header.site nav.account a { font-size: 12px; } +} /* ── the reader's theme ──────────────────────────────────────────────────── Buttons drawn as the links beside them, because they belong to that row and a row of three real @@ -281,7 +794,10 @@ header.site nav.account a { color: var(--text); } brighter: a state carried by colour alone is a state a greyscale display does not carry, which is the same rule the heatmap's hatch exists for. */ -form.theme { display: flex; align-items: baseline; gap: 8px; margin: 0; } +/* Centred in the bar rather than baseline-aligned to it: the bar stretches its children to 60px, + and a baseline inside a stretched box lands wherever the box's first line happens to be — which + put this cluster sixteen pixels above the buttons beside it. */ +form.theme { display: flex; align-items: center; gap: 8px; margin: 0; } form.theme .kick { font: 10px/1 var(--kick); letter-spacing: 0.16em; @@ -312,19 +828,55 @@ pre.plain { white-space: pre-wrap; font-size: 13px; line-height: 1.6; color: var /* ── home ──────────────────────────────────────────────────────────────── */ -.hero h1 { font-size: 28px; max-width: 24ch; line-height: 1.2; } +.page-flow { display: flex; flex-direction: column; gap: 22px; } + +/* + The heading runs to whatever width it has. It carried a 24ch clamp, which broke "A directory of + the MU* hobby" across two lines at every width including 1440 — a measure is for paragraphs, and + this is one short line whose whole job is to be read at a glance. +*/ +.hero-head { display: flex; flex-direction: column; gap: 8px; } +.hero h1 { font-size: 26px; line-height: 1.2; letter-spacing: -0.01em; margin: 0; } -form.search { display: flex; gap: 8px; max-width: 560px; margin: var(--cpad) 0; } -form.search input { flex: 1; min-width: 0; } +/* The one sentence a stranger needs before they read a count. Dimmed a step so the heading above it + stays the first thing the eye takes, and measured so it never runs past a comfortable line. */ +.hero .lede { font-size: 14.5px; line-height: 1.6; color: var(--dim); max-width: 62ch; margin: 0; } +/* + One field with the search glyph inside it, and the submit beside it at the field's own height. + The two used to be separate boxes at two different sizes, which read as a form rather than as the + one control it is. +*/ +form.search { display: flex; gap: 8px; max-width: 520px; margin: 0; } +.search-field { + flex: 1 1 auto; min-width: 0; + display: flex; align-items: center; gap: 10px; + background: var(--recessed); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 0 14px; +} +.search-field:focus-within { border-color: color-mix(in srgb, var(--accent) 55%, var(--line)); } +.search-glyph { color: var(--accent); font-size: 15px; line-height: 1; } input[type="search"], input[type="text"] { background: var(--recessed); border: 1px solid var(--line); color: var(--text); font: 14px var(--sans); padding: 8px 11px; - border-radius: 6px; + border-radius: var(--radius); +} + +/* After the generic input rule, not before it: the field's own box is the bordered thing here and + the input inside it is only the caret. Ordered the other way round the generic rule won on equal + specificity and drew a second border inside the first. */ +.search-field input[type="search"] { + flex: 1; min-width: 0; + background: none; border: 0; padding: 11px 0; border-radius: 0; + color: var(--text); font: 14px var(--sans); } +.search-field input[type="search"]:focus { outline: none; } +.search-field input[type="search"]::placeholder { color: var(--faint); } button { background: var(--raised); @@ -332,95 +884,268 @@ button { color: var(--text); font: 14px var(--sans); padding: 8px 14px; - border-radius: 6px; + border-radius: var(--radius); cursor: pointer; } -ul.counts { +/* ── the four figures ────────────────────────────────────────────────────── + A label and a value, in a framed tile, at the size a front page's whole claim deserves. They were + one 12px mono line reading like a caption, which is what a count of the hobby is not. */ + +ul.tiles { list-style: none; margin: 0; padding: 0; - display: flex; flex-wrap: wrap; gap: 20px; font-size: 12px; color: var(--dim); + display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 12px; +} +ul.tiles li { + border: 1px solid var(--line-soft); border-radius: var(--radius-lg); + background: var(--surface); box-shadow: var(--shadow); padding: 16px; + display: grid; gap: 8px; align-content: start; +} +ul.tiles a { text-decoration: none; display: grid; gap: 8px; } + +/* + The value stays in the machine face. The drawing sets it in the UI sans, and the handoff's own + token rules say the opposite two pages earlier — "mono for anything the machine said: counts, + versions, ages … the one typographic rule carrying meaning". A count of the hobby is the purest + case of a machine-said value on this site, so the prose wins over the component. The size is the + drawing's. +*/ +ul.tiles .kicker { color: var(--faint); } +ul.tiles .figure { + font-size: 28px; font-weight: 600; letter-spacing: -0.01em; color: var(--text); + line-height: 1; } -ul.counts strong { color: var(--text); font-weight: 400; } +ul.tiles .figure.state-present { color: var(--accent); } /* The crawler strip, under the counts it is the provenance of. A step smaller and dimmer than they are, because it is about the instrument rather than about the hobby — a reader who never notices it has lost nothing, and one who comes looking for it knows where to look. */ +/* Hairlines top and bottom, as the handoff frames it: the strip is a band of provenance under the + figures it qualifies, and a band reads as one thing where a loose line reads as a footnote. */ p.crawler { - margin: 10px 0 0; font-size: 11px; + margin: 0; padding: 12px 0; font-size: 12.5px; + display: flex; align-items: center; gap: 10px; + border-top: 1px solid var(--line-soft); border-bottom: 1px solid var(--line-soft); } +p.crawler .state-present { text-shadow: var(--glow); } .feeds { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); - gap: var(--cpad); - margin: calc(var(--cpad) * 2) 0; + gap: 22px; + margin: 0; } +/* A column: its heading, then rows. The heading carried a 33.6px top margin from the global h2 + rule, which put each of the three columns a block lower than the rhythm the page is set on. */ +.feeds .feed { display: flex; flex-direction: column; } +.feeds .feed h2 { margin: 0 0 10px; } +.feeds .feed-lit h2 { color: var(--accent); } +.feeds .feed-dark h2 { color: var(--amber); } + /* ── liveness feed cards ─────────────────────────────────────────────────── One shape, three registers. Neutral, unlit, and — for a return — the one place the site raises its voice, because a game dark for two years answering is what no incumbent can tell you. */ -.feed-card { - border: 1px solid var(--line); - background: var(--recessed); - border-radius: 8px; - padding: 12px 14px; - margin-bottom: 10px; +/* A row: name left, age right, a hairline between. Three columns of boxed cards gave a name and an + age the furniture of a section, and framed things on this site are supposed to mean something — + the connect screen is quoted in a frame because it is somebody else's. */ + +.feed-row { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 12px; + padding: 9px 0; + border-bottom: 1px solid var(--line-soft); } -.feed-card .feed-name { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; margin-top: 6px; } -.feed-card .feed-name a { font-size: 15px; font-weight: 600; text-decoration: none; } -.feed-card .detail { font-size: 11.5px; color: var(--dim); margin: 4px 0 0; } -.feed-card.unlit { opacity: 0.78; } -.feed-card.unlit .feed-name a { color: var(--dim); } +/* The last row of a column closes on the column's own edge rather than on a rule to nothing. */ +.feeds .feed-row:last-child { border-bottom: 0; } -.feed-card.returned { - border-color: color-mix(in srgb, var(--accent) 34%, transparent); - background: color-mix(in srgb, var(--accent) 10%, transparent); - animation: return-glow 1.4s ease-out 1; +.feed-row .feed-name { + min-width: 0; font-size: 14px; color: var(--text); text-decoration: none; +} +.feed-row .feed-name:hover { text-decoration: underline; text-underline-offset: 3px; } +.feed-row .feed-state { + flex: none; display: inline-flex; align-items: baseline; gap: 5px; + font-size: 12.5px; color: var(--faint); +} +.feed-row .feed-state .sep { color: var(--faint); } +.feed-row.unlit .feed-name { color: var(--dim); } + +/* The return is the same row as the other two. The accent is on the word that says it is back and + nowhere else — a tinted card with a glow gave one column furniture the other two did not have, + and the eye cannot run down three lists that are drawn as different kinds of thing. */ +.feed-row.returned .feed-state .state-present { color: var(--accent); } + +/* ── the card's own footer ───────────────────────────────────────────────── + The same row on every page: where else to go, and the text mirror last. A rule above it, because + it is the end of the card and not another block in the column. */ + +.card-footer { + display: flex; flex-wrap: wrap; gap: 16px; + margin: 0; padding-top: 14px; + border-top: 1px solid var(--line-soft); + font-size: 13px; color: var(--dim); +} + +/* On a card page the footer is a band of the card rather than a block in a padded column, so it + pads itself to the same edge every other band uses. */ +.card-page > .card-footer { padding: 14px var(--gutter) 22px; } +.card-footer { align-items: baseline; } +.card-footer a { text-decoration: none; } +.card-footer a:hover { text-decoration: underline; text-underline-offset: 3px; } +.card-footer .sep { color: var(--faint); } + +/* + The language switcher, at the far end of the footer beside the plain-text link — the two controls + that change how the page is read rather than which page it is. Pushed there rather than placed, so + the destinations keep the left edge they had. +*/ +form.locale { display: flex; align-items: center; gap: 10px; margin: 0 0 0 auto; } + +/* The label is the theme control's kicker, because these two sit side by side and are the same kind + of thing: a small named control that changes how the page is read. */ +form.locale .kick { + font: 10px/1 var(--kick); + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--faint); } -/* Motion budget, one of three: once, on arrival, never repeating. */ -@keyframes return-glow { - from { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 55%, transparent); } - to { box-shadow: 0 0 0 14px transparent; } +/* + One box, drawn as the search field is: the control and its submit inside a single hairline rather + than a native dropdown and a wide button beside it. The footer is otherwise text, and two heavy + rectangles in it read as the loudest thing on the page — which a language switcher is not. +*/ +.locale-field { + display: flex; align-items: center; gap: 2px; + background: var(--recessed); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 0 4px 0 11px; +} +.locale-field:focus-within { border-color: color-mix(in srgb, var(--accent) 55%, var(--line)); } + +/* Stripped of the platform's own chrome so it matches the site rather than the OS — a native select + is a different shade, a different radius and a different caret on each of the three, and none of + them is this one. */ +form.locale select { + appearance: none; + background: none; border: 0; border-radius: 0; margin: 0; + padding: 7px 18px 7px 0; + color: var(--text); font: 13px var(--sans); + cursor: pointer; + + /* Sized to the option in force rather than to the longest one in the list. A select is as wide + as its widest child by default, so "English" sat in a box cut for "Русский (CI canary)" and + the caret ended up a hand's width from the word it belongs to. Where this is not supported the + box is merely the old width, which is the behaviour it replaces — nothing is lost by it. */ + field-sizing: content; +} +/* + The ring goes round the box, not round the flat control inside it. Stripping `appearance` takes the + platform's focus ring with it, and the border tint below is a mouse affordance rather than an + indicator a keyboard reader can rely on — 2.4.7 wants something that survives at any contrast. +*/ +form.locale select:focus { outline: none; } +.locale-field:has(select:focus-visible) { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +/* The nav's chevron, not a new glyph. Pulled back over the select's right padding and transparent to + the pointer, so the caret is part of the same click target rather than a dead spot beside it. */ +.locale-caret { + margin-left: -16px; + color: var(--faint); font-size: 11px; line-height: 1; + pointer-events: none; +} + +/* The search's own arrow. A submit is needed because this site runs no script, and this is the + smallest shape that says "commit" in a vocabulary the reader has already met. */ +.locale-go { + flex: none; display: flex; align-items: center; justify-content: center; + width: 22px; height: 22px; padding: 0; + background: none; border: 0; border-radius: 6px; + color: var(--faint); font-size: 13px; cursor: pointer; } +.locale-go:hover { color: var(--accent); background: var(--raised); } + -.feed-card.returned .return-line { font-size: 15px; line-height: 1.5; color: var(--text); margin: 6px 0 0; } /* ── game page ─────────────────────────────────────────────────────────── */ -.game-head { display: flex; gap: var(--cpad); flex-wrap: wrap; align-items: flex-start; } -.game-head .game-title { flex: 1; min-width: 260px; } +/* The header band: padded like every other band on a card page, with a rule under it running the + card's full width. */ +.game-head { + display: flex; gap: 20px; flex-wrap: wrap; + align-items: flex-start; justify-content: space-between; + padding: 24px var(--gutter); + border-bottom: 1px solid var(--line-soft); +} + +.game-identity { display: flex; gap: 16px; align-items: flex-start; min-width: 0; flex: 1 1 340px; } +.game-head .game-title { display: flex; flex-direction: column; gap: 6px; min-width: 0; } +.game-head .game-title h1 { margin: 0; } +.game-head .claim-line { margin: 0; font-size: 13.5px; } .game-head .plate { - width: 68px; height: 68px; flex: none; - border: 1px solid var(--line); border-radius: 8px; + width: 64px; height: 64px; flex: none; + border: 1px solid var(--line-soft); border-radius: 12px; background: var(--recessed); color: var(--dim); display: grid; place-items: center; font-size: 22px; + padding: 8px; box-sizing: border-box; +} + +/* + The count and the probe that produced it, as one block. It was a number and a provenance chip on + one line at body size, which set the fact most readers came for at the size of the metadata + qualifying it. +*/ +.game-figure { + flex: 0 0 auto; display: flex; flex-direction: column; align-items: flex-end; gap: 6px; + text-align: right; } +.game-figure .figure-count { + margin: 0; font-size: 20px; color: var(--accent); + text-shadow: var(--glow); font-variant-numeric: tabular-nums; +} +.game-figure .figure-count .unit { font-size: 15px; } + +/* 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; } +.game-figure .figure-count.unknown { + color: var(--faint); text-shadow: none; font-size: 15px; font-variant-numeric: normal; +} +.game-figure .figure-age { margin: 0; } + +/* One column, and the sections spaced by it rather than by margins that disagree. */ +.game-body { display: flex; flex-direction: column; gap: 28px; padding: 28px var(--gutter) 8px; } +.game-body > section { display: flex; flex-direction: column; gap: 10px; } +.game-body > section > h2 { margin: 0; font-size: 17px; } /* An icon sits in the monogram's place and at its size. `contain` rather than `cover`: a logo that is not square is letterboxed rather than cropped, because cropping somebody's artwork to fit our box is a small edit to a thing they published. The plate's own background shows through where the image does not reach, so a transparent PNG lands on the same recessed square as the monogram. */ .game-head img.plate { object-fit: contain; padding: 4px; } -.game-head .connect { flex: none; min-width: 240px; } -.game-head .lede { font-size: 15px; line-height: 1.65; max-width: 62ch; } -.game-head .tagline { font-size: 16px; } -.game-head .live { font-size: 14px; } +.game-head .lede { font-size: 14.5px; line-height: 1.6; max-width: 62ch; margin: 0; color: var(--dim); } +.game-head .tagline { font-size: 15px; margin: 0; } -/* The count's label, beside the count. Spaced in CSS rather than by a space in the markup, because - the renderer trims whitespace around a conditional — and sized to the line it qualifies. */ -.game-head .live .chip { margin-left: 6px; } -.game-head .live .chip .age { font-size: inherit; } +/* + An address is a line, not a card. Each was a bordered panel the full width of the title column, + which gave a hostname the furniture of the one framed thing on this page — somebody else's output, + quoted. +*/ .game-head .endpoint { - background: var(--raised); border: 1px solid var(--line); border-radius: 6px; - padding: 7px 10px; font-size: 12.5px; margin-bottom: 6px; + display: flex; flex-wrap: wrap; align-items: baseline; gap: 12px; + margin: 0; padding-top: 4px; + font-size: 13.5px; color: var(--text); } -/* Same reason as the chip above, and it shipped as "penultimatemush.com 9500answering here since - Jul 2026": the renderer trims the whitespace between the port and a conditional span, so the gap - has to be a margin rather than a space in the markup. */ -.game-head .endpoint > span { margin-left: 8px; } +.game-head .endpoint .faint { font-size: 12.5px; } +.game-head .endpoint.former .address { color: var(--dim); } .badges { margin: 4px 0 6px; } .badge.claimed { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 34%, transparent); } @@ -433,6 +1158,7 @@ p.crawler { } .archive-plate strong { color: var(--text); font-weight: 500; } +/* Kept for any surface that still wants two columns; the game page is one column now. */ .two-up { display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(0, 0.85fr); gap: calc(var(--cpad) * 2); } /* @@ -441,7 +1167,7 @@ p.crawler { on it was clipped, while .heat-wrap's own overflow-x sat there doing nothing. The only region allowed to scroll sideways is the ANSI frame. */ -.two-up > *, .feeds > *, .row-main > * { min-width: 0; } +.two-up > *, .feeds > *, .row-main > *, .game-body > * { min-width: 0; } .section-head { display: flex; align-items: baseline; justify-content: space-between; gap: var(--cpad); flex-wrap: wrap; } .section-head .count { font-size: 12px; margin: 0; } @@ -556,8 +1282,14 @@ nav.spans .span { padding: 6px 10px; border: 1px solid var(--line); border-radius: 999px; color: var(--dim); text-decoration: none; } -nav.spans .span:hover { border-color: var(--dim); color: var(--text); } -nav.spans .span.on { border-color: var(--accent); color: var(--accent); } +nav.spans a.span:hover { border-color: var(--dim); color: var(--text); } + +/* The pressed one, which is a span rather than a link to the page you are on. Tinted as well as + outlined, because with no hover to distinguish it the border alone was doing all the work. */ +nav.spans .span.on { + border-color: var(--accent); color: var(--accent); + background: color-mix(in srgb, var(--accent) 10%, transparent); +} /* The shape sits under the range and is the smaller question of the two, so it follows immediately rather than being spaced as a second block. */ nav.spans.shapes { margin-top: -4px; } @@ -569,11 +1301,37 @@ details.read-as-text[open] > *:not(summary) { animation: reveal 120ms ease-out; /* Motion budget, three of three. */ @keyframes reveal { from { opacity: 0; } to { opacity: 1; } } -ul.perday, ul.spells { +ul.spells { list-style: none; margin: 6px 0 0; padding: 0; font-family: var(--mono); font-size: 12px; color: var(--dim); } -ul.perday li, ul.spells li { padding: 2px 0; } +ul.spells li { padding: 2px 0; } + +/* The grid's text alternative: seven rows rather than a hundred and sixty-eight cells. Numbers are + tabular so the columns scan, and the caption sits under the table it describes. */ +table.perday { + width: 100%; margin: 6px 0 0; border-collapse: collapse; + font-family: var(--mono); font-size: 12px; color: var(--dim); + caption-side: bottom; +} +table.perday caption { padding: 8px 0 0; color: var(--faint); text-align: left; } +table.perday th, table.perday td { padding: 4px 8px 4px 0; text-align: left; } +table.perday thead th { + font: 10px/1 var(--kick); letter-spacing: 0.16em; text-transform: uppercase; color: var(--faint); + border-bottom: 1px solid var(--line); padding-bottom: 6px; +} +table.perday tbody th { color: var(--text); font-weight: 600; } +table.perday td.num { font-variant-numeric: tabular-nums; } + +/* Not enough measured days to draw a week. A panel rather than a bare line, so the section reads as + answered rather than as a graphic that failed to load. */ +.sparse { + margin: 10px 0 var(--cpad); padding: calc(var(--cpad) * 1.5) var(--cpad); + border: 1px dashed var(--line); border-radius: 8px; + background: var(--recessed); color: var(--dim); text-align: center; +} +.sparse p { margin: 0; } +.sparse .kicker { margin-bottom: 6px; } /* ── availability strip ──────────────────────────────────────────────────── Ninety bars, oldest left. Degraded is a *short* bar and not merely another colour; unreachable @@ -600,19 +1358,50 @@ dl.stats dd { margin: 3px 0 0; font-size: 17px; } /* ── the ANSI quotation frame ──────────────────────────────────────────── */ -figure.ansi .quote { position: relative; background: #000; } -figure.ansi .quote pre { - margin: 0; padding: var(--cpad); - font: 13px/1.5 var(--mono-cjk); font-variant-ligatures: none; - color: #aaaaaa; white-space: pre; overflow-x: auto; +/* Every row, once, in a region that scrolls — a screen twice the height of the frame is scrolled + rather than cropped and offered again below. The cap is about 26 rows, which is what a terminal + showed the player; past that the reader scrolls the same way they would have on connecting. + Focusable, because a scrolling region a keyboard cannot reach is one a keyboard cannot read. */ +figure.ansi .quote { + position: relative; background: #000; + border-radius: 6px; + max-height: 33em; overflow: auto; overscroll-behavior: contain; } -figure.ansi .crop-fade { - position: absolute; left: 0; right: 0; bottom: 0; height: 56px; - background: linear-gradient(to bottom, rgba(0, 0, 0, 0), #000); - pointer-events: none; +figure.ansi .quote:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } +figure.ansi .quote pre { + margin: 0; padding: 14px 16px; + font: 12px/1.4 var(--mono-cjk); font-variant-ligatures: none; + color: #aaaaaa; white-space: pre; } figure.ansi figcaption { display: flex; gap: 8px; flex-wrap: wrap; } -details.read-as-text .ansi { border: 1px solid var(--line); } + +/* + ── the words, in the same figure ────────────────────────────────────────── + The prose lines are what a player actually needs — the address and the connect/create/QUIT + instructions — and they were behind a disclosure outside the frame, which made the block a picture + with a footnote rather than a quotation with a reading. Under a hairline, with the kicker the + handoff names, and open: this is the one text copy of the art, and a text alternative a reader has + to open is one most readers never see. +*/ +.screen-text { + border-top: 1px solid var(--line-soft); + padding-top: 12px; +} +.screen-text > summary { cursor: pointer; list-style: none; } +.screen-text > summary::-webkit-details-marker { display: none; } +.screen-text > summary::after { content: " ▾"; color: var(--faint); letter-spacing: normal; } +.screen-text[open] > summary::after { content: " ▴"; } +.screen-text .kicker { color: var(--accent); } +.screen-text > summary:hover .kicker { color: var(--text); } +.screen-text > pre { margin-top: 8px; } +.screen-text pre { + margin: 0; + font: 12.5px/1.7 var(--mono-cjk); font-variant-ligatures: none; + color: var(--text); + white-space: pre-wrap; + overflow-wrap: anywhere; +} + pre.plain-screen { background: var(--recessed); padding: var(--cpad); overflow-x: auto; font-size: 12px; } .ansi-plate { @@ -637,28 +1426,54 @@ pre.plain-screen { background: var(--recessed); padding: var(--cpad); overflow-x ul.games, ul.archive { list-style: none; margin: 0; padding: 0; } +/* + The row pads itself and the hairline runs the column's full width. It used to hang on a negative + margin so a hover plane could reach past the reading measure — the results column owns its own + padding now, so the row simply is the width it is drawn at. +*/ ul.games > li, ul.archive > li { - border-bottom: 1px solid var(--line); - padding: var(--row-pad) var(--cpad); - - /* Negative margin so the hover plane runs to the reading measure's own edge and the text does - not shift when it appears — a row that moves under the pointer is a row you mis-click. */ - margin: 0 calc(var(--cpad) * -1); + border-bottom: 1px solid var(--line-soft); + padding: var(--row-pad) var(--gutter); + margin: 0; } -ul.games > li.game-row:hover { background: var(--surface); } +ul.games > li.game-row:hover { background: var(--raised); } + +/* The row is a touch target before it is a layout: the game's name is the link, and on a phone a + list of five hundred of them is a list of five hundred things to hit. The handoff's geometry — + 13px by 20px over a 46px floor — is what puts a comfortable target under every name. */ +ul.games > li.game-row { min-height: 46px; padding: var(--row-pad) var(--gutter); } /* Three columns: the game's face, what it is, and what we measured. The plate is what gives a five-hundred-row list a left edge the eye can run down — before it, every row began with a different-length name and the only way to find a game was to read them. `start` rather than `baseline` because a 36px box has no baseline worth aligning a name to. */ +/* Two zones on the row and a third under them: identity on the left, measurement on the right, and + the meta line spanning beneath the name. The handoff's geometry exactly. */ +/* + Three columns and two lines: identity, measurement, freshness across the top, and the machine + facts spanning beneath. The count and the age are siblings on one baseline — they were a nested + two-row grid in the third column, which stacked the age under the count and left neither able to + line up down the page. +*/ .row-main { display: grid; - grid-template-columns: auto minmax(0, 1fr) auto; - gap: 2px var(--cpad); - align-items: start; + grid-template-columns: minmax(0, 1fr) auto auto; + gap: 3px 20px; + align-items: baseline; } +/* + `contents` so the name and the metadata are grid items of the row itself rather than of a box + inside column one — that is what lets the metadata span all three columns while the name occupies + only the first. +*/ +.row-main .row-text { display: contents; } +.row-main .row-head { grid-column: 1; grid-row: 1; min-width: 0; } +.row-main .row-count { grid-column: 2; grid-row: 1; } +.row-main .row-seen { grid-column: 3; grid-row: 1; } +.row-main .meta { grid-column: 1 / -1; grid-row: 2; min-width: 0; } + /* The row's own plate. Same recessed square, same border, same monogram treatment as the game page's — one vocabulary at two sizes, so a reader who has seen the page recognises the row. */ .row-main .plate { @@ -683,9 +1498,9 @@ ul.games .name, ul.archive .name { font-size: 15px; font-weight: 600; text-decor ul.games .name:hover { text-decoration: underline; text-underline-offset: 3px; } ul.archive .name { color: var(--dim); } -/* The majority state of the catalogue, so it is the quiet word rather than the third pill. A badge - every row wears distinguishes no row from any other, and at 700 rows it is chrome. */ -ul.games .unclaimed { font: 10px/1 var(--kick); letter-spacing: 0.14em; text-transform: uppercase; color: var(--faint); } +/* No rule for "unclaimed": it is not on a row any more. The majority state of the catalogue said on + every row is an administrative fact about our records, repeated once per game at a reader looking + for somewhere to play. It stays on the game page, where it arrives with the way to change it. */ .row-text .tagline { margin: 2px 0 0; @@ -699,70 +1514,75 @@ ul.games .unclaimed { font: 10px/1 var(--kick); letter-spacing: 0.14em; text-tra white-space: nowrap; } -ul.games .meta, ul.archive .meta { margin: 3px 0 0; font-size: 11.5px; color: var(--faint); } +ul.games .meta, ul.archive .meta { margin: 3px 0 0; font-size: 12.5px; color: var(--dim); } ul.games .meta .protocol { white-space: nowrap; } +/* The protocols this row did not print. A count rather than a name, and the names are in its title + for a pointer and on the game's page for everybody. */ +ul.games .meta .protocol.more { color: var(--dim); } + /* A provenance chip on a listing or archive row. The chip's own age size is set for body copy, and these contexts are already smaller than that — so the age rides the row's size rather than growing larger than the text it qualifies. Everything else about the chip is unchanged, deliberately: the row, the archive and the game page have to be one vocabulary and not three that resemble each other. `.meta` is the class both lists give that line, so both are covered by naming it once. */ -.meta .chip .age, .row-figure .chip .age { font-size: inherit; } +.meta .chip .age { font-size: inherit; } + +/* ── the two measured columns ────────────────────────────────────────────── + Tabular figures so the counts line up down the page without a table, and right-aligned so the + units place is the edge the eye runs down. + + The count carries no unit and no provenance glyph. "on" after every number was one English word + repeated 515 times — the column is named once, above, which is also one string per locale rather + than five hundred — and where a number came from is a fact you weigh when choosing between two + games rather than while scanning five hundred, so it lives on the game page and on the facet + badges instead. */ -/* The measured column. Tabular figures so the counts line up down the page without a table. */ -.row-figure { +.row-count { margin: 0; + grid-column: 2; text-align: right; - font-size: 12px; white-space: nowrap; + font-size: 15px; font-variant-numeric: tabular-nums; - display: grid; - gap: 2px; - justify-items: end; + color: var(--text); } -/* ── the count, which is the fact most readers came for ──────────────────── - Players on now is a fact like any other and is labelled like any other — the glyph beside it means - measured or declared exactly as it does in every other column, because a second meaning for the - accent dot would cost it the first one. What sets it apart is scale and position, which carry no - meaning to lose: the number is 17px and nothing else in a row is above 15, so it is what the eye - lands on, and it sits at the head of a column that lines up down the page. - - The number's own weight then does the rest of the work. A count with somebody in it is at full - text colour; a measured zero is dimmed one step — still a measurement, still glyphed, still above - the break, and visibly not somewhere to go tonight. An unknown says so in words and is dimmer - again. Three states, three weights, no new colours. */ +/* Somebody is on. The one place a live count is marked as such, and the glow follows the digits + rather than a pip beside them. */ +.row-count.live { color: var(--accent); } -.row-figure .players { color: var(--text); display: block; } -.row-figure .players .count { font-size: 17px; font-weight: 600; letter-spacing: -0.01em; } -.row-figure .players .pip { margin-right: 5px; } +/* A measured zero: dimmed one step, never faint. We got in and nobody was there is a result, and + it is not the same fact as a count we could not read. */ +.row-count.empty { color: var(--dim); } -/* A count of nobody, measured. Dimmed rather than hidden — we got in and nobody was there is a - result, and the row above the break is where it belongs. */ -.row-figure .players.empty .count { color: var(--dim); font-weight: 400; } +/* Not a number at all, so it is not set as one: smaller, faint, and in words. */ +.row-count.unknown { color: var(--faint); font-size: 13.5px; font-variant-numeric: normal; } -/* The one place a live count is marked as such: a soft glow on the pip where somebody is actually on - right now. A text-shadow rather than a ring, because a ring is drawn on the glyph's inline box — - which is a tall narrow rectangle, not the dot — and rendered as a lozenge round a circle. This - follows the glyph itself at any font and any size. - - Static. Five hundred of these animating is a page that cannot be read, and the motion budget is - spent on the "came back" card. It is a reinforcement and never the carrier: the count, the glyph - and the word behind it all say the same thing without it. */ -.row-figure .players.live .pip.state-present { - text-shadow: 0 0 7px color-mix(in srgb, var(--accent) 60%, transparent); +.row-seen { + margin: 0; + grid-column: 3; + text-align: right; + white-space: nowrap; + font-size: 12.5px; + color: var(--faint); + font-variant-numeric: tabular-nums; } -/* The count's own chip rides beside the figure. Spaced in CSS and not by a space in the markup, - because the renderer trims whitespace around a conditional and "219 on" ran into its own glyph. */ -.row-figure .players .chip { margin-left: 6px; } -.row-figure .players .unit { color: var(--faint); } -.row-figure .players.unknown { color: var(--faint); } -.row-figure .seen { color: var(--faint); font-size: 11.5px; } - -/* What a window sort ranked this row on, printed on the row it ranked. Faint and small: it is the - basis for the order rather than a fact the row is about, and the count above it stays the figure. */ -.row-figure .window { color: var(--faint); font-size: 11.5px; } +/* + Claimed: a ring with a tick in it and no visible word. It was a pill the width of "claimed" on the + handful of rows that carry it — furniture for a fact a browsing reader did not come for. The name + is on the element, where a screen reader and a tooltip both reach it. +*/ +.claim-ring { + display: inline-flex; align-items: center; justify-content: center; + width: 16px; height: 16px; flex: none; + border-radius: 99px; + border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent); + background: color-mix(in srgb, var(--accent) 12%, transparent); + color: var(--accent); + font: 10px/1 var(--mono); +} /* Archived: dimmed one step, and that is the whole treatment. No red, no strikethrough, no "dead" — the entry is a library record for a periodical that ceased publication. */ @@ -788,26 +1608,84 @@ li.unranked-break { li.unranked-break:hover { background: var(--surface); } -dl.archive-facts { margin: 0; font-size: 12px; min-width: 220px; } +/* + The archive row is two zones, not the listing's three — it has no count column, and its facts + panel is a definition list rather than two cells. It shares .row-main with the listing for the + gap and the baseline, so it names its own columns rather than inheriting a third it has nothing + to put in. +*/ +ul.archive .row-main { grid-template-columns: minmax(0, 1fr) auto; } + +dl.archive-facts { margin: 0; font-size: 12px; min-width: 200px; } dl.archive-facts dd { margin: 0 0 6px; color: var(--dim); } +/* Below the width where a 200px facts panel and a game's metadata both fit, they stack — the panel + was holding its minimum and clipping the name and codebase beside it. */ +@media (max-width: 620px) { + ul.archive .row-main { grid-template-columns: minmax(0, 1fr); } + dl.archive-facts { min-width: 0; } +} + /* ── the listing's own header ────────────────────────────────────────────── */ -.listing-head { margin-bottom: var(--cpad); } -.listing-head p { margin: 0; max-width: 62ch; } +/* The card's own header band: padded like the drawing, with a rule under it running the full + width of the card rather than stopping at a content measure. */ +.listing-head { + margin: 0; + padding: 22px var(--gutter) 18px; + border-bottom: 1px solid var(--line-soft); + display: flex; flex-direction: column; gap: 7px; +} +.listing-head h1 { margin: 0; } +.listing-head p { margin: 0; max-width: 62ch; font-size: 14px; } + +/* ── the listing's toolbar ───────────────────────────────────────────────── + What order this is in on the left, the control that changes it and the two column names on the + right, over the columns they name. */ .listing-bar { display: flex; align-items: baseline; - gap: var(--cpad); + justify-content: space-between; + gap: 12px 20px; flex-wrap: wrap; - margin: calc(var(--cpad) * 1.5) 0 4px; + margin: 0; + padding: 11px var(--gutter); + border-bottom: 1px solid var(--line-soft); + font-size: 13px; + color: var(--dim); } -.listing-bar .result-count { margin: 0; font-size: 13px; } -.listing-bar .result-count strong { font-size: 19px; font-weight: 600; } .listing-bar .listing-order { margin: 0; } -.listing-bar .listing-order a { color: var(--faint); } +.listing-bar .listing-order .order-name { color: var(--text); } +.listing-bar .listing-order .sep { margin: 0 6px; color: var(--faint); } +.listing-bar .listing-order a { color: var(--dim); } + +.listing-tools { display: flex; align-items: baseline; gap: 20px; } +.listing-tools .column-head { margin: 0; } + +/* ── a pressed-state toggle group ────────────────────────────────────────── + Not links to the page you are already reading. The one in force is a with aria-current, so + there is no keystroke that lands on a control whose only effect is to re-fetch this page — the + same fix the trend range and shape selectors already carry. */ + +.switch { display: flex; align-items: baseline; gap: 8px; font-size: 12.5px; } +.switch a { color: var(--dim); text-decoration: none; } +.switch a:hover { color: var(--text); text-decoration: underline; text-underline-offset: 3px; } +.switch .on { color: var(--accent); } +/* A rule, not a wider gap: the two halves of the switch are different kinds of order — three that + change which measurement the column shows, and two that only change the stacking. */ +.switch .switch-rule { + width: 1px; height: 13px; align-self: center; margin: 0 2px; background: var(--line); +} + +/* The window under the statistic it belongs to, quieter than the switch that produced it. */ +.switch.windows { + padding: 8px var(--gutter); + border-bottom: 1px solid var(--line-soft); + font-size: 12px; + justify-content: flex-end; +} /* Nothing matched. A plate rather than a paragraph, because an empty listing is a state and not an @@ -829,9 +1707,25 @@ dl.archive-facts dd { margin: 0 0 6px; color: var(--dim); } /* ── one column below 900px; no horizontal scroll except inside the frame ── */ @media (max-width: 900px) { - .two-up, .feeds { grid-template-columns: minmax(0, 1fr); } - .game-head { flex-direction: column; } - header.site { flex-wrap: wrap; } + .feeds { grid-template-columns: minmax(0, 1fr); } + + /* + The theme control is the one thing in the bar that goes nowhere, so it is what the bar gives + up its words for first: three buttons become their initials' worth of room. + + Taken out of the drawing and left in the accessibility tree, because the form names itself + with `aria-labelledby="theme-label"` and this is that label. `display: none` removed it from + both, so below 900px a screen reader met three buttons in a group with no name at all — the + one width where the surrounding words that would have explained them are gone too. + */ + header.site form.theme .kick { + position: absolute; + width: 1px; height: 1px; + padding: 0; margin: -1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + } /* All 168 cells at 12px rather than a scrolling grid — the whole week at a glance is the reason @@ -933,7 +1827,22 @@ ul.sole-use-names { ul.sole-use-names li { color: var(--dim); } /* Wide content scrolls inside its own box; the page body never scrolls sideways. */ -.table-wrap { overflow-x: auto; margin: var(--cpad) 0; } +/* + A scroller contains its own absolutely positioned descendants — and until it did, the game page + scrolled sideways. + + `.sr-only` is `position: absolute`, and with no positioned ancestor its containing block was the + initial one: the text a screen reader reads out of the capability matrix was laid out at the + matrix's own width, 463px in German, *outside* this box and outside `.shell`'s `overflow-x: clip`. + So five spans nobody can see pushed the document 59px wider than a 360px phone, and the whole page + scrolled to reveal nothing. Visually-hidden is not the same as out of the layout, and one + `position: relative` is what makes the difference invisible in both directions. +*/ +.table-wrap { position: relative; overflow-x: auto; margin: var(--cpad) 0; } + +/* Inside a section that already spaces its children, the wrapper carries no margin of its own — + and it keeps the table's rounded frame from being clipped square by the scroll box. */ +.game-body .table-wrap { margin: 0; border-radius: var(--radius); } table.protocols, table.ranking { border-collapse: collapse; width: 100%; } table.protocols th, table.protocols td, @@ -981,13 +1890,12 @@ table.ranking tbody th { font-weight: 400; } is the only reason they are there. ═══════════════════════════════════════════════════════════════════════════ */ -form.facet-form { - border: 1px solid var(--line); - border-radius: 10px; - background: var(--surface); - padding: var(--cpad); - margin: 0 0 var(--cpad); -} +/* + No box. It was a panel on a page — border, radius, its own surface — and inside the card it became + a box within a box, fighting the hairline that already separates it from the rows. The column's + own edge is the separation; the form is just its contents. +*/ +form.facet-form { border: 0; background: none; padding: 0; margin: 0; } /* ── the bar ─────────────────────────────────────────────────────────────── */ @@ -995,7 +1903,58 @@ form.facet-form { The archive's own search box is the same control with a legend on it, so it is styled here rather than growing a second look for one question asked on two pages. */ -.filter-bar, fieldset.facets { +/* ── the panel, as one disclosure below 900px ────────────────────────────── + Above that width the summary is hidden and the content is forced open, so the same markup is a + column on a laptop and a disclosure on a phone with no second rendering and no script. + + Guarded on ::details-content: where a browser does not have it, forcing a closed open + from CSS is not possible, so the summary stays visible at every width — a "filters" disclosure at + the top of the column rather than an unreachable panel behind a hidden control. */ + +.facet-collapse { min-width: 0; } +.facet-collapse > summary { + display: flex; align-items: center; gap: 8px; + cursor: pointer; list-style: none; + padding: 9px 8px; margin-bottom: 4px; + border: 1px solid var(--line); border-radius: var(--radius); + font: 10px/1 var(--kick); letter-spacing: 0.16em; text-transform: uppercase; + color: var(--dim); +} +.facet-collapse > summary::-webkit-details-marker { display: none; } +.facet-collapse > summary:hover { color: var(--text); border-color: var(--dim); } +.facet-collapse > summary::after { + content: "▾"; margin-left: auto; font-size: 11px; letter-spacing: normal; +} +.facet-collapse[open] > summary::after { content: "▴"; } +.facet-collapse .summary-count { + font-family: var(--mono); font-size: 11px; letter-spacing: normal; color: var(--accent); +} +.facet-body { display: flex; flex-direction: column; gap: 24px; } + +@supports selector(::details-content) { + @media (min-width: 901px) { + .facet-collapse > summary { display: none; } + .facet-collapse::details-content { content-visibility: visible; block-size: auto; } + } +} + +/* One field, and nothing else — the sort and the two inclusion switches that used to share this row + are a toolbar switch and two facet rows now. */ +.filter-bar { display: block; margin: 0; } +.filter-bar .search-field { padding: 0 8px 0 12px; } +.filter-bar .search-field input[type="search"] { padding: 7px 0; font-size: 13px; } + +/* The submit, inside the field. A 268px column has no room for a second box on that row, and a + submit that exists only as the return key is one a pointer-only reader cannot press. */ +.search-go { + flex: none; display: flex; align-items: center; justify-content: center; + width: 22px; height: 22px; padding: 0; + background: none; border: 0; border-radius: 6px; + color: var(--faint); font-size: 13px; cursor: pointer; +} +.search-go:hover { color: var(--text); background: var(--raised); } + +fieldset.facets { display: flex; gap: 10px; align-items: center; @@ -1004,7 +1963,7 @@ form.facet-form { fieldset.facets { border: 1px solid var(--line); - border-radius: 10px; + border-radius: var(--radius-lg); background: var(--surface); padding: var(--cpad); margin: 0 0 var(--cpad); @@ -1017,7 +1976,8 @@ fieldset.facets legend { color: var(--faint); } -.filter-bar input[type="search"], fieldset.facets input[type="search"] { flex: 1 1 16rem; min-width: 0; } +.filter-bar input[type="search"] { min-width: 0; width: 100%; } +fieldset.facets input[type="search"] { flex: 1 1 16rem; min-width: 0; } .bar-field { display: inline-flex; align-items: center; gap: 6px; } .bar-label { font: 10px/1 var(--kick); letter-spacing: 0.16em; text-transform: uppercase; color: var(--faint); } @@ -1030,39 +1990,125 @@ fieldset.facets legend { auto-fit at a narrow floor, so eight facets pack four to a row on a laptop, two on a tablet and one on a phone without a breakpoint being written for any of them. */ -.facet-grid { +/* ── the listing's two columns ───────────────────────────────────────────── + What you are asking on the left, what came back on the right. `stretch` so the filter column's + own edge runs the full height of the card: aligned to its content it stops under a short panel + and leaves a well beside the rows. */ + +.listing-grid { display: grid; - grid-template-columns: repeat(auto-fit, minmax(178px, 1fr)); - gap: 10px var(--cpad); - margin-top: var(--cpad); + grid-template-columns: minmax(0, 268px) minmax(0, 1fr); + align-items: stretch; + gap: 0; + margin: 0; } -/* The control sits at the bottom of its cell, so a label that wraps grows upward and every select in - the row stays on one line. Left to itself the grid aligns the tops, and one long facet name puts - one control half a row below its neighbours. */ +/* + Placed explicitly, and that is load-bearing: the panel renders TWO siblings into this grid — the + form, and the row of active-filter chips that sits outside it because a chip is a link and not a + control. Left to auto-placement the chips took the second column and the listing dropped to the + first, under a filter column a thousand pixels tall — so applying a filter looked like a page that + had lost its results. +*/ +/* + The filter column is a surface a step below the card, so it reads as a column even where its rows + run out — the border alone left it as an unmarked area of the same fill as the results beside it. +*/ +.listing-grid > .facet-form { + grid-column: 1; + grid-row: 1 / span 99; + background: var(--recessed); + border-right: 1px solid var(--line-soft); + + /* Less 8px than the gutter, because every row inside pads itself by that — which is what puts + a facet label on exactly the same left edge as the wordmark above it. */ + padding: 20px max(6px, calc(var(--gutter) - 8px)); + display: flex; + flex-direction: column; + gap: 24px; +} +.listing-grid > * { grid-column: 2; min-width: 0; } +.listing-side { min-width: 0; } +.listing-grid > .active-filters { padding: 12px var(--gutter) 0; margin: 0; } + +/* + ── the filter column, narrow ────────────────────────────────────────────── + A disclosure above the listing rather than eleven hundred pixels of open panel to scroll past + before the first game. Closed by default and open whenever the reader has actually asked + something, because a filter in force that nothing shows is the defect this control replaces. +*/ +@media (max-width: 900px) { + .listing-grid { grid-template-columns: minmax(0, 1fr); } + .listing-grid > * { grid-column: 1; } + .listing-grid > .facet-form { + grid-column: 1; grid-row: auto; + border-right: 0; + border-bottom: 1px solid var(--line-soft); + } + .listing-grid > .active-filters { padding: 12px var(--gutter) 0; } + + /* Two columns of groups where there is room for them, so the opened panel is a band rather + than a column a reader has to scroll the length of. */ + .facet-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 24px; } +} + +/* In a 268px column the facets stack, one group per row. Twenty-four pixels between groups, which + is the panel's own rhythm and twice the gap between a group's header and its rows. */ +.facet-grid { + display: flex; + flex-direction: column; + gap: 24px; + margin: 0; +} + +/* A facet is a heading, a note and a list of rows. */ .facet { min-width: 0; - display: grid; - grid-template-rows: 1fr auto; - align-content: end; + display: flex; + flex-direction: column; + gap: 9px; + border: 0; + padding: 0; + margin: 0; } +/* + The header block sits at the rows' own left padding, so the group name, the note and every row + label share one left edge — which is the whole reason the rows are padded rather than the panel. +*/ .facet > label, .facet > legend { display: flex; - gap: 6px; - align-items: baseline; - flex-wrap: wrap; - margin-bottom: 3px; - padding: 0; + flex-direction: column; + gap: 3px; + margin: 0; + padding: 0 8px; + width: 100%; + box-sizing: border-box; +} + +.facet .facet-head { + display: flex; align-items: center; justify-content: space-between; gap: 8px; } .facet .facet-name { font: 10px/1.4 var(--kick); - letter-spacing: 0.14em; + letter-spacing: 0.16em; text-transform: uppercase; color: var(--dim); } +/* + The one sentence a group needs at the moment somebody uses it, under its own heading rather than + once at the foot of the panel — the three groups say three different things and the line that + used to serve all of them contradicted the first. +*/ +.facet-note { + margin: 0; + font-size: 12px; + line-height: 1.5; + color: var(--faint); +} + select { background: var(--recessed); border: 1px solid var(--line); @@ -1074,9 +2120,139 @@ select { .facet select { width: 100%; max-width: 100%; } -/* A facet that is doing something says so at rest, and not only in colour — the chip row above the - results names it in words, and the select's own text is the value it is set to. */ -.facet select.chosen { border-color: color-mix(in srgb, var(--accent) 45%, var(--line)); } +/* ── the three-state facet row ───────────────────────────────────────────── + Off, only, anything but — one row per value, and the state legible without opening anything. The + two radios are hidden and the glyph is drawn, but they are real radios: the browser's own group + behaviour is what makes this work with no script, and every state is a word in the accessible + name as well as a shape and a tint. */ + +.facet .rows { display: grid; gap: 2px; margin: 0; } + +/* + The row's geometry, and it is a grid rather than a flex run for one reason: every column has to + land in the same place on every row of every group. As a flex run the widest label in a group + pushed that group's count and exclude columns ten pixels right of every other group's — and past + the panel's own edge, which is what the audit measured as a 253px row inside a 243px box. + + 15px the state glyph + 1fr the value, which is the one cell allowed to give up width + auto the count, right-aligned against the 1fr's slack so every count shares an edge + 18px the exclusion, where a "remove" belongs +*/ +.facet-row { + display: grid; + grid-template-columns: minmax(0, 1fr) 18px; + align-items: center; + gap: 9px; + min-height: 30px; + padding: 0 8px; + border-radius: 9px; + font-size: 13.5px; + color: var(--dim); + border: 1px solid transparent; +} + +/* One ordered scale: no exclusion, so no column for one. */ +.facet-row.single { grid-template-columns: minmax(0, 1fr); } + +.facet-row .pick { + display: grid; + grid-template-columns: 15px minmax(0, 1fr) auto; + align-items: center; + gap: 9px; + min-width: 0; + cursor: pointer; +} +.facet-row .tick { text-align: center; color: var(--faint); font-family: var(--mono); font-size: 12px; } +.facet-row .row-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +/* + `--dim` rather than `--faint`, because the panel is not the page. + + `--faint` was raised to 4.8:1 once already, measured against the page background — and the facet + panel is a more recessed surface than that, where the same token falls to 4.09:1 in dark and + 4.37:1 in light. Both are under the 4.5:1 this site holds itself to, and both are carried by a + 12px number, which is the smallest text in the panel and the one a reader most needs to read. + Raising the token instead would have moved every age and provenance line on the site to fix a + contrast the page itself does not have. +*/ +.facet-row .count { + font-family: var(--mono); font-size: 12px; font-variant-numeric: tabular-nums; color: var(--dim); +} + +/* The exclusion affordance, at the far edge where a "remove" belongs. Quiet until it is the state + the row is in. */ +.facet-row .drop { + display: block; text-align: center; cursor: pointer; + color: var(--faint); font-family: var(--mono); font-size: 12.5px; +} +.facet-row .drop:hover { color: var(--text); } +/* Links, so a click applies the filter rather than arming a control that waits for a button. */ +.facet-row a { text-decoration: none; color: inherit; border-radius: var(--radius); } + +.facet-row:hover { background: var(--raised); } + +/* + A rung of a scale that this filter leaves empty. It stays — a bounded facet is four named + thresholds a reader picks between, and deleting the empty ones meant narrowing the codebase + silently removed two of activity's four rows and shifted the panel under the reader's pointer. It + is dimmed rather than disabled, because 0 is an answer: "no Evennia game was active this week" is + a fact about the catalogue, and clicking it lands on the listing's own empty state. +*/ +.facet-row.empty { opacity: 0.55; } +.facet-row.empty:hover { opacity: 1; } + +/* Included: the accent as a tint and a ring, and the count goes accent with the label — the number + is the part of the row that says what the filter will return, so it is the part that has to look + chosen. */ +.facet-row.on { + color: var(--accent-ink); + background: color-mix(in srgb, var(--accent) 12%, transparent); + border-color: color-mix(in srgb, var(--accent) 40%, transparent); +} +.facet-row.on .tick, .facet-row.on .count { color: var(--accent-ink); } +.facet-row.on:hover { background: color-mix(in srgb, var(--accent) 16%, transparent); } + +/* + Excluded: the danger tint, ring and strikethrough the handoff draws. + + Red is not a provenance colour here and never becomes one — it marks a state the reader put the + control into, not a state of the data. That is why nothing else on the site is red: the accent + means measured, amber means declared, and this means refused. +*/ +.facet-row.excluded { + color: var(--danger); + background: color-mix(in srgb, var(--danger) 10%, transparent); + border-color: color-mix(in srgb, var(--danger) 35%, transparent); +} +.facet-row.excluded .tick, .facet-row.excluded .count { color: var(--danger); } +.facet-row.excluded .row-name { + text-decoration: line-through; + text-decoration-color: color-mix(in srgb, var(--danger) 50%, transparent); +} +.facet-row.excluded .drop { color: var(--danger); } +.facet-row.excluded:hover { background: color-mix(in srgb, var(--danger) 14%, transparent); } + +/* The radios are hidden, so the focus ring belongs to the row that holds them. */ +.facet-row a:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } + +/* The tail. A summary is a control, so it gets the row's own height and hit area. */ +.facet .facet-tail { margin-top: 2px; } +.facet .facet-tail summary { + cursor: pointer; padding: 4px 8px; font-size: 12px; color: var(--faint); border-radius: 9px; +} +.facet .facet-tail summary:hover { color: var(--text); background: var(--raised); } + +/* Said once, under the groups it applies to. */ +.facet-hint { margin: 12px 0 0; font-size: 11.5px; color: var(--dim); } + +/* The facets a reader goes looking for, rather than the three they arrive with. */ +.facet-more-groups { margin: 0; border-top: 1px solid var(--line-soft); padding-top: 14px; } +.facet-more-groups > summary { + cursor: pointer; font: 10px/1 var(--kick); letter-spacing: 0.16em; text-transform: uppercase; + color: var(--faint); padding: 4px 8px; border-radius: var(--radius); +} +.facet-more-groups > summary:hover { color: var(--text); background: var(--raised); } +.facet-more-groups .facet-grid { margin-top: 14px; } /* ── the presence facet ──────────────────────────────────────────────────── */ @@ -1088,11 +2264,15 @@ select { fieldset.facet.presence { border: 0; padding: 0; - margin: var(--cpad) 0 0; + margin: 0; min-width: 0; } -fieldset.facet.presence .checks { display: flex; flex-wrap: wrap; gap: 6px; } +fieldset.facet.presence .checks { + display: flex; flex-wrap: wrap; gap: 6px; min-width: 0; padding: 0 8px; +} +fieldset.facet.presence .check { max-width: 100%; } +fieldset.facet.presence .check .check-name { overflow-wrap: anywhere; min-width: 0; } fieldset.facet.presence .check { display: inline-flex; @@ -1110,9 +2290,6 @@ fieldset.facet.presence .check:hover { border-color: var(--dim); } fieldset.facet.presence .check:has(:checked) { border-color: color-mix(in srgb, var(--accent) 45%, var(--line)); } fieldset.facet.presence .check .count { color: var(--faint); font-variant-numeric: tabular-nums; } -/* Where the unticked-is-not-a-no sentence lives: inside the fieldset it is about, at the moment of - ticking, rather than in a key at the bottom of the panel. */ -fieldset.facet.presence .hint { margin: 6px 0 0; font-size: 11.5px; color: var(--faint); max-width: 62ch; } /* Measured, declared, and the one thing we concluded ourselves — three registers, never the only carrier of the difference, because the chip spells each out in words as well and colour is not a @@ -1137,25 +2314,28 @@ fieldset.facet.presence .hint { margin: 6px 0 0; font-size: 11.5px; color: var(- a title attribute: this is the difference the whole site exists to publish, and it stays in the document whether or not a pointer ever hovers anything. */ +/* + One line and one disclosure. The three-row provenance legend that used to stand here is behind the + disclosure now: every group wears its own evidence badge, which is where the word is actually + needed — before a filter is applied rather than after the panel has been read to the bottom. It + also wrapped its descriptions right-aligned, which is what a flex row does to a text node it did + not expect to break. +*/ .facet-key { display: flex; - flex-wrap: wrap; - gap: 4px 18px; - align-items: baseline; - margin-top: var(--cpad); - padding-top: 10px; - border-top: 1px solid var(--line); - font-size: 11.5px; + flex-direction: column; + gap: 8px; + margin: 0 8px; + padding-top: 14px; + border-top: 1px solid var(--line-soft); + font-size: 12px; color: var(--faint); + text-align: left; } -.facet-key .key-item { display: inline-flex; gap: 5px; align-items: baseline; } -.facet-key .facet-more { margin-left: auto; } -.facet-key summary { cursor: pointer; color: var(--dim); } - -.facet-key .facet-more[open] { flex: 1 0 100%; margin-left: 0; margin-top: 6px; } -.facet-key .facet-more ul { margin: 6px 0 0; padding-left: 1.2em; max-width: 72ch; } -.facet-key .facet-more li { margin-bottom: 5px; color: var(--dim); } +.facet-key summary { cursor: pointer; color: var(--dim); font-size: 11.5px; } +.facet-key .facet-more ul { margin: 8px 0 0; padding-left: 1.2em; } +.facet-key .facet-more li { margin-bottom: 6px; color: var(--dim); line-height: 1.5; } /* ── what the query is currently asking for ──────────────────────────────── */ @@ -1202,18 +2382,16 @@ fieldset.facet.presence .hint { margin: 6px 0 0; font-size: 11.5px; color: var(- /* ── the panel and the rows, narrow ──────────────────────────────────────── */ +/* + Narrow rows keep all three columns and all three stay aligned. They used to collapse into a flex + run under the name, which put the age beside the count on some rows and under it on others — the + same list drawn two ways down one screen. The name gives up width first, which is the one cell + that can spare it, and the two right-hand columns hold their edge. +*/ @media (max-width: 620px) { - /* The bar stacks: a search box sharing a row with a sort and a toggle has room for none of them. */ - .filter-bar input[type="search"] { flex: 1 0 100%; } - .filter-bar button { margin-left: auto; } - - /* The measured column goes under the name rather than fighting it for a width neither has — - under the name and not under the plate, so the row keeps the left edge the plate gives it and - the figure lines up with the text it belongs to. */ - .row-main { grid-template-columns: auto minmax(0, 1fr); } - .row-main .plate { grid-row: span 2; } - .row-figure { text-align: left; justify-items: start; margin-top: 4px; } - .row-figure { display: flex; gap: 12px; flex-wrap: wrap; align-items: baseline; } + .row-main { gap: 3px 12px; } + .row-count { font-size: 14px; } + .row-count.unknown { font-size: 12.5px; } } /* ── reference and orientation pages ─────────────────────────────────────── The hand-written section (spec §9). Two things it has to communicate that no @@ -1375,3 +2553,283 @@ ol.events .faint { margin-right: 8px; } details.resign form { margin-top: 6px; } details.resign input[type="text"] { display: block; margin: 6px 0; max-width: 16ch; } + +/* ── find a game ─────────────────────────────────────────────────────────── + The questions on the left, the count on the right, and the count still on + screen when the last question is answered — which is the whole premise of + this page. Until this block existed the page had no rule of its own at all: + it was the one surface left on user-agent form styling, drawn full-bleed + with 2px groove borders and 5px of padding. */ + +.find-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 310px); + align-items: stretch; + gap: 0; + margin: 0; + + /* + `clip`, never `hidden`. An ancestor with overflow: hidden is a scroll + container, and a sticky element can only travel inside its own — so the + panel would pin for a few hundred pixels and then scroll away for the + rest, leaving the count invisible while questions four to six were + answered. That is the exact failure this page was redrawn to fix, and + `clip` clips without establishing the container. + */ + overflow: clip; +} + +.find-questions { min-width: 0; grid-column: 1; } + +/* ── one question ───────────────────────────────────────────────────────── + 24 above the heading, 14 under it, 24 to the next rule. Symmetric outside, + tighter inside, so the heading binds to the options it labels and the + questions separate from each other. The numbers are on the elements + themselves rather than on a container: a renders on the border + edge and outside the padding box, which is why the drawing's own first + attempt produced 13px above the heading and 2px below it. */ + +.find-q { + padding: 24px var(--gutter); + border-top: 1px solid var(--line-soft); +} + +.find-q-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + margin: 0 0 14px; +} + +/* The question is a heading, so a screen reader can jump between the six and + the page has an outline. h2's own rhythm belongs to prose sections and + would fight the 24/14 above. */ +.find-q-head h2 { font-size: 16px; font-weight: 600; margin: 0; color: var(--text); } + +.find-opts { display: flex; flex-wrap: wrap; gap: 10px 8px; } + +/* + An option chip. 40px is the floor, not the height: a long gloss wraps and + the chip grows rather than clipping it. The row gap is deliberately larger + than the column gap — equal gaps make wrapped rows read as a grid instead of + as rows. +*/ +.find-opt { + display: inline-flex; + align-items: center; + gap: 9px; + min-height: 40px; + padding: 8px 14px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--raised); + color: var(--dim); + font-size: 13.5px; + text-decoration: none; + transition: background var(--dur) var(--ease), border-color var(--dur) var(--ease); +} + +.find-opt .tick { font-family: var(--mono); font-size: 12px; color: var(--faint); } +.find-opt .count { font-size: 12px; color: var(--faint); } +.find-opt:hover { background: color-mix(in srgb, var(--accent) 8%, var(--raised)); } +.find-opt:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } + +/* Chosen: the tint, the ring and the count in the accent, because the number + is the part of the chip that says what answering will return. */ +.find-opt.on { + color: var(--accent); + background: color-mix(in srgb, var(--accent) 12%, transparent); + border-color: color-mix(in srgb, var(--accent) 45%, transparent); +} +.find-opt.on .tick, .find-opt.on .count { color: var(--accent); } + +/* The long tail. A summary is a control, so it gets a control's hit area. */ +.find-tail { margin-top: 10px; } +.find-tail > summary { + cursor: pointer; + display: inline-block; + padding: 6px 10px; + font-size: 12px; + color: var(--faint); + border-radius: var(--radius); +} +.find-tail > summary:hover { color: var(--text); background: var(--raised); } +.find-tail .find-opts { margin-top: 10px; } + +/* ── the name field ─────────────────────────────────────────────────────── + Label above the field, not beside it: beside it the field lost half the + column and a typed name could not be read back. */ + +.find-name { padding: 0 var(--gutter); margin: 0; } +.find-name label { display: block; margin-bottom: 8px; } +.find-name .search-field { max-width: 460px; } + +/* ── the count ──────────────────────────────────────────────────────────── + The outer cell carries the fill and the edge at full row height; the inner + wrapper is what sticks. Splitting the two is what lets the column read as a + column even where the panel is short. */ + +.find-panel { + grid-column: 2; + background: var(--recessed); + border-left: 1px solid var(--line-soft); + min-width: 0; +} + +.find-panel-inner { + position: sticky; + top: 20px; + display: flex; + flex-direction: column; + gap: 14px; + /* 24 at the top so the first line lands on the first question's heading. */ + padding: 24px 22px; +} + +.find-count { display: flex; align-items: baseline; gap: 8px; margin: 0; } + +.find-n { + font-size: 40px; + font-weight: 600; + line-height: 1; + color: var(--accent); + font-variant-numeric: tabular-nums; +} + +.find-noun { font-size: 13.5px; color: var(--dim); } +.find-basis { margin: 0; font-size: 12px; color: var(--faint); } + +.find-chips { display: flex; flex-wrap: wrap; gap: 6px; } + +.find-chip { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 5px 11px; + border: 1px solid var(--line); + border-radius: var(--radius-pill); + background: var(--raised); + color: var(--dim); + font-size: 12px; + text-decoration: none; + max-width: 100%; +} +.find-chip .chip-value { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.find-chip .chip-x { color: var(--faint); } +.find-chip:hover { color: var(--text); border-color: var(--dim); } + +/* + The way out of a combination that returned almost nothing. Quiet on purpose: + no amber, no warning box, no sentence about the result being narrow. The + reader can see the count; this is the one thing they cannot work out. +*/ +.find-loosen { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 8px 12px; + border: 1px dashed var(--line); + border-radius: var(--radius); + color: var(--dim); + font-size: 12.5px; + text-decoration: none; +} +.find-loosen:hover { color: var(--text); border-color: var(--dim); background: var(--raised); } + +/* The one accented control on the page, and it is a link: it navigates to the + listing with this question asked, and nothing here is a form to fire. */ +.find-go { + display: block; + padding: 11px 16px; + border-radius: var(--radius); + border: 1px solid color-mix(in srgb, var(--accent) 55%, transparent); + background: color-mix(in srgb, var(--accent) 14%, transparent); + box-shadow: var(--glow); + color: var(--accent); + font-size: 14px; + font-weight: 600; + text-align: center; + text-decoration: none; +} +.find-go:hover { background: color-mix(in srgb, var(--accent) 22%, transparent); } +.find-go:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } + +.find-clear { margin: 0; font-size: 12px; } +.find-clear a { color: var(--faint); } +.find-clear a:hover { color: var(--text); } + +/* + ── the panel, narrow ───────────────────────────────────────────────────── + The drawing has no responsive treatment and cannot be followed here: at + 430px its own grid crushes the question column to 139px while the panel + keeps all 310. So the panel stacks — above the questions, because the count + is the thing the page exists to show and putting it under six questions + would mean scrolling past every one of them to find out what they returned. + It stays sticky at the top of the viewport, which is the same promise the + side column makes at a width that has room for one. +*/ +@media (max-width: 860px) { + .find-grid { grid-template-columns: minmax(0, 1fr); } + .find-questions, .find-panel { grid-column: 1; } + + /* + Below the questions in the visual order because it is below them in the + DOM, and a column that disagrees with the tab order is a worse answer + than a long page. It sticks to the *bottom* instead of the top, which + keeps the same promise from the other end: the count and the submit are + on screen while the last question is being answered, without a reader + having to find them again afterwards. + */ + .find-panel { + border-left: 0; + border-top: 1px solid var(--line-soft); + position: sticky; + bottom: 0; + } + + /* + Laid across rather than down, because a bar pinned to the bottom of a + phone spends every pixel it takes: short controls share a line, so the + whole panel costs four rows with two answers given rather than seven. + + A wrapping row *in document order*, and that part is not cosmetic. This + was a two-column bar with the call to action pinned right and spanning + three rows, which put it first to the eye and fourth to the keyboard: + tabbing ran chips, loosen, then 114px back up the screen to the button. + Flex-wrap packs the same controls in the order they are written, so the + focus ring only ever travels forwards. + */ + .find-panel-inner { + position: static; + display: flex; + /* The base rule stacks; this is the one place the direction changes, and + leaving it out gives a centred column that reads as a broken card. */ + flex-direction: row; + flex-wrap: wrap; + align-items: center; + gap: 8px 12px; + padding: 12px var(--gutter); + } + + /* + The kicker labels a column, and this is a bar. The sub-line beside the + count says the same thing in more words, and the sentence a screen reader + is given — "2 games match every answer" — is untouched, so nothing is + lost that was only being carried here. + */ + .find-panel-inner > .kicker { display: none; } + + .find-chips { flex: 0 1 auto; } + .find-go { padding: 9px 14px; } + .find-n { font-size: 28px; } + + /* + The bar is pinned over the foot of the viewport, and a browser scrolling a + newly focused option into view does not know it is there — so tabbing down + the last question lands the focus ring behind the panel. `scroll-padding` + is the scriptless way to tell it, and `:has` is what keeps the rule on the + one page that has a bar rather than on every scroll on the site. + */ + html:has(.find-page) { scroll-padding-bottom: 200px; } +} diff --git a/tests/MUI.Catalog.Tests/AdultListingTests.cs b/tests/MUI.Catalog.Tests/AdultListingTests.cs index d8b76a0..0a0ed14 100644 --- a/tests/MUI.Catalog.Tests/AdultListingTests.cs +++ b/tests/MUI.Catalog.Tests/AdultListingTests.cs @@ -33,7 +33,13 @@ private static GameFacetRow Row(string slug, string? genre = null, bool adult = Codebase: "Evennia", Family: null, Genre: genre, - IsAdult: adult); + IsAdult: adult, + + // Every game here answered and was counted. The adult switch is what this suite is + // about, and a row that was also uncounted would put a second reason in front of the + // one being asserted. + Uncounted: false, + Unreachable: false); private static readonly GameFacetRow[] Catalogue = [ diff --git a/tests/MUI.Catalog.Tests/FacetPolarityTests.cs b/tests/MUI.Catalog.Tests/FacetPolarityTests.cs index d1988ce..fe6c4a3 100644 --- a/tests/MUI.Catalog.Tests/FacetPolarityTests.cs +++ b/tests/MUI.Catalog.Tests/FacetPolarityTests.cs @@ -173,7 +173,9 @@ private static GameListing Search(GameFilter filter) => Codebase: game.Codebase, Family: null, Genre: null, - IsAdult: false); + IsAdult: false, + Uncounted: false, + Unreachable: false); private static GameSummary Game(string slug, string? codebase) => new( Guid.NewGuid(), slug, slug, null, LifecycleState.Active, IsClaimed: false, diff --git a/tests/MUI.Catalog.Tests/FacetedSearchTests.cs b/tests/MUI.Catalog.Tests/FacetedSearchTests.cs index ea5e2ed..5a26de2 100644 --- a/tests/MUI.Catalog.Tests/FacetedSearchTests.cs +++ b/tests/MUI.Catalog.Tests/FacetedSearchTests.cs @@ -24,7 +24,9 @@ private static GameFacetRow Row( string? charset = null, bool tls = false, DateTimeOffset? lastReachableAt = null, - string[]? protocols = null) + string[]? protocols = null, + bool uncounted = false, + bool unreachable = false) { var summary = new GameSummary( Guid.NewGuid(), slug, slug, Tagline: null, LifecycleState.Active, IsClaimed: false, @@ -43,7 +45,12 @@ private static GameFacetRow Row( // Adult content has its own suite (AdultListingTests). Every row here declares none, so // these counts are taken over the whole set and are not quietly shaped by that default. - IsAdult: false); + IsAdult: false, + + // Both default to false, so a row is a game we reached and counted unless a test says + // otherwise — the ordinary case, and the one every count in this file is taken over. + uncounted, + unreachable); } private static FacetGroup Group(GameListing listing, string key) => @@ -213,7 +220,12 @@ public async Task NeverReachedIsItsOwnBandAndNotTheOldestOne() null), ActivityBand.Dark, FacetedSearch.LastSeenOf(null, Now), - false, null, null, null, null, null, false), + false, null, null, null, null, null, false, + + // Never once reached, so unreachable — and NOT uncounted, because we hold no + // presence row for it at all. Naming a cause for that is what rule 2 forbids. + Uncounted: false, + Unreachable: true), ]; await Assert.That(FacetedSearch.LastSeenOf(null, Now)).IsEqualTo(LastSeenBand.Never); @@ -371,10 +383,145 @@ public async Task OneGamesCapitalisationDoesNotNameAValue() await Assert.That(codebase.Count).IsEqualTo(3); } + // ── what we could measure ──────────────────────────────────────────────────────────────── + + /// + /// The set these four rows describe, once, so every assertion below is about the same catalogue. + /// + /// + /// The first two are the whole point. zero is a game we got into and counted + /// nobody in, and unreadable is a game we got into and could not count at all. Both sit in + /// — the band cannot tell them apart, which is why these facets + /// exist — and only the second is uncounted. gone is the third state twice over: never + /// reached, and with nothing measured to be uncounted about. + /// + private static GameFacetRow[] Measured() => + [ + Row("zero", band: ActivityBand.Quiet, genre: "Fantasy"), + Row("unreadable", band: ActivityBand.Quiet, genre: "Fantasy", uncounted: true), + Row("busy", genre: "Historical"), + Row("gone", band: ActivityBand.Dark, genre: "Fantasy", + lastReachableAt: Now.AddDays(-90), unreachable: true), + ]; + + [Test] + public async Task AGameMeasuredAtZeroIsNotUncounted() + { + // The failure this facet was written to make impossible. A measured nought is a count — we + // got in and nobody was there (rule 2) — and it shares an activity band with a game whose + // every WHO was past our parser. A filter that returned both under a word meaning the second + // would publish our own parser's limits as somebody's empty game. + var rows = Measured(); + + var uncounted = FacetedSearch.Search(rows, new GameFilter { Uncounted = FacetChoice.Of(FacetTokens.Yes) }); + + await Assert.That(uncounted.Games.Select(g => g.Slug)).IsEquivalentTo(new[] { "unreadable" }); + + // And both are still in the band, which is the band being right rather than the facet being + // redundant with it. + var quiet = FacetedSearch.Search(rows, new GameFilter { Band = ActivityBand.Quiet }); + + await Assert.That(quiet.Games.Select(g => g.Slug)).IsEquivalentTo(new[] { "zero", "unreadable" }); + } + + [Test] + public async Task AGameWeHaveNotMeasuredIsNeitherUncountedNorCounted() + { + // §5.4's third state, which names no cause. `gone` has no presence rows at all, so it is not + // uncounted — the facet says "we tried and could not read", and claiming that of a game we + // never got into would be the same fabrication in the other direction. + var rows = Measured(); + + var uncounted = FacetedSearch.Search(rows, new GameFilter { Uncounted = FacetChoice.Of(FacetTokens.Yes) }); + var unreachable = FacetedSearch.Search(rows, new GameFilter { Unreachable = FacetChoice.Of(FacetTokens.Yes) }); + + await Assert.That(uncounted.Games.Any(g => g.Slug == "gone")).IsFalse(); + await Assert.That(unreachable.Games.Select(g => g.Slug)).IsEquivalentTo(new[] { "gone" }); + } + + [Test] + public async Task TheTwoSwitchesComposeWithEachOtherAndWithAnUnrelatedFacet() + { + // The reason they are two FacetChoices rather than two values of `band`: a reader narrowing + // by genre has to be able to drop both kinds of unmeasured game without spending the one + // selection `band` has. Three questions at once, and all three applied. + var rows = Measured(); + + var listing = FacetedSearch.Search(rows, new GameFilter + { + Genre = FacetChoice.Of("Fantasy"), + Uncounted = FacetChoice.Not(FacetTokens.Yes), + Unreachable = FacetChoice.Not(FacetTokens.Yes), + }); + + // Fantasy, minus the one we could not read and the one we could not reach — leaving the + // measured nought, which is a game we counted and must survive both exclusions. + await Assert.That(listing.Games.Select(g => g.Slug)).IsEquivalentTo(new[] { "zero" }); + } + + [Test] + public async Task ExcludingBothStillLeavesAListingThatExplainsItself() + { + // Hiding is a decision about the listing, so the controls that made it have to stay + // reachable — a selection whose only affordance has vanished is the defect the whole panel + // was rebuilt to remove. Both rows survive at the count their own selection returns. + var rows = Measured(); + + var listing = FacetedSearch.Search(rows, new GameFilter + { + Uncounted = FacetChoice.Not(FacetTokens.Yes), + Unreachable = FacetChoice.Not(FacetTokens.Yes), + }); + + await Assert.That(listing.Games.Select(g => g.Slug)).IsEquivalentTo(new[] { "zero", "busy" }); + + foreach (var key in new[] { FacetKeys.Uncounted, FacetKeys.Unreachable }) + { + var value = Value(listing, key, FacetTokens.Yes); + + await Assert.That(value.State).IsEqualTo(FacetState.Excluded); + await Assert.That(value.Count).IsEqualTo(1); + } + } + + [Test] + public async Task TheCountBesideEachSwitchIsWhatChoosingItReturns() + { + // The panel's own promise, made over the set that has both hard states in it. The generic + // walk above covers this too; this one names the facets, so a change that made the counts + // fall out of a wider denominator fails here with the right words on it. + var rows = Measured(); + var listing = FacetedSearch.Search(rows, new GameFilter()); + + foreach (var key in new[] { FacetKeys.Uncounted, FacetKeys.Unreachable }) + { + var value = Value(listing, key, FacetTokens.Yes); + + await Assert.That(value.Count).IsEqualTo(1); + await Assert.That(FacetedSearch.Search(rows, Choose(key, value.Token)).Games.Count) + .IsEqualTo(value.Count); + } + } + + [Test] + public async Task ASwitchNothingMatchesIsNotDrawnAtAll() + { + // A bounded facet keeps every rung of a scale, because a scale with a rung missing is not + // the same scale. This is not a scale — it is one fact, held or not — so a catalogue we + // could count and reach in full offers no control, which is the honest rendering of a + // measurement with nothing in it. + var listing = FacetedSearch.Search([Row("busy"), Row("also")], new GameFilter()); + + await Assert.That(listing.Facets.Any(f => f.Key == FacetKeys.Uncounted)).IsFalse(); + await Assert.That(listing.Facets.Any(f => f.Key == FacetKeys.Unreachable)).IsFalse(); + } + private static GameFilter Choose(string key, string token) => key switch { FacetKeys.Band => new GameFilter { Band = Band(token) }, FacetKeys.LastSeen => new GameFilter { LastSeen = Seen(token) }, + FacetKeys.Uncounted => new GameFilter { Uncounted = FacetChoice.Parse(token) }, + FacetKeys.Unreachable => new GameFilter { Unreachable = FacetChoice.Parse(token) }, FacetKeys.Protocol => new GameFilter { MeasuredProtocols = [token] }, FacetKeys.Tls => new GameFilter { Tls = true }, FacetKeys.Charset => new GameFilter { Charset = FacetChoice.Parse(token) }, diff --git a/tests/MUI.Catalog.Tests/Persistence/FacetQueriesPostgresTests.cs b/tests/MUI.Catalog.Tests/Persistence/FacetQueriesPostgresTests.cs index 3ebf947..53ea769 100644 --- a/tests/MUI.Catalog.Tests/Persistence/FacetQueriesPostgresTests.cs +++ b/tests/MUI.Catalog.Tests/Persistence/FacetQueriesPostgresTests.cs @@ -280,10 +280,116 @@ await Assert.That(games.Count) } } + [Test] + public async Task AMeasuredZeroIsNotUncountedAndTheBandCannotTellThemApart() + { + // The one this facet exists for, against the reader that actually computes it. + // + // Both games are reachable, both have no count above nought this week, and both therefore + // land in `quiet` — the band is one threshold and cannot say why. `empty` was probed and + // answered nought, which is a measurement of theirs; `unreadable` was probed and produced no + // number, which is a limit of ours. Returning the first under a word meaning the second is + // rules 2, 4 and 5 in one control, and the digest threw the distinction away until now. + await using var db = await PostgresFixture.MigratedAsync(); + var empty = await Seed.GameAsync(db, "empty", "Measured empty", lastReachableAt: Now); + var unreadable = await Seed.GameAsync(db, "unreadable", "Unreadable", lastReachableAt: Now); + var writer = new PresenceWriter(new NpgsqlPresenceStore(db.DataSource)); + + // Three probes each, over the same hours, differing only in what came back. + foreach (var hours in new[] { 1, 20, 60 }) + { + await writer.WriteAsync( + empty, PresenceReading.Counted(0, FieldSource.Who), Now.AddHours(-hours)); + await writer.WriteAsync( + unreadable, + PresenceReading.Unmeasurable(UnmeasurableReason.WhoUnparseable), + Now.AddHours(-hours)); + } + + var queries = QueriesOn(db); + + var quiet = await queries.ListAsync(new GameFilter { Band = ActivityBand.Quiet }); + await Assert.That(quiet.Select(g => g.Slug)).IsEquivalentTo(new[] { "empty", "unreadable" }); + + var uncounted = await queries.ListAsync( + new GameFilter { Uncounted = FacetChoice.Of(FacetTokens.Yes) }); + await Assert.That(uncounted.Select(g => g.Slug)).IsEquivalentTo(new[] { "unreadable" }); + + // And the count each game's row carries agrees: nought is a number, and the other is null. + var listed = await queries.ListAsync(new GameFilter()); + await Assert.That(listed.Single(g => g.Id == empty).PlayersNow).IsEqualTo(0); + await Assert.That(listed.Single(g => g.Id == unreadable).PlayersNow).IsNull(); + } + + [Test] + public async Task AGameWithNoPresenceRowsAtAllIsNotUncounted() + { + // §5.4's third state. A probe that failed writes no presence row (`PresenceWriter`), so a + // game with nothing stored is one we did not measure — and "we tried and could not read it" + // is a cause, which that state may not be given. It is unreachable instead, from the + // availability series, which can tell the two apart. + await using var db = await PostgresFixture.MigratedAsync(); + await Seed.GameAsync(db, "never", "Never answered", lastReachableAt: null); + var probed = await Seed.GameAsync(db, "probed", "Probed", lastReachableAt: Now); + var writer = new PresenceWriter(new NpgsqlPresenceStore(db.DataSource)); + + await writer.WriteAsync( + probed, PresenceReading.Unmeasurable(UnmeasurableReason.WhoLoginPrompt), Now.AddHours(-2)); + + var queries = QueriesOn(db); + + await Assert.That( + (await queries.ListAsync(new GameFilter { Uncounted = FacetChoice.Of(FacetTokens.Yes) })) + .Select(g => g.Slug)) + .IsEquivalentTo(new[] { "probed" }); + + await Assert.That( + (await queries.ListAsync(new GameFilter { Unreachable = FacetChoice.Of(FacetTokens.Yes) })) + .Select(g => g.Slug)) + .IsEquivalentTo(new[] { "never" }); + } + + [Test] + public async Task TheTwoSwitchesNarrowTogetherAndTheCountsSayWhatTheyReturn() + { + // Orthogonal, and orthogonal to the rest of the panel: excluding both leaves the games we + // measured, with the two controls still drawn so the reader can undo what they did. + await using var db = await PostgresFixture.MigratedAsync(); + var counted = await Seed.GameAsync(db, "counted", "Counted", lastReachableAt: Now); + var unreadable = await Seed.GameAsync(db, "unreadable", "Unreadable", lastReachableAt: Now); + await Seed.GameAsync(db, "gone", "Gone", lastReachableAt: Now.AddDays(-90)); + var writer = new PresenceWriter(new NpgsqlPresenceStore(db.DataSource)); + + await writer.WriteAsync(counted, PresenceReading.Counted(4, FieldSource.Who), Now.AddHours(-1)); + await writer.WriteAsync( + unreadable, PresenceReading.Unmeasurable(UnmeasurableReason.I3NoReply), Now.AddHours(-1)); + + var queries = QueriesOn(db); + + var kept = await queries.SearchAsync(new GameFilter + { + Uncounted = FacetChoice.Not(FacetTokens.Yes), + Unreachable = FacetChoice.Not(FacetTokens.Yes), + }); + + await Assert.That(kept.Games.Select(g => g.Slug)).IsEquivalentTo(new[] { "counted" }); + + foreach (var key in new[] { FacetKeys.Uncounted, FacetKeys.Unreachable }) + { + var value = Group(kept, key)!.Values.Single(v => v.Token == FacetTokens.Yes); + + await Assert.That(value.State).IsEqualTo(FacetState.Excluded); + await Assert.That((await queries.ListAsync(Choose(key, value.Token))).Count) + .IsEqualTo(value.Count); + } + } + private static GameFilter Choose(string key, string token) => key switch { FacetKeys.Band => new GameFilter { Band = Band(token) }, FacetKeys.LastSeen => new GameFilter { LastSeen = Seen(token) }, + FacetKeys.Uncounted => new GameFilter { Uncounted = FacetChoice.Parse(token) }, + FacetKeys.Unreachable => new GameFilter { Unreachable = FacetChoice.Parse(token) }, FacetKeys.Protocol => new GameFilter { MeasuredProtocols = [token] }, FacetKeys.Tls => new GameFilter { Tls = true }, FacetKeys.Charset => new GameFilter { Charset = FacetChoice.Parse(token) }, diff --git a/tests/MUI.Web.Tests/AboutPageTests.cs b/tests/MUI.Web.Tests/AboutPageTests.cs index 2f6d462..9e8b1b2 100644 --- a/tests/MUI.Web.Tests/AboutPageTests.cs +++ b/tests/MUI.Web.Tests/AboutPageTests.cs @@ -10,6 +10,7 @@ using MUI.Discovery; using MUI.Web.Api; using MUI.Web.Components; +using MUI.Web.Localization; namespace MUI.Web.Tests; @@ -34,6 +35,21 @@ public class AboutPageTests private static string Plain => PlainText.RenderAbout(Page); + /// + /// The English a message id carries, which is what this page is asserted against. + /// + /// + /// The copy lives in now, so a sentence pasted into a test would be a + /// third copy of it — and the one nothing checks. Asking the bundle asserts the fact this page + /// makes a claim about ("the archive-grace limitation is stated") rather than the spelling of + /// it, and still fails if the id stops reaching the page. Where a claim is a rule + /// rather than a sentence — the refusal to say "no automated opt-out" — the literal stays, + /// because there the wording is the rule. A rule about the site's vocabulary is the one + /// case where a literal on this page is wrong: it belongs over the whole bundle in every locale, + /// which is where puts it. + /// + private static string Says(string id) => Messages.For(Locales.SourceTag, id); + [Test] public async Task EveryDirectoryTheBackfillReadIsCreditedByNameAndByAddress() { @@ -59,13 +75,17 @@ public async Task ASourceWeChoseNotToFetchIsCreditedAndSaidToBeUnread() var mudverse = Page.Sources.Single(s => s.Name == "MudVerse"); await Assert.That(mudverse.State).IsEqualTo(ImportSourceState.Withheld); - await Assert.That(Plain).Contains("not read — awaiting permission"); + await Assert.That(Plain).Contains(Says("about.source.withheld")); + + // And the badge for one we did read is a different string, not a negation of this one: a + // reader meets the difference between "we chose not to" and "we could not" only here. + await Assert.That(Says("about.source.read")).IsNotEqualTo(Says("about.source.withheld")); } [Test] public async Task TheAttributionSaysAddressesOnlyAndSaysWhy() { - await Assert.That(Render.Words(Plain)).Contains("We take addresses. Nothing else."); + await Assert.That(Render.Words(Plain)).Contains(Says("about.sources.addresses.lead")); await Assert.That(Render.Words(Plain)).Contains("No player counts"); await Assert.That(Render.Words(Plain)).Contains("no reachability history"); } @@ -131,12 +151,13 @@ 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(Render.Words(Plain)).Contains("is a placeholder and answers nobody"); + await Assert.That(Render.Words(Plain)).Contains(Says("about.identity.placeholder.plain")); var configured = PlainText.RenderAbout(AboutPage.Build( new ProbeOptions { InfoUrl = "https://example.test/crawler" }, new DatasetLicenceOptions())); - await Assert.That(Render.Words(configured)).DoesNotContain("is a placeholder and answers nobody"); + await Assert.That(Render.Words(configured)) + .DoesNotContain(Says("about.identity.placeholder.plain")); } [Test] @@ -182,7 +203,7 @@ public async Task ThePoliteAndSecurityFactsAboutAProbeSurvive() { var text = Render.Words(Plain); - await Assert.That(text).Contains("CRAWL DELAY wins."); + await Assert.That(text).Contains(Says("about.crawler.delay.lead")); await Assert.That(text).Contains("resolved before anything is dialled"); await Assert.That(text).Contains("globally routable"); } @@ -203,27 +224,87 @@ 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(Says("about.measures.declared.lead")); 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"); } + /// + /// The page explains what reachability is, in whatever language it is being read in. + /// + /// + /// + /// This used to count the English word "uptime" and require exactly two of it. That was a + /// reasonable guard while the page was a C# string and an unreasonable one the moment it became + /// a translation: a Japanese or Chinese rendering of the same two refusals contains the token + /// zero times and would fail, and a German one contains it twice by a coincidence of loanwords + /// rather than because the rule held. Worse, the assertion made the *presence* of the forbidden + /// word the thing under test, so the page's vocabulary rule was guarded by requiring the + /// vocabulary to be broken. + /// + /// + /// The rule survives, split from the spelling. Here: the two refusal ids reach the page, asked + /// of the bundle, so this holds in every locale. And in + /// : the word appears in no other + /// message, in no locale — which is the rule itself ("reachable, never uptime", in copy), + /// checked over the whole bundle rather than inferred from one page's word count. + /// + /// [Test] - public async Task ReachableIsExplainedAndNeverCalledUptimeExceptToRefuseTheWord() + public async Task ReachableIsExplainedInWhateverLanguageThePageIsRead() { var text = Render.Words(Plain); - await Assert.That(text).Contains("Reachable, never uptime."); + await Assert.That(text).Contains(Says("about.measures.reachable.lead")); + await Assert.That(text).Contains(Says("about.measures.reachable.body")); - // 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); + // The substance of the refusal, which is what the rule is for: the measurement is of our + // socket from our host, and an unreachable game may be perfectly alive. await Assert.That(text).Contains("unreachable and perfectly alive"); await Assert.That(text).Contains("nothing here measured it"); } + /// + /// "Reachable, never uptime" — over the bundle, in every locale, rather than over one page. + /// + /// + /// The two about-page ids are the whole exemption: they are the sentences that name the word in + /// order to refuse it, and they are the only place on the site allowed to. Every other id is + /// checked in every bundle a reader can be served, so a translator who reaches for the loanword + /// in a reachability string fails this rather than shipping it — which a rendered-English word + /// count could never have caught. + /// + [Test] + public async Task NoMessageOutsideTheseTwoRefusalsUsesTheWordUptime() + { + string[] refusals = ["about.measures.reachable.lead", "about.measures.reachable.body"]; + + var tags = Locales.All + .Select(locale => locale.Tag) + .Append(Locales.SourceTag) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + await Assert.That(tags).IsNotEmpty(); + + foreach (var tag in tags) + { + foreach (var id in Messages.Ids.Except(refusals, StringComparer.Ordinal)) + { + var pattern = Messages.Pattern(tag, id) ?? string.Empty; + + await Assert.That(pattern.Contains("uptime", StringComparison.OrdinalIgnoreCase)) + .IsFalse() + .Because($"{id} says \"uptime\" in {tag}; the word here is reachable"); + } + } + + // And the exemption is real rather than vacuous: the English refusals do use the word, which + // is what makes them refusals and not silence. + await Assert.That(Says("about.measures.reachable.lead").ToLowerInvariant()).Contains("uptime"); + } + [Test] public async Task TheThingsThisSiteWillNotDoAreStatedRatherThanImplied() { @@ -231,10 +312,10 @@ public async Task TheThingsThisSiteWillNotDoAreStatedRatherThanImplied() // 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."); + await Assert.That(text).Contains(Says("about.never.votes.lead")); + await Assert.That(text).Contains(Says("about.never.forums.lead")); + await Assert.That(text).Contains(Says("about.never.names.lead")); + await Assert.That(text).Contains(Says("about.never.population.lead")); } [Test] @@ -242,7 +323,7 @@ public async Task TheDataLicenceIsPresentedAsUndecidedRatherThanAsSettled() { var text = Render.Words(Plain); - await Assert.That(text).Contains("The code is MIT."); + await Assert.That(text).Contains(Says("about.licence.code.lead")); await Assert.That(text).Contains("licence for the data is an open question"); await Assert.That(text).Contains("not yet taken"); @@ -295,6 +376,58 @@ public async Task TheGraphicalPageCarriesEverySentenceThePlainOneDoes() } } + /// + /// Every sentence on this page comes out of the bundle, and the machine voice does not. + /// + /// + /// + /// The pseudolocale accents and brackets every string that reached a reader through + /// , so anything still legible as English here is a sentence typed into a + /// page — which is what the whole of this page was until it was moved. This was the largest + /// untranslated surface on the site, and the one it would be worst to leave: it is where the + /// site explains what a measurement here proves, to a reader who by definition does not yet + /// trust it. + /// + /// + /// The directories' names and addresses go the other way. They are somebody else's name and + /// somebody else's URL, they are the credit §7.6 owes, and translating either would destroy the + /// acknowledgement rather than localize it — so they are asserted to be unchanged. + /// + /// + [Test] + public async Task EverySentenceComesFromTheBundleAndTheDirectoriesOwnNamesDoNot() + { + var page = AboutPage.Build(new ProbeOptions(), new DatasetLicenceOptions(), "qps-ploc"); + var pseudo = PlainText.RenderAbout(page, "qps-ploc"); + + await Assert.That(pseudo).Contains("⟦"); + + foreach (var section in Page.Sections) + { + await Assert.That(pseudo) + .DoesNotContain(section.Heading) + .Because($"the {section.Id} heading never went through the message pipeline"); + + foreach (var point in section.Points) + { + await Assert.That(pseudo) + .DoesNotContain(point.Lead) + .Because($"a point in {section.Id} is hard-coded English"); + } + } + + // The crawler's own name and the licence a deployment configured are machine voice too, and + // are read off the objects that own them rather than out of the bundle. + await Assert.That(pseudo).Contains(page.Sections.Single(s => s.Id == "crawler").Identity!.Name); + await Assert.That(pseudo).Contains(new DatasetLicenceOptions().LicenceName); + + foreach (var source in Page.Sources) + { + await Assert.That(pseudo).Contains(source.Name); + await Assert.That(pseudo).Contains(source.Url); + } + } + [Test] public async Task TheGraphicalPageIsReachableWithoutScriptingAndSaysHowToReadItPlainly() { diff --git a/tests/MUI.Web.Tests/AccountSurfaceTests.cs b/tests/MUI.Web.Tests/AccountSurfaceTests.cs index 173d0b8..c8df4ef 100644 --- a/tests/MUI.Web.Tests/AccountSurfaceTests.cs +++ b/tests/MUI.Web.Tests/AccountSurfaceTests.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Security.Claims; using System.Text.RegularExpressions; @@ -15,8 +16,10 @@ using MUI.Catalog.Persistence; using MUI.Web; using MUI.Web.Accounts; +using MUI.Web.Components; using MUI.Web.Components.Pages; using MUI.Web.Fixtures; +using MUI.Web.Localization; namespace MUI.Web.Tests; @@ -64,11 +67,123 @@ public async Task WithNoDatabaseThePageSaysAccountsNeedOne() var body = Render.Words(await host.Client.GetStringAsync("/account")); - await Assert.That(body).Contains("Accounts need a database"); - await Assert.That(body).DoesNotContain("Sign in"); - await Assert.That(body).DoesNotContain("give up this claim"); + // The fact, through the bundle, rather than a pasted sentence: the claim is that this page + // says the one thing and offers neither the way in nor a write surface, and it goes on + // being that claim when somebody rewords the copy. + await Assert.That(body).Contains(En("account.noDatabase")); + await Assert.That(body).DoesNotContain(En("account.signInButton")); + await Assert.That(body).DoesNotContain(En("account.resign.summary")); } + /// One message in the source language, as it reads once rendered. + private static string En(string id) => Render.Words(Messages.For(Locales.SourceTag, id)); + + /// + /// A placed sentence in the source language, with its slots filled as the page fills them. + /// + /// + /// hands back runs and markers so the markup can choose an element + /// per slot; a test wants the sentence a reader ends up with. Reassembling it here means these + /// assertions read the same bundle the page does — the alternative is a pasted English string, + /// which is exactly what this pass exists to remove. + /// + private static string EnPlaced( + string id, + IReadOnlyDictionary fills, + params (string Key, object? Value)[] args) => + Render.Words(string.Concat(Sentence + .Place(Locales.SourceTag, id, [.. fills.Keys], args) + .Select(part => part.Slot is null ? part.Text : fills[part.Slot]))); + + /// + /// Every word of the sign-in page comes out of the message bundle. + /// + /// + /// + /// Both branches, because they are two different pages: over the demo fixture it is one sentence + /// saying there is nothing to sign in to, and behind a database it is the whole passkey + /// explanation — and the second was never rendered by any test at all, so it could have drifted + /// into English without anything noticing. + /// + /// + /// The pseudolocale is the instrument: it accents and brackets anything that reached a reader + /// through , so an English sentence surviving here is one typed into the + /// markup. Asserted against the English render rather than against pasted strings, so this keeps + /// holding when the copy is edited. + /// + /// + [Test] + [Arguments(true)] + [Arguments(false)] + public async Task EveryWordOfSigningInComesFromTheBundle(bool withDatabase) + { + var english = Render.Words(await SignInAsync(Locales.SourceTag, withDatabase)); + var pseudo = Render.Words(await SignInAsync("qps-ploc", withDatabase)); + + await Assert.That(pseudo).Contains("⟦"); + + // Which page this is, asserted rather than assumed: a harness that failed to register + // identity would render the one-sentence branch twice and the loop below would pass on a + // page nobody had looked at. + await Assert.That(english.Contains( + Messages.For(Locales.SourceTag, "account.store.heading"), StringComparison.Ordinal)) + .IsEqualTo(withDatabase); + + await Assert.That(english.Contains( + Messages.For(Locales.SourceTag, "account.signIn.noDatabase"), StringComparison.Ordinal)) + .IsEqualTo(!withDatabase); + + // Every sentence of the English page, absent from the same page in another language. + // + // Argument-free ids only, and that costs this sweep nothing: the sign-in page says no + // message that takes one. The `account.` prefix now also covers the owner dashboard, whose + // sentences name a game, a date or a count — and a pattern cannot be rendered at all + // without its arguments, so including them here would throw rather than assert. + foreach (var id in Sayable("account.")) + { + var sentence = Render.Words(Messages.For(Locales.SourceTag, id)); + + if (!english.Contains(sentence, StringComparison.Ordinal)) + { + continue; + } + + await Assert.That(pseudo) + .DoesNotContain(sentence) + .Because($"{id} is rendered as English whatever language the page was asked for"); + } + } + + /// + /// The sign-in page, in one locale, with or without a database behind it. + /// + /// + /// The locale arrives the way the middleware leaves it — in HttpContext.Items — because + /// that is what the page reads. Identity is registered only for the second case, which is + /// exactly the condition the page itself branches on. + /// + private static Task SignInAsync(string tag, bool withDatabase) => + Render.ComponentAsync([], services => + { + services.AddLogging(); + services.AddSingleton(new MUI.Web.Data.CatalogueSource(IsMeasured: withDatabase)); + + var context = new DefaultHttpContext(); + context.Items[LocaleRouting.ItemKey] = + new LocaleContext(Locales.Find(tag)!, FromPath: tag != Locales.SourceTag); + services.AddCascadingValue(_ => context); + + if (!withDatabase) + { + return; + } + + services.AddHttpContextAccessor(); + services.AddAuthentication(); + services.AddSingleton>(new Accounts([], FixtureGameQueries.Now)); + services.AddIdentityCore().AddSignInManager(); + }); + /// A signed-out visitor is offered the way in and nothing else. [Test] public async Task ASignedOutVisitorIsOfferedSignInAndNoWriteSurface() @@ -77,7 +192,7 @@ public async Task ASignedOutVisitorIsOfferedSignInAndNoWriteSurface() await Assert.That(markup).Contains("/account/sign-in"); await Assert.That(markup).DoesNotContain("An account with nothing claimed is told where to start. @@ -87,9 +202,20 @@ public async Task AnAccountWithNoClaimsIsPointedAtTheListing() var markup = await World.New().SignedIn().RenderAsync(); var words = Render.Words(markup); - await Assert.That(words).Contains("You have not claimed anything yet"); + // The whole sentence, reassembled from the bundle including the two runs the markup turns + // into a link and an emphasis — so this holds on the words a reader gets rather than on + // the fragments the markup happens to be split into. Read off the visible text, because a + // placed sentence has elements inside it. + await Assert.That(Render.Text(markup)).Contains(EnPlaced( + "account.empty.body", + new Dictionary(StringComparer.Ordinal) + { + ["listing"] = En("account.empty.listing"), + ["claimControl"] = En("account.empty.claimControl"), + })); + await Assert.That(words).Contains("/games"); - await Assert.That(words).DoesNotContain("Waiting on a token"); + await Assert.That(words).DoesNotContain(En("account.pending.heading")); await Assert.That(markup).DoesNotContain("resign"); } @@ -100,11 +226,11 @@ public async Task APendingClaimLinksToItsToken() var markup = await World.New().SignedIn().Pending(Mush).RenderAsync(); var words = Render.Words(markup); - await Assert.That(words).Contains("Waiting on a token"); + await Assert.That(words).Contains(En("account.pending.heading")); await Assert.That(markup).Contains("/g/m-u-s-h/claim"); // Pending is not owning: none of the owner surfaces appear for it. - await Assert.That(words).DoesNotContain("Claimed"); + await Assert.That(words).DoesNotContain(En("account.claimed.heading")); await Assert.That(markup).DoesNotContain("badge.svg"); } @@ -122,7 +248,7 @@ public async Task AVerifiedClaimShowsEverythingAClaimGrants() var markup = await World.New().SignedIn().Verified(Ashen).RenderAsync(); var words = Render.Words(markup); - await Assert.That(words).Contains("Claimed"); + await Assert.That(words).Contains(En("account.claimed.heading")); await Assert.That(markup).Contains("/g/ashen-court"); // §8.5's enrichment, through OwnerPanel. @@ -136,7 +262,7 @@ public async Task AVerifiedClaimShowsEverythingAClaimGrants() await Assert.That(markup).Contains("/g/ashen-court/mssp"); await Assert.That(markup).Contains("/g/ashen-court/badge.svg"); await Assert.That(markup).Contains("/g/ashen-court/badge.json"); - await Assert.That(words).Contains("history"); + await Assert.That(words).Contains(En("account.history.summary")); await Assert.That(markup).Contains("/resign"); } @@ -185,11 +311,19 @@ public async Task ACoOwnedGameNamesTheOtherOwners() var markup = await World.New().SignedIn().Verified(Ashen).CoOwnedBy("thistle").RenderAsync(); var words = Render.Words(markup); - await Assert.That(words).Contains("Also owned by thistle"); - await Assert.That(words).Contains("verified a token of their own"); + // One co-owner, so the message's `one` branch — which is a different sentence from the + // plural in English and in most languages, and naming the branch is the point. + await Assert.That(words).Contains( + Render.Words(Messages.Say( + Locales.SourceTag, "account.claim.coOwners", ("count", 1), ("names", "thistle")))); // The resign copy is the one that differs when somebody else holds the game too. - await Assert.That(words).Contains("the game stays claimed if anybody else owns it"); + await Assert.That(Render.Text(markup)).Contains(EnPlaced( + "account.resign.confirm", + new Dictionary(StringComparer.Ordinal) + { + ["word"] = OwnershipWrites.ResignConfirmation, + })); } /// A sole owner sees no co-owner line at all. @@ -198,7 +332,10 @@ public async Task ASoleOwnerIsNotToldAboutOwnersWhoDoNotExist() { var words = Render.Words(await World.New().SignedIn().Verified(Ashen).RenderAsync()); - await Assert.That(words).DoesNotContain("Also owned by"); + // The whole rendered line for one co-owner, which cannot appear when there are none. + await Assert.That(words).DoesNotContain( + Render.Words(Messages.Say( + Locales.SourceTag, "account.claim.coOwners", ("count", 1), ("names", "thistle")))); } // ── the status banner ───────────────────────────────────────────────────── @@ -213,11 +350,15 @@ public async Task ASoleOwnerIsNotToldAboutOwnersWhoDoNotExist() /// branch — so every arm is driven here, by the querystring the redirect actually carries. /// [Test] - [Arguments("?saved={game}&did=fields", "now shows it as owner-declared")] - [Arguments("?saved={game}&did=screen-hidden", "stopped republishing")] - [Arguments("?saved={game}&did=screen-shown", "connect screen is on its page again")] - [Arguments("?resigned=1", "Given up.")] - [Arguments("?refused=CODEBASE&because=NotEnrichable", "CODEBASE was not changed")] + [Arguments("?saved={game}&did=fields", "account.saved.fields")] + [Arguments("?saved={game}&did=screen-hidden", "account.saved.screenHidden")] + [Arguments("?saved={game}&did=screen-shown", "account.saved.screenShown")] + [Arguments("?saved={game}&did=crawl-stopped", "account.saved.crawlStopped")] + [Arguments("?saved={game}&did=crawl-resumed", "account.saved.crawlResumed")] + [Arguments("?saved={game}&did=unlisted", "account.saved.unlisted")] + [Arguments("?saved={game}&did=relisted", "account.saved.relisted")] + [Arguments("?resigned=1", "account.resigned.lead")] + [Arguments("?refused=CODEBASE&because=NotEnrichable", "account.refused.lead")] public async Task EveryOutcomeReportsTheActionThatHappened(string query, string expected) { var words = Render.Words(await World.New() @@ -225,9 +366,19 @@ public async Task EveryOutcomeReportsTheActionThatHappened(string query, string .Verified(Ashen) .RenderAsync(query.Replace("{game}", Ashen.ToString(), StringComparison.Ordinal))); - await Assert.That(words).Contains(expected); + // The id rather than a pasted fragment, so a reworded banner still has to be the banner for + // the action that happened. The four §11 and listing arms were added to OwnerWrites and + // never to this table, which is the exact omission the remark above describes — the switch + // had already reported the wrong branch once for the same reason. + // + // Both arguments are supplied to every arm; a pattern that names neither ignores them. + await Assert.That(words).Contains(Render.Words(Messages.Say( + Locales.SourceTag, expected, ("game", AshenName), ("field", "CODEBASE")))); } + /// The fixture game's own name, which the banner quotes and never translates. + private const string AshenName = "Ashen Court"; + /// /// Two outcomes at once is one banner, and it is the one the chain says it is. /// @@ -244,12 +395,154 @@ public async Task ACraftedUrlCarryingTwoOutcomesStillReportsOnlyOne() .Verified(Ashen) .RenderAsync($"?resigned=1&saved={Ashen}&did=fields&refused=CODEBASE")); - await Assert.That(words).Contains("Given up."); - await Assert.That(words).DoesNotContain("now shows it as owner-declared"); - await Assert.That(words).DoesNotContain("was not changed"); + await Assert.That(words).Contains(En("account.resigned.lead")); + await Assert.That(words).DoesNotContain(Render.Words( + Messages.Say(Locales.SourceTag, "account.saved.fields", ("game", AshenName)))); + await Assert.That(words).DoesNotContain(Render.Words( + Messages.Say(Locales.SourceTag, "account.refused.lead", ("field", "CODEBASE")))); + } + + // ── the language the dashboard is answered in ───────────────────────────── + + /// + /// Every word of the dashboard comes out of the bundle, on the fullest page it has. + /// + /// + /// + /// The pseudolocale is the instrument, as it is for the sign-in page: it accents and brackets + /// anything that reached a reader through , so an English sentence + /// surviving here is one somebody typed into the markup. Driven on a verified claim with a + /// co-owner and a banner, because that is the state that renders the owner panel, the badge + /// block, the audit log and the resignation form — most of this page's words are in there. + /// + /// + /// What stays English is named rather than tolerated: a game's name, an operator's chosen + /// display name, a field name out of the registry, the confirmation word the form compares + /// against, the badge's own text and an ISO stamp. Every one of those is machine voice, and + /// translating any of them would break the thing it names. + /// + /// + [Test] + public async Task EveryWordOfTheDashboardComesFromTheBundle() + { + var english = Render.Text(await Full(Locales.SourceTag)); + var pseudo = Render.Text(await Full("qps-ploc")); + + await Assert.That(pseudo).Contains("⟦"); + + // The page really is the full one, rather than a guard branch rendered twice. + await Assert.That(english).Contains(En("account.claimed.heading")); + await Assert.That(english).Contains(En("owner.crawl.heading")); + + foreach (var id in Sayable("account.", "owner.")) + { + var sentence = Render.Words(Messages.For(Locales.SourceTag, id)); + + if (!english.Contains(sentence, StringComparison.Ordinal)) + { + continue; + } + + await Assert.That(pseudo) + .DoesNotContain(sentence) + .Because($"{id} is rendered as English whatever language the page was asked for"); + } } + /// + /// A German request gets German wherever German exists, and the machine voice survives it. + /// + /// + /// + /// Gated on rather than on a list of ids, and that is the + /// point. This page's own ids are new and the four satellites are translated in one round + /// afterwards, so today most of them fall back to English — which is the designed behaviour and + /// not a failure. Naming ids here would either freeze that state in or fail the moment the + /// translations land. Asking the bundle instead means the assertion is "German where there is + /// German", which is true now and stays true as each id is translated. + /// + /// + /// The counter proves it is not vacuous: the owner panel already renders the provenance word, + /// which is translated, so this has real German to find today. + /// + /// + [Test] + public async Task AGermanRequestGetsGermanOnTheDashboard() + { + var english = Render.Text(await Full(Locales.SourceTag)); + var german = Render.Text(await Full("de")); + + var translated = 0; + + // The provenance family joins by the one id these pages render, not by prefix: the others + // carry the bare word "measured", which occurs inside half the sentences on this page and + // would match as a substring rather than as the chip the panel promises. + foreach (var id in Sayable("account.", "owner.").Append("provenance.game.ownerDeclared")) + { + var en = Render.Words(Messages.For(Locales.SourceTag, id)); + var de = Render.Words(Messages.For("de", id)); + + // An id that fell back has nothing to assert, and a word German spells the same way + // proves nothing either way. + if (!Messages.HasOwn("de", id) + || string.Equals(en, de, StringComparison.Ordinal) + || !english.Contains(en, StringComparison.Ordinal)) + { + continue; + } + + await Assert.That(german).Contains(de).Because($"{id} is not answered in German"); + + translated++; + } + + await Assert.That(translated) + .IsGreaterThan(0) + .Because("no id on this page has German yet, so this test asserted nothing"); + + // The page is answered in German rather than merely served under a German tag. + await Assert.That(german).IsNotEqualTo(english); + + // A fallback shows the English and never the id. That is the one failure mode a reader + // cannot recover from — English inside a German sentence tells them something true, and + // `account.title` on the page tells them the site is broken. + foreach (var id in Sayable("account.", "owner.").Where(i => !Messages.HasOwn("de", i))) + { + await Assert.That(german).DoesNotContain(id).Because($"{id} reached a reader as its id"); + } + + // Machine voice is untouched: the game's name and the confirmation word are not translated. + await Assert.That(german).Contains(AshenName); + await Assert.That(german).Contains(OwnershipWrites.ResignConfirmation); + } + + /// Ids under these prefixes that can be rendered without arguments. + /// + /// A pattern cannot be formatted at all without the arguments it names, so a sweep that walks + /// whole sentences has to leave those out. It costs little: the parameterised ids on these + /// pages are the ones carrying a game's name, a date or a count, and each has its own test. + /// + private static IEnumerable Sayable(params string[] prefixes) => + Messages.Ids + .Where(i => prefixes.Any(p => i.StartsWith(p, StringComparison.Ordinal))) + .Where(i => !IcuMessage.Compile(Messages.Pattern(Locales.SourceTag, i)!).Arguments().Any()); + + /// The dashboard in one locale, in the state that renders the most of it. + private static Task Full(string tag) => + World.New() + .In(tag) + .SignedIn() + .Verified(Ashen) + .CoOwnedBy("thistle") + .RenderAsync($"?saved={Ashen}&did=fields"); + /// An enrichment refusal names the field and says which rule refused it. + /// + /// The two reasons are different sentences and must not be swapped: one says an answer was too + /// long, the other says the field is measured and that nobody edits a measurement, us included. + /// A refusal that gave the second reason for the first cause would be the site claiming a rule + /// it does not have. + /// [Test] public async Task ARefusalOverLengthSaysSoRatherThanBlamingTheField() { @@ -258,9 +551,15 @@ public async Task ARefusalOverLengthSaysSoRatherThanBlamingTheField() .Verified(Ashen) .RenderAsync("?refused=FANDOM&because=TooLong")); - await Assert.That(words).Contains("FANDOM was not changed"); - await Assert.That(words).Contains($"{OwnerEnrichment.MaxValueLength} characters"); - await Assert.That(words).DoesNotContain("That field is measured"); + await Assert.That(words).Contains(Render.Words( + Messages.Say(Locales.SourceTag, "account.refused.lead", ("field", "FANDOM")))); + + await Assert.That(words).Contains(Render.Words(Messages.Say( + Locales.SourceTag, + "account.refused.tooLong", + ("max", OwnerEnrichment.MaxValueLength.ToString(CultureInfo.InvariantCulture))))); + + await Assert.That(words).DoesNotContain(En("account.refused.measured")); } // ── the routes the page posts to ────────────────────────────────────────── @@ -363,9 +662,23 @@ private sealed class World private readonly List _claims = []; private bool _signedIn; + private string _tag = Locales.SourceTag; public static World New() => new(); + /// + /// The locale the request was answered in, as the middleware leaves it. + /// + /// + /// In HttpContext.Items rather than on a thread's culture, because that is where the + /// page reads it from — every surface here is handed its locale rather than inferring one. + /// + public World In(string tag) + { + _tag = tag; + return this; + } + public World SignedIn() { _signedIn = true; @@ -442,7 +755,7 @@ public Task RenderAsync(string query = "") => services.AddSingleton(new OwnerEnrichment( claims, fields, new FieldReconciler(fields), FieldRegistry.Instance, new Frozen(Now))); - services.AddSingleton>(new Accounts(_accounts)); + services.AddSingleton>(new Accounts(_accounts, Now)); services.AddIdentityCore(); }); @@ -456,10 +769,13 @@ public Task RenderAsync(string query = "") => ExpiresAt = Now.AddDays(20), }; - private static HttpContext Context(MuiUser? user) + private HttpContext Context(MuiUser? user) { var context = new DefaultHttpContext(); + context.Items[LocaleRouting.ItemKey] = + new LocaleContext(Locales.Find(_tag)!, FromPath: _tag != Locales.SourceTag); + if (user is not null) { context.User = new ClaimsPrincipal(new ClaimsIdentity( @@ -600,78 +916,4 @@ public Task> UnarchivedAsync(CancellationToken ct = de CancellationToken ct = default) => Task.FromResult(null); } - - /// - /// Enough of a user store to find an account and list its passkeys. - /// - /// - /// Identity's UserManager is a concrete type over a store interface, so a page that calls - /// it needs one. This is persistence rather than authorisation: which account the page is for - /// comes from the cascaded principal, and what that account may see comes from the claim store. - /// - private sealed class Accounts(List users) : IUserStore, IUserPasskeyStore - { - public Task FindByIdAsync(string id, CancellationToken ct) => - Task.FromResult(users.FirstOrDefault(u => u.Id.ToString() == id)); - - public Task GetUserIdAsync(MuiUser user, CancellationToken ct) => - Task.FromResult(user.Id.ToString()); - - public Task GetUserNameAsync(MuiUser user, CancellationToken ct) => - Task.FromResult(user.DisplayName); - - public Task GetNormalizedUserNameAsync(MuiUser user, CancellationToken ct) => - Task.FromResult(user.NormalisedName); - - public Task FindByNameAsync(string name, CancellationToken ct) => - Task.FromResult(users.FirstOrDefault(u => u.NormalisedName == name)); - - public Task SetUserNameAsync(MuiUser user, string? name, CancellationToken ct) => Task.CompletedTask; - - public Task SetNormalizedUserNameAsync(MuiUser user, string? name, CancellationToken ct) => - Task.CompletedTask; - - public Task CreateAsync(MuiUser user, CancellationToken ct) => - Task.FromResult(IdentityResult.Success); - - public Task UpdateAsync(MuiUser user, CancellationToken ct) => - Task.FromResult(IdentityResult.Success); - - public Task DeleteAsync(MuiUser user, CancellationToken ct) => - Task.FromResult(IdentityResult.Success); - - public Task AddOrUpdatePasskeyAsync(MuiUser user, UserPasskeyInfo passkey, CancellationToken ct) => - Task.CompletedTask; - - public Task> GetPasskeysAsync(MuiUser user, CancellationToken ct) => - Task.FromResult>( - [ - new UserPasskeyInfo( - [1, 2, 3], - [4, 5, 6], - Now.AddYears(-1), - 0u, - ["internal"], - true, - true, - true, - [], - []) - { - Name = "the yubikey in the drawer", - }, - ]); - - public Task FindByPasskeyIdAsync(byte[] id, CancellationToken ct) => - Task.FromResult(null); - - public Task FindPasskeyAsync(MuiUser user, byte[] id, CancellationToken ct) => - Task.FromResult(null); - - public Task RemovePasskeyAsync(MuiUser user, byte[] id, CancellationToken ct) => Task.CompletedTask; - - public void Dispose() - { - } - } } diff --git a/tests/MUI.Web.Tests/Accounts.cs b/tests/MUI.Web.Tests/Accounts.cs new file mode 100644 index 0000000..08bf9f8 --- /dev/null +++ b/tests/MUI.Web.Tests/Accounts.cs @@ -0,0 +1,88 @@ +using Microsoft.AspNetCore.Identity; + +using MUI.Web.Accounts; + +namespace MUI.Web.Tests; + +/// +/// Enough of a user store to find an account and list its passkeys. +/// +/// +/// +/// Identity's UserManager is a concrete type over a store interface, so a page that calls it +/// needs one. This is persistence rather than authorisation: which account a page is for comes from +/// the cascaded principal, and what that account may see comes from the claim store. +/// +/// +/// Its own file because two surfaces need it. The dashboard and the claim page both go through +/// UserManager.GetUserAsync before they render anything an owner can act on, and the second +/// one could not be rendered at all while this was private to the first — so the claim page's +/// signed-in states had no test, which is the same gap the dashboard was in before it got one. +/// +/// +internal sealed class Accounts(List users, DateTimeOffset now) + : IUserStore, IUserPasskeyStore +{ + public Task FindByIdAsync(string id, CancellationToken ct) => + Task.FromResult(users.FirstOrDefault(u => u.Id.ToString() == id)); + + public Task GetUserIdAsync(MuiUser user, CancellationToken ct) => + Task.FromResult(user.Id.ToString()); + + public Task GetUserNameAsync(MuiUser user, CancellationToken ct) => + Task.FromResult(user.DisplayName); + + public Task GetNormalizedUserNameAsync(MuiUser user, CancellationToken ct) => + Task.FromResult(user.NormalisedName); + + public Task FindByNameAsync(string name, CancellationToken ct) => + Task.FromResult(users.FirstOrDefault(u => u.NormalisedName == name)); + + public Task SetUserNameAsync(MuiUser user, string? name, CancellationToken ct) => Task.CompletedTask; + + public Task SetNormalizedUserNameAsync(MuiUser user, string? name, CancellationToken ct) => + Task.CompletedTask; + + public Task CreateAsync(MuiUser user, CancellationToken ct) => + Task.FromResult(IdentityResult.Success); + + public Task UpdateAsync(MuiUser user, CancellationToken ct) => + Task.FromResult(IdentityResult.Success); + + public Task DeleteAsync(MuiUser user, CancellationToken ct) => + Task.FromResult(IdentityResult.Success); + + public Task AddOrUpdatePasskeyAsync(MuiUser user, UserPasskeyInfo passkey, CancellationToken ct) => + Task.CompletedTask; + + public Task> GetPasskeysAsync(MuiUser user, CancellationToken ct) => + Task.FromResult>( + [ + new UserPasskeyInfo( + [1, 2, 3], + [4, 5, 6], + now.AddYears(-1), + 0u, + ["internal"], + true, + true, + true, + [], + []) + { + Name = "the yubikey in the drawer", + }, + ]); + + public Task FindByPasskeyIdAsync(byte[] id, CancellationToken ct) => + Task.FromResult(null); + + public Task FindPasskeyAsync(MuiUser user, byte[] id, CancellationToken ct) => + Task.FromResult(null); + + public Task RemovePasskeyAsync(MuiUser user, byte[] id, CancellationToken ct) => Task.CompletedTask; + + public void Dispose() + { + } +} diff --git a/tests/MUI.Web.Tests/AdultSurfaceTests.cs b/tests/MUI.Web.Tests/AdultSurfaceTests.cs index 4042fb5..b3b4124 100644 --- a/tests/MUI.Web.Tests/AdultSurfaceTests.cs +++ b/tests/MUI.Web.Tests/AdultSurfaceTests.cs @@ -1,3 +1,4 @@ +using MUI.Web.Localization; using MUI.Catalog; using MUI.Web.Api; using MUI.Web.Components; @@ -70,23 +71,37 @@ public async Task TheBarCarriesTheControlWhetherOrNotItIsTicked() { // Drawn on every request, ticked or not. A default whose only undo appears once you have // already undone it is a default a reader cannot find, and this one hides games silently. + // + // A link now rather than a checkbox waiting on a submit button: the panel's "show" button + // is gone, so a control that only armed itself would be one nothing could fire. foreach (var filter in new[] { Listing(), Listing($"?{FacetKeys.Adult}=true") }) { var html = await PanelAsync(filter); - await Assert.That(html).Contains($"name=\"{FacetKeys.Adult}\""); - await Assert.That(Render.Words(html)).Contains("include adult"); + // The control, whichever way it is set. Its href is the *other* position, so asserting + // on a fixed URL would only ever find one of the two states — which is how a control + // that vanished once it had been used would pass a test. + await Assert.That(html).Contains("adult content,"); + await Assert.That(Render.Words(html)).Contains("adult"); } } [Test] - public async Task TheTickedBoxIsCheckedWhenTheUrlAsksForThem() + public async Task TheSwitchSaysWhichWayItIsSetAndLinksToTheOther() { - // The URL is the whole of this page's state, so a shared link has to come back with the box - // in the position that produced it. - await Assert.That(await PanelAsync(Listing())).DoesNotContain($"name=\"{FacetKeys.Adult}\" value=\"true\" checked"); - await Assert.That(await PanelAsync(Listing($"?{FacetKeys.Adult}=1"))) - .Contains($"name=\"{FacetKeys.Adult}\" value=\"true\" checked"); + // The URL is the whole of this page's state, so a shared link has to come back with the + // switch in the position that produced it — and the way out has to be the other position. + var off = await PanelAsync(Listing()); + var on = await PanelAsync(Listing($"?{FacetKeys.Adult}=1")); + + // Off: says so, and its link turns them on. + await Assert.That(off).Contains("adult content, hidden"); + await Assert.That(off).Contains($"href=\"/games?{FacetKeys.Adult}=true\""); + + // On: says so, and its link is the one that clears the key rather than setting it false — + // the address a reader copies says only what they asked for. + await Assert.That(on).Contains("adult content, shown"); + await Assert.That(on).DoesNotContain($"href=\"/games?{FacetKeys.Adult}=true\""); } [Test] @@ -96,9 +111,16 @@ public async Task IncludingThemIsAChipThatCanBeTakenBackOff() var filter = Listing(Query); var listing = await Queries.SearchAsync(filter); - var chip = ActiveFilters.For(listing.Facets, filter, Query).Single(c => c.Facet is "adult"); - await Assert.That(chip.Value).IsEqualTo("included"); + // Both halves of the chip come out of the bundle now, so this asks the bundle what they say + // rather than repeating the English. The fact under test is unchanged and is not wording: + // the chip for this key exists, it says the key is included, and its link takes it back off. + var chips = ActiveFilters.For(Locales.SourceTag, listing.Facets, filter, Query); + var chip = chips.Single( + c => c.Facet == FacetWords.Group(Locales.SourceTag, FacetKeys.Adult)); + + await Assert.That(chip.Value) + .IsEqualTo(Messages.For(Locales.SourceTag, "facet.value.included")); await Assert.That(chip.RemoveHref).DoesNotContain(FacetKeys.Adult); } diff --git a/tests/MUI.Web.Tests/AnsiTests.cs b/tests/MUI.Web.Tests/AnsiTests.cs index 6531733..324d8e3 100644 --- a/tests/MUI.Web.Tests/AnsiTests.cs +++ b/tests/MUI.Web.Tests/AnsiTests.cs @@ -92,23 +92,135 @@ public async Task NothingCapturedIsDistinctFromTooSmall() } [Test] - public async Task AScreenPastTwentyFourRowsIsCroppedAndSaysHowManyThereAre() + public async Task ALongScreenIsParsedWholeRatherThanCropped() { - var screen = Ansi.Parse(Screen(47), suppressedByOwner: false); + // There is no crop any more. The frame used to hold the first twenty-four rows, offer the + // whole screen again under "show all N rows" and its text a third time under "read as text" + // — three copies of one piece of box-drawing, and three passes through it for anybody + // listening. One region, every row, and it scrolls. + var screen = Ansi.Parse(Screen(214), suppressedByOwner: false); - await Assert.That(screen.RowCount).IsEqualTo(47); - await Assert.That(screen.IsCropped).IsTrue(); - await Assert.That(screen.IsOversized).IsFalse(); - await Assert.That(screen.Visible.Count()).IsEqualTo(Ansi.CropRows); + await Assert.That(screen.RowCount).IsEqualTo(214); + await Assert.That(screen.Rows.Count).IsEqualTo(214); } [Test] - public async Task AScreenPastTwoHundredRowsIsFlaggedAsOversizedAndStillCropsAtTwentyFour() + public async Task AWidthIsCountedInTerminalCellsAndPerRowRatherThanPerScreen() { - var screen = Ansi.Parse(Screen(214), suppressedByOwner: false); + // The row that made a screen-wide flag wrong: seventy-eight ASCII characters and one Han + // glyph. Seventy-nine characters, eighty cells — and the caption used to halve the whole + // screen the moment any wide rune appeared anywhere, and report forty for this. + var mixed = new string('x', 78) + "漢"; + var screen = Ansi.Parse($"{mixed}\nplain\nplain", suppressedByOwner: false); + + await Assert.That(screen.Rows[0].Text.Length).IsEqualTo(79); + await Assert.That(screen.Rows[0].Cells).IsEqualTo(80); + await Assert.That(screen.CellColumns).IsEqualTo(80); + await Assert.That(screen.HasWideRunes).IsTrue(); + + // And eighty-one cells cannot be one row of an eighty-column terminal. A double-width glyph + // that would straddle the right margin is moved whole to the next line rather than split + // across it, which is what a terminal does — so seventy-nine ASCII plus one Han glyph is two + // rows, not one over-wide one. The layout counts cells and the caption reads them; before + // this the layout counted UTF-16 units, so a screen of wide glyphs wrapped after eighty + // runes, which is a hundred and sixty cells. + var straddling = Ansi.Parse(new string('x', 79) + "漢\nplain", suppressedByOwner: false); + + await Assert.That(straddling.Rows[0].Cells).IsEqualTo(79); + await Assert.That(straddling.Rows[1].Text).IsEqualTo("漢"); + + // And a screen drawn entirely out of wide glyphs is twice its character count, which is the + // case the halving was written for and the only one it got right. + var wide = Ansi.Parse($"{new string('漢', 40)}\n{new string('漢', 40)}\n{new string('漢', 40)}", + suppressedByOwner: false); + + await Assert.That(wide.CellColumns).IsEqualTo(80); + } + + [Test] + public async Task AWideRuneOnOneRowDoesNotWidenTheRowsAroundIt() + { + // The width reported is the widest row, not the widest row's arithmetic applied to all of + // them. A banner with one Japanese line in it is as wide as that line and no wider. + var screen = Ansi.Parse("ascii\n漢字\nascii", suppressedByOwner: false); + + await Assert.That(screen.Rows[0].Cells).IsEqualTo(5); + await Assert.That(screen.Rows[1].Cells).IsEqualTo(4); + await Assert.That(screen.CellColumns).IsEqualTo(5); + } + + [Test] + public async Task ACombiningMarkCostsNoCellOfItsOwn() + { + // It is drawn onto the cell before it. Counted as a character, an accented Greek or + // Devanagari banner would be reported wider than it is drawn. + // + // Composed here rather than written as a literal, so the decomposition is this test's and + // not whatever normalisation the file was saved under: three letters, each carrying a + // combining acute of its own. + var acute = (char)0x0301; + var decomposed = string.Concat(Enumerable.Repeat($"e{acute}", 3)); + var screen = Ansi.Parse($"{decomposed}\nplain\nplain", suppressedByOwner: false); + + await Assert.That(screen.Rows[0].Text.Length).IsEqualTo(6); + await Assert.That(screen.Rows[0].Cells).IsEqualTo(3); + await Assert.That(screen.HasWideRunes).IsFalse(); + } + + [Test] + public async Task AnEmptyScreenHasNoWidthRatherThanTheGridsWidth() + { + await Assert.That(Ansi.Parse(null, false).CellColumns).IsEqualTo(0); + } - await Assert.That(screen.IsOversized).IsTrue(); - await Assert.That(screen.Visible.Count()).IsEqualTo(Ansi.CropRows); + /// + /// The whole accessible alternative to the picture is outside the picture. + /// + /// + /// role="img" makes every descendant presentational, so a disclosure inside + /// div.quote is announced as nothing at all and the one-line aria-label becomes + /// the only alternative there is — a label saying the screen exists, in place of the screen. + /// The disclosure therefore sits beside the quoted region and inside the figure. + /// + [Test] + public async Task TheReadAsTextDisclosureIsOutsideTheRoleImgSubtree() + { + var html = await Render.ComponentAsync(new() + { + ["Screen"] = Ansi.Parse(Screen(6), suppressedByOwner: false), + }); + + await Assert.That(html).Contains("role=\"img\""); + await Assert.That(html).Contains("aria-label="); + await Assert.That(html).Contains("class=\"screen-text\""); + + // The quoted region holds one and no other element that closes with , so the + // first after it is its own — and the disclosure has to come after that. + var quote = html.IndexOf("class=\"quote\"", StringComparison.Ordinal); + var closes = html.IndexOf("", quote, StringComparison.Ordinal); + var disclosure = html.IndexOf("class=\"screen-text\"", StringComparison.Ordinal); + + await Assert.That(quote).IsGreaterThanOrEqualTo(0); + await Assert.That(disclosure).IsGreaterThan(closes); + + // And it is still inside the figure, which is what makes the two one block. + var figure = html.IndexOf("", StringComparison.Ordinal); + await Assert.That(disclosure).IsLessThan(figure); + } + + [Test] + public async Task TheCaptionStatesTheWidestRowInCellsRatherThanAHalvedGrid() + { + // Seventy-eight ASCII characters and one Han glyph: eighty cells, the widest a row of an + // eighty-column terminal can be. The caption said forty, because one wide rune anywhere + // halved the whole screen. + var html = Render.Words(await Render.ComponentAsync(new() + { + ["Screen"] = Ansi.Parse(new string('x', 78) + "漢\nplain\nplain", suppressedByOwner: false), + })); + + await Assert.That(html).Contains("80×"); + await Assert.That(html).DoesNotContain("40×"); } [Test] diff --git a/tests/MUI.Web.Tests/CapabilityMatrixTests.cs b/tests/MUI.Web.Tests/CapabilityMatrixTests.cs index 6a9180f..3b36606 100644 --- a/tests/MUI.Web.Tests/CapabilityMatrixTests.cs +++ b/tests/MUI.Web.Tests/CapabilityMatrixTests.cs @@ -42,13 +42,29 @@ public async Task ADisagreementIsMarkedInTheRowAndNotOnlyByATint() } [Test] - public async Task TheCountIsInTheSectionHeadSoItSurvivesCollapse() + public async Task TheTallyIsTheTablesCaptionAndIsSaidExactlyOnce() { + // It used to be the section's heading text and, verbatim, the table's sr-only caption — so a + // screen reader heard the same sentence twice on the way to the first row. As the caption it + // is the table's own description, it is on screen for everybody, and it is there once. + // The wording is the short form the handoff's copy table asks for. "capabilities" was the + // word the heading directly above already says, so the caption no longer repeats it. var html = await MatrixAsync(); var head = html[..html.IndexOf("(new() { ["Slug"] = "m-u-s-h" }); - await Assert.That(page).Contains("Everything here was measured, not entered by an owner"); + // One sentence, where the badge and a paragraph three blocks below used to say the same + // thing twice. The invitation is the half that depends on there being anywhere to sign in. + await Assert.That(Render.Words(page)).Contains("Unclaimed — everything here was measured."); await Assert.That(page).DoesNotContain("Claim this game"); } @@ -95,4 +106,311 @@ public async Task OnlyChannelsAProbeCanReadAreClaimChannels() { await Assert.That(Enum.GetNames()).IsEquivalentTo(new[] { "Mssp", "ConnectScreen" }); } + + // ── the language the claim page is answered in ──────────────────────────── + + /// + /// Every word of the claim page comes out of the message bundle. + /// + /// + /// + /// Driven on the state that renders the most of it: a signed-in operator with a pending token, + /// which is the page an owner actually works from — the instructions, both channels, the + /// expiry and the recheck button. The degraded states are covered below, because those are the + /// ones a reader of the demo site meets. + /// + /// + /// The pseudolocale is the instrument: it brackets anything that reached a reader through + /// , so an English sentence surviving here is one typed into the markup. + /// + /// + [Test] + public async Task EveryWordOfClaimingComesFromTheBundle() + { + var english = Render.Text(await ClaimWorld.Pending(Locales.SourceTag)); + var pseudo = Render.Text(await ClaimWorld.Pending("qps-ploc")); + + await Assert.That(pseudo).Contains("⟦"); + + // The page really is the token-bearing one rather than a guard branch rendered twice. + await Assert.That(english).Contains(Render.Words( + Messages.For(Locales.SourceTag, "claim.either.heading"))); + + foreach (var id in Sayable("claim.")) + { + var sentence = Render.Words(Messages.For(Locales.SourceTag, id)); + + if (!english.Contains(sentence, StringComparison.Ordinal)) + { + continue; + } + + await Assert.That(pseudo) + .DoesNotContain(sentence) + .Because($"{id} is rendered as English whatever language the page was asked for"); + } + } + + /// + /// A German request gets German wherever German exists, and the token survives it. + /// + /// + /// Gated on rather than on a list of ids: this page's own ids are + /// new and the satellites are translated in one round afterwards, so most of them fall back to + /// English today — the designed behaviour. The parts that must never move are asserted + /// directly, because every one of them is something an operator pastes into a config file. + /// + [Test] + public async Task AGermanRequestGetsGermanOnTheClaimPage() + { + var english = Render.Text(await ClaimWorld.Pending(Locales.SourceTag)); + var german = Render.Text(await ClaimWorld.Pending("de")); + + foreach (var id in Sayable("claim.").Where(i => Messages.HasOwn("de", i))) + { + var de = Render.Words(Messages.For("de", id)); + var en = Render.Words(Messages.For(Locales.SourceTag, id)); + + if (string.Equals(de, en, StringComparison.Ordinal) + || !english.Contains(en, StringComparison.Ordinal)) + { + continue; + } + + await Assert.That(german).Contains(de).Because($"{id} is not answered in German"); + } + + // A fallback shows the English and never the id. + foreach (var id in Sayable("claim.").Where(i => !Messages.HasOwn("de", i))) + { + await Assert.That(german).DoesNotContain(id).Because($"{id} reached a reader as its id"); + } + + // The machine voice, which is the half of this page that must be byte-identical in every + // language: the token itself, the MSSP variable and the connect-screen prefix are what a + // probe looks for, and a translated one is a claim that could never verify. + await Assert.That(german).Contains(ClaimWorld.Token); + await Assert.That(german).Contains(ClaimTokenBeacon.MsspVariable); + await Assert.That(german).Contains(ClaimTokenBeacon.ConnectScreenPrefix); + + // And the accepted spellings are read from the parser rather than retyped into the page. + foreach (var accepted in ClaimTokenBeacon.AcceptedMsspVariables) + { + await Assert.That(german).Contains(accepted); + } + } + + /// + /// The states a reader of the demo site can actually reach, in the language they asked for. + /// + /// + /// There is no database behind the fixture, so claiming is absent rather than present and + /// broken — and "absent" is itself a sentence, which has to be in the reader's language like + /// any other. Both branches, because a slug that names nothing and a slug that names a game we + /// cannot claim are different answers. + /// + [Test] + [Arguments(Locales.SourceTag)] + [Arguments("qps-ploc")] + [Arguments("de")] + public async Task TheDegradedClaimPageAnswersInTheReadersLanguage(string tag) + { + var missing = Render.Text(await ClaimWorld.NoGame(tag)); + + await Assert.That(missing).Contains(Render.Words(Messages.For(tag, "claim.noGame"))); + + var unclaimable = Render.Text(await ClaimWorld.NoDatabase(tag)); + + await Assert.That(unclaimable).Contains(Render.Words(Messages.For(tag, "claim.noDatabase"))); + + // The heading names the game, and the game's name is its own bytes in every language. + await Assert.That(unclaimable).Contains(Render.Words( + Messages.Say(tag, "claim.title", ("game", ClaimWorld.GameName)))); + } + + /// Ids under a prefix that can be rendered without arguments. + private static IEnumerable Sayable(string prefix) => + Messages.Ids + .Where(i => i.StartsWith(prefix, StringComparison.Ordinal)) + .Where(i => !IcuMessage.Compile(Messages.Pattern(Locales.SourceTag, i)!).Arguments().Any()); + + /// + /// The claim page, in one locale, in each state it can be reached in. + /// + /// + /// At component level with an cascaded in, for the reason the + /// dashboard's harness is: §8.2 makes passkeys the only way in, so a loopback host cannot + /// produce an authenticated session without an authenticator. The page's own guards still run — + /// which game, which account, and whether there is a claim — against a real + /// over in-memory stores. + /// + private static class ClaimWorld + { + public const string Slug = "ashen-court"; + + public const string GameName = "Ashen Court"; + + /// A token of the right shape. Machine voice, and identical in every language. + public const string Token = "muidx-t3kn2wxy7q4m8dgh6prs"; + + private static readonly DateTimeOffset Now = FixtureGameQueries.Now; + + private static readonly Guid GameId = Guid.Parse("aaaaaaaa-0000-0000-0000-000000000007"); + + /// A signed-in operator with a token outstanding: the page an owner works from. + public static Task Pending(string tag) => RenderAsync(tag, withGame: true, withClaims: true); + + /// A slug that names nothing. + public static Task NoGame(string tag) => RenderAsync(tag, withGame: false, withClaims: true); + + /// A real game on a site with no database behind it — the demo fixture's state. + public static Task NoDatabase(string tag) => + RenderAsync(tag, withGame: true, withClaims: false); + + private static Task RenderAsync(string tag, bool withGame, bool withClaims) + { + var user = new MuiUser { DisplayName = "corvid-admin", CreatedAt = Now }; + + var claim = new GameClaim + { + Id = Guid.CreateVersion7(), + GameId = GameId, + UserId = user.Id, + Token = Token, + IssuedAt = Now.AddDays(-2), + ExpiresAt = Now.AddDays(12), + }; + + return Render.ComponentAsync( + new Dictionary { ["Slug"] = Slug }, + services => + { + var games = new Games(withGame + ? new GameRecord(GameId, Slug, GameName, null, LifecycleState.Active, false, Now) + : null); + + var claims = new Claims([claim]); + + services.AddLogging(); + services.AddSingleton(new Frozen(Now)); + services.AddSingleton(new MUI.Web.Data.CatalogueSource(IsMeasured: withClaims)); + services.AddSingleton(); + services.AddSingleton(games); + + // Registered together, because that is how the site registers them: claiming + // exists only when a connection string does, and the page's "no database" + // branch is exactly the absence of this service. + if (withClaims) + { + services.AddSingleton(claims); + services.AddSingleton(new ClaimService(claims, games, new Frozen(Now))); + } + + services.AddSingleton>(new Accounts([user], Now)); + services.AddIdentityCore(); + + var context = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity( + [new System.Security.Claims.Claim( + ClaimTypes.NameIdentifier, user.Id.ToString())], + "Test")), + }; + + context.Items[LocaleRouting.ItemKey] = + new LocaleContext(Locales.Find(tag)!, FromPath: tag != Locales.SourceTag); + + services.AddCascadingValue(_ => context); + }); + } + + private sealed class Games(GameRecord? game) : IGameStore + { + public Task ByIdAsync(Guid id, CancellationToken ct = default) => + Task.FromResult(game?.Id == id ? game : null); + + public Task BySlugAsync(string slug, CancellationToken ct = default) => + Task.FromResult(game?.Slug == slug ? game : null); + + public Task InsertAsync(GameRecord g, CancellationToken ct = default) => + throw new NotSupportedException(); + + public Task ExcludeAsync(Guid id, string reason, DateTimeOffset at, CancellationToken ct = default) => + throw new NotSupportedException(); + + public Task IncludeAsync(Guid id, DateTimeOffset at, CancellationToken ct = default) => + throw new NotSupportedException(); + + public Task UnlistAsync(Guid id, Guid by, DateTimeOffset at, CancellationToken ct = default) => + throw new NotSupportedException(); + + public Task RelistAsync(Guid id, DateTimeOffset at, CancellationToken ct = default) => + throw new NotSupportedException(); + + public Task SetStateAsync(Guid id, LifecycleState s, DateTimeOffset at, CancellationToken ct = default) => + throw new NotSupportedException(); + + public Task MarkReachableAsync(Guid id, DateTimeOffset at, CancellationToken ct = default) => + throw new NotSupportedException(); + + public Task RenameAsync( + Guid id, + string name, + string slug, + DateTimeOffset at, + CancellationToken ct = default) => + throw new NotSupportedException(); + + public Task SetClaimedAsync(Guid id, bool isClaimed, CancellationToken ct = default) => + throw new NotSupportedException(); + + public Task CorroborateAsync( + Guid id, + DateTimeOffset at, + IReadOnlyList signals, + CancellationToken ct = default) => + throw new NotSupportedException(); + + public Task> UnarchivedAsync(CancellationToken ct = default) => + throw new NotSupportedException(); + } + + private sealed class Claims(List claims) : IClaimStore + { + public Task FindAsync(Guid id, CancellationToken ct = default) => + Task.FromResult(claims.FirstOrDefault(c => c.Id == id)); + + public Task> ForGameAsync(Guid game, CancellationToken ct = default) => + Task.FromResult>([.. claims.Where(c => c.GameId == game)]); + + public Task> ForUserAsync(Guid user, CancellationToken ct = default) => + Task.FromResult>([.. claims.Where(c => c.UserId == user)]); + + public Task FindPendingByTokenAsync(Guid g, string t, CancellationToken ct = default) => + Task.FromResult(null); + + public Task InsertAsync(GameClaim claim, CancellationToken ct = default) + { + claims.Add(claim); + return Task.CompletedTask; + } + + public Task UpdateAsync(GameClaim claim, CancellationToken ct = default) => Task.CompletedTask; + + public Task RecordEventAsync(ClaimEvent e, CancellationToken ct = default) => Task.CompletedTask; + + public Task> EventsAsync(Guid claim, CancellationToken ct = default) => + Task.FromResult>([]); + } + + private sealed class Frozen(DateTimeOffset at) : TimeProvider + { + public override DateTimeOffset GetUtcNow() => at; + } + + private sealed class NoAntiforgery : AntiforgeryStateProvider + { + public override AntiforgeryRequestToken? GetAntiforgeryToken() => null; + } + } } diff --git a/tests/MUI.Web.Tests/CrawlerStripTests.cs b/tests/MUI.Web.Tests/CrawlerStripTests.cs index 2d8a571..5337a44 100644 --- a/tests/MUI.Web.Tests/CrawlerStripTests.cs +++ b/tests/MUI.Web.Tests/CrawlerStripTests.cs @@ -1,3 +1,5 @@ +using MUI.Web.Localization; + using MUI.Catalog; using MUI.Web.Components; @@ -29,7 +31,8 @@ public async Task ARecentProbeReadsAsWorking() var pulse = Pulse(TimeSpan.FromSeconds(40)); await Assert.That(pulse.State(Now)).IsEqualTo(CrawlState.Working); - await Assert.That(CrawlerCopy.State(pulse, Now)).IsEqualTo("crawler working — last probe just now"); + await Assert.That(CrawlerCopy.State(Locales.SourceTag, pulse, Now)) + .IsEqualTo("crawler live · last probe " + Messages.For(Locales.SourceTag, "age.ago.now")); } /// @@ -46,10 +49,11 @@ public async Task ARecentProbeReadsAsWorking() public async Task AStalePulseIsQuietAndNamesNoCause() { var pulse = Pulse(TimeSpan.FromHours(4)); - var copy = CrawlerCopy.State(pulse, Now); + var copy = CrawlerCopy.State(Locales.SourceTag, pulse, Now); await Assert.That(pulse.State(Now)).IsEqualTo(CrawlState.Quiet); - await Assert.That(copy).IsEqualTo("crawler quiet — last probe 4h ago"); + await Assert.That(copy).IsEqualTo( + "crawler quiet · last probe " + Relative.Ago(Locales.SourceTag, TimeSpan.FromHours(4))); foreach (var diagnosis in new[] { "stopped", "down", "crashed", "stalled", "failed", "error", "offline" }) { @@ -65,10 +69,10 @@ public async Task TheStripNeverSaysUptimeAndNeverDescribesAGame() { var rendered = string.Join( "\n", - CrawlerCopy.State(Pulse(TimeSpan.FromSeconds(10)), Now), - CrawlerCopy.State(Pulse(TimeSpan.FromDays(2)), Now), - CrawlerCopy.LastCycle(Pulse(TimeSpan.Zero, Cycle(8, 6, 2))) ?? string.Empty, - CrawlerCopy.Registry(Pulse(TimeSpan.Zero))); + CrawlerCopy.State(Locales.SourceTag, Pulse(TimeSpan.FromSeconds(10)), Now), + CrawlerCopy.State(Locales.SourceTag, Pulse(TimeSpan.FromDays(2)), Now), + CrawlerCopy.LastCycle(Locales.SourceTag, Pulse(TimeSpan.Zero, Cycle(8, 6, 2))) ?? string.Empty, + CrawlerCopy.Registry(Locales.SourceTag, Pulse(TimeSpan.Zero))); foreach (var word in new[] { "uptime", "unreachable", "reachable" }) { @@ -85,10 +89,10 @@ public async Task TheStripNeverSaysUptimeAndNeverDescribesAGame() public async Task AnUnmeasuredPulseRendersNothing() { await Assert.That(CrawlerPulse.Unknown.State(Now)).IsEqualTo(CrawlState.NotYet); - await Assert.That(CrawlerCopy.LastCycle(CrawlerPulse.Unknown)).IsNull(); + await Assert.That(CrawlerCopy.LastCycle(Locales.SourceTag, CrawlerPulse.Unknown)).IsNull(); var html = await Render.PageAsync([]); - await Assert.That(html).DoesNotContain("crawler working"); + await Assert.That(html).DoesNotContain("crawler live"); await Assert.That(html).DoesNotContain("crawler quiet"); } @@ -103,11 +107,11 @@ public async Task AnUnmeasuredPulseRendersNothing() [Test] public async Task ACycleWithNothingDueSaysSoRatherThanPrintingZeroes() { - await Assert.That(CrawlerCopy.LastCycle(Pulse(TimeSpan.Zero, Cycle(0, 0, 0)))) - .IsEqualTo("last cycle: nothing was due"); + await Assert.That(CrawlerCopy.LastCycle(Locales.SourceTag, Pulse(TimeSpan.Zero, Cycle(0, 0, 0)))) + .IsEqualTo("nothing due this cycle"); - await Assert.That(CrawlerCopy.LastCycle(Pulse(TimeSpan.Zero, Cycle(8, 6, 2)))) - .IsEqualTo("last cycle: 8 due · 6 answered · 2 failed"); + await Assert.That(CrawlerCopy.LastCycle(Locales.SourceTag, Pulse(TimeSpan.Zero, Cycle(8, 6, 2)))) + .IsEqualTo("8 due · 6 answered · 2 failed"); } /// diff --git a/tests/MUI.Web.Tests/EcosystemSurfaceTests.cs b/tests/MUI.Web.Tests/EcosystemSurfaceTests.cs index 7790ef9..ab610f6 100644 --- a/tests/MUI.Web.Tests/EcosystemSurfaceTests.cs +++ b/tests/MUI.Web.Tests/EcosystemSurfaceTests.cs @@ -1,6 +1,7 @@ using MUI.Catalog; using MUI.Web.Components; using MUI.Web.Fixtures; +using MUI.Web.Localization; namespace MUI.Web.Tests; @@ -24,6 +25,18 @@ private static async Task EcosystemAsync() => private static async Task RankingsAsync() => PlainText.RenderRankings(await Queries.RankingsAsync(), Now); + /// + /// One message, as the source locale renders it. + /// + /// + /// These assertions are about what the two surfaces claim, not about the English they + /// happen to claim it in. Reading the claim out of the bundle keeps the guard exactly as strong + /// — a page that stopped saying it fails here — while leaving the wording free to be translated, + /// which is the whole reason it moved into the bundle. + /// + private static string Say(string id, params (string Key, object? Value)[] args) => + Messages.For("en", id, args.ToDictionary(a => a.Key, a => a.Value, StringComparer.Ordinal)); + [Test] public async Task NoAbsolutePlayerFigureIsEmittedByEitherSurface() { @@ -43,7 +56,7 @@ public async Task NoAbsolutePlayerFigureIsEmittedByEitherSurface() await Assert.That(text).DoesNotContain("across all games"); // And it says out loud that the omission is deliberate rather than an oversight. - await Assert.That(Render.Words(text)).Contains("Shares, never totals"); + await Assert.That(Render.Words(text)).Contains(Say("ecosystem.noTotals")); } [Test] @@ -73,9 +86,14 @@ public async Task BothDenominatorsAreNamedRatherThanImplied() // talkative and calling the difference adoption. var text = Render.Words(await EcosystemAsync()); - await Assert.That(text).Contains("whose handshake we completed"); - await Assert.That(text).Contains("whose MSSP report we hold"); - await Assert.That(text).Contains("Two sets of games, so two denominators"); + var dashboard = await Queries.EcosystemAsync(); + + await Assert.That(text).Contains(EcosystemCopy.Handshakes("en", dashboard.Handshakes)); + await Assert.That(text).Contains(EcosystemCopy.MsspReports("en", dashboard.MsspReports)); + await Assert.That(text).Contains(Say( + "ecosystem.plain.denominators", + ("measured", EcosystemCopy.Handshakes("en", dashboard.Handshakes)), + ("declared", EcosystemCopy.MsspReports("en", dashboard.MsspReports)))); } [Test] @@ -85,12 +103,15 @@ public async Task AProtocolWithNoMeasurementSaysSoRatherThanShowingNoughtPerCent // this, which is a fact about our reach; "0.0%" is a claim about everybody else's servers. var never = new ProtocolAdoption("TLS", Offered: null, Declined: 0, Handshakes: 400, Declared: 12, MsspReports: 300); - await Assert.That(EcosystemCopy.Measured(never)).Contains("not measured"); - await Assert.That(EcosystemCopy.Measured(never)).DoesNotContain("0.0%"); - await Assert.That(EcosystemCopy.Measured(never)).DoesNotContain("0 of 400"); + await Assert.That(EcosystemCopy.Measured("en", never)) + .IsEqualTo(Say("ecosystem.measured.never")); + await Assert.That(EcosystemCopy.Measured("en", never)).DoesNotContain("0.0%"); + await Assert.That(EcosystemCopy.Measured("en", never)).DoesNotContain("0 of 400"); // The declared side is unaffected and still carries its own set. - await Assert.That(EcosystemCopy.Declared(never)).IsEqualTo("12 of 300 (4.0%)"); + await Assert.That(EcosystemCopy.Declared("en", never)).IsEqualTo( + Say("ecosystem.share", ("count", 12), ("total", 300), ("fraction", 12d / 300))); + await Assert.That(EcosystemCopy.Declared("en", never)).IsEqualTo("12 of 300 (4.0%)"); } [Test] @@ -100,8 +121,9 @@ public async Task AnEmptyDenominatorIsNothingMeasuredAndNotNoughtPerCent() var nothing = new MeasuredShare("GMCP", 0, 0); await Assert.That(nothing.Fraction).IsNull(); - await Assert.That(EcosystemCopy.Share(nothing)).Contains("nothing measured yet"); - await Assert.That(EcosystemCopy.Share(nothing)).DoesNotContain("%"); + await Assert.That(EcosystemCopy.Share("en", nothing)) + .IsEqualTo(Say("ecosystem.share.nothing", ("count", 0), ("total", 0))); + await Assert.That(EcosystemCopy.Share("en", nothing)).DoesNotContain("%"); } [Test] @@ -112,8 +134,7 @@ public async Task TheMeasuredColumnIsSaidToBeAFloor() // saying so publishes our own instrumentation as a fact about somebody's game. var text = Render.Words(await EcosystemAsync()); - await Assert.That(text).Contains("may support a protocol without ever offering it"); - await Assert.That(text).Contains("as a floor"); + await Assert.That(text).Contains(Say("ecosystem.protocols.floor")); } [Test] @@ -124,8 +145,8 @@ public async Task TheDashboardCallsItselfASnapshotAndNotATrend() // crawl reaching more games and nothing about anybody adopting anything. var text = Render.Words(await EcosystemAsync()); - await Assert.That(text).Contains("A snapshot of what we can measure now"); - await Assert.That(text).Contains("nothing to plot"); + await Assert.That(text).Contains(Say("ecosystem.snapshot")); + await Assert.That(text).Contains(Say("ecosystem.transitions.none")); await Assert.That(text).DoesNotContain("growth"); } @@ -138,7 +159,10 @@ public async Task AGameWithNoCodebaseIsOutsideTheDenominatorAndSaidToBe() await Assert.That(dashboard.Codebases.NotIdentified).IsGreaterThan(0); await Assert.That(dashboard.Codebases.Families.Sum(f => f.Count)) .IsEqualTo(dashboard.Codebases.Identified); - await Assert.That(text).Contains("left out of the denominator, never counted as something else"); + await Assert.That(text).Contains(Say( + "ecosystem.codebases.basis", + ("listed", dashboard.Codebases.Identified + dashboard.Codebases.NotIdentified), + ("identified", dashboard.Codebases.Identified))); } [Test] @@ -155,8 +179,13 @@ public async Task TheCodebasePanelNamesTheListingBesideTheGamesThatAnswered() await Assert.That(listed).IsEqualTo(dashboard.ListedGames); await Assert.That(dashboard.Codebases.Identified).IsNotEqualTo(listed); - await Assert.That(text).Contains($"Of the {listed} games listed, {dashboard.Codebases.Identified} told us what they run"); - await Assert.That(text).Contains($"every share below is over those {dashboard.Codebases.Identified}"); + // Both numbers in one sentence, and each unmistakable for the other. + await Assert.That(text).Contains(Say( + "ecosystem.codebases.basis", + ("listed", listed), + ("identified", dashboard.Codebases.Identified))); + await Assert.That(text).Contains($"{listed} games listed"); + await Assert.That(text).Contains($"over those {dashboard.Codebases.Identified}"); } /// The one-game codebases are folded out of the chart and listed under it. @@ -177,7 +206,8 @@ public async Task ACodebaseOnlyOneGameRunsIsFoldedOutOfTheChartAndStillPrinted() await Assert.That(text).Contains( $"{codebases.SoleUseTotal.Count} of {codebases.Identified}"); - await Assert.That(text).Contains("no other listed game runs"); + await Assert.That(text).Contains( + Say("ecosystem.soleUse", ("share", EcosystemCopy.Share("en", codebases.SoleUseTotal)))); foreach (var alone in codebases.SoleUse) { @@ -201,7 +231,8 @@ public async Task TheMsspRowStaysBecauseItIsTheOnlyMeasurementThatIsNotAFloor() await Assert.That(mssp.Measured).IsNotNull(); await Assert.That(mssp.Declined).IsGreaterThan(0); await Assert.That(text).Contains("MSSP"); - await Assert.That(text).Contains("is the one row below that is not a floor"); + await Assert.That(text).Contains( + Say("ecosystem.mssp.instrument", ("instrument", EcosystemProtocols.Instrument))); } [Test] @@ -218,9 +249,9 @@ public async Task OnlyMsspHasNoDeclaredFigureAndTheReasonIsItsDenominator() var blank = dashboard.Protocols.Where(p => p.DeclaredShare is null).Select(p => p.Protocol); await Assert.That(blank).IsEquivalentTo(new[] { "MSSP" }); - await Assert.That(EcosystemCopy.Declared(dashboard.Protocols.Single(p => p.Protocol == "MSSP"))) + await Assert.That(EcosystemCopy.Declared("en", dashboard.Protocols.Single(p => p.Protocol == "MSSP"))) .DoesNotContain("%"); - await Assert.That(text).Contains("every report here is the answer"); + await Assert.That(text).Contains(Say("ecosystem.declared.none")); } [Test] @@ -236,13 +267,14 @@ public async Task TwoCountsOfMsspAreReconciledRatherThanLeftToSubtract() var withGap = new ProtocolAdoption("MSSP", Offered: 123, Declined: 295, Handshakes: 418, Declared: 1, MsspReports: 131); - await Assert.That(EcosystemCopy.MsspBasis(withGap, 131)).Contains("We hold 131 reports"); - await Assert.That(EcosystemCopy.MsspBasis(withGap, 131)).Contains("the other 8"); + await Assert.That(EcosystemCopy.MsspBasis("en", withGap, 131)).Contains( + Say("ecosystem.mssp.gap", ("reports", 131), ("offered", 123), ("gap", 8))); // And no gap means no sentence about one, rather than "the other 0". var level = withGap with { Offered = 131 }; - await Assert.That(EcosystemCopy.MsspBasis(level, 131)).DoesNotContain("the other"); + await Assert.That(EcosystemCopy.MsspBasis("en", level, 131)) + .IsEqualTo(Say("ecosystem.mssp.instrument", ("instrument", EcosystemProtocols.Instrument))); } [Test] @@ -250,9 +282,15 @@ public async Task TheRankingsStateWhatTheyRankOnAndOverWhatWindow() { var text = Render.Words(await RankingsAsync()); - await Assert.That(text).Contains("Median of the player counts we measured over the last 7 days"); - await Assert.That(text).Contains("counted samples a median needs"); - await Assert.That(text).Contains("A measured zero counts; an unreadable count does not"); + await Assert.That(text).Contains(Say("rankings.basis.median", ("days", 7))); + + // The threshold, as a sentence rather than as arithmetic. Where nothing qualifies the page + // said "0 of 519 games listed produced the 24 counted samples a median needs, on at least 4 + // days of the window", which is a subtraction the reader was left to do. + await Assert.That(text).Contains("24 samples across 4 days"); + + // Rule 4 on the surface that most invites a zero to be read as an absence. + await Assert.That(text).Contains(Say("rankings.basis.zero")); } [Test] @@ -261,7 +299,7 @@ public async Task TheRankingsOfferNoVoteAndClaimNoBest() // §2's permanent non-goal, said on the surface a reader would look for it on. var text = Render.Words(await RankingsAsync()); - await Assert.That(text).Contains("No votes, stars or ratings, ever"); + await Assert.That(text).Contains(Say("rankings.noVote")); await Assert.That(text.ToLowerInvariant()).DoesNotContain("rate this"); await Assert.That(text.ToLowerInvariant()).DoesNotContain("top rated"); } diff --git a/tests/MUI.Web.Tests/FacetSurfaceTests.cs b/tests/MUI.Web.Tests/FacetSurfaceTests.cs index 6cf11d6..1f6e01c 100644 --- a/tests/MUI.Web.Tests/FacetSurfaceTests.cs +++ b/tests/MUI.Web.Tests/FacetSurfaceTests.cs @@ -1,3 +1,4 @@ +using MUI.Web.Localization; using System.Reflection; using MUI.Catalog; @@ -39,6 +40,12 @@ .. typeof(FacetKeys) FacetKeys.Tls => "true", FacetKeys.Band => "quiet", FacetKeys.LastSeen => "week", + + // One word each, like the two bands above and unlike the open-ended facets: their + // vocabularies are a single value, so the binding refuses anything else rather than + // narrowing a listing to nothing on a typo. + FacetKeys.Uncounted => "yes", + FacetKeys.Unreachable => "yes", FacetKeys.Protocol => "GMCP", // Not the default. The point of the check below is that the parameter changed something, // and asking for the order the filter already arrives in changes nothing by construction. @@ -216,9 +223,16 @@ public async Task ThePanelIsAPlainGetFormWithAControlPerFacet() await Assert.That(html).Contains("action=\"/games\""); await Assert.That(html).DoesNotContain("
@PlainText.RenderSubmit(Answer, Catalogue.IsMeasured)
@PlainText.RenderSubmit(Answer, Catalogue.IsMeasured, Tag)
@SubmitCopy.Lede
@SubmitCopy.Lede(Tag)
@answer.Sentence
@link.Label
@SubmitCopy.NoCatalogue
@SubmitCopy.NoCatalogue(Tag)
plain text
@L("a11y.plainText")
@Series.Sentence
days in UTC · @Series.From.ToString("d MMM yyyy") – @Series.To.ToString("d MMM yyyy")
@Sentence
@L("trend.range", ("from", Series.From), ("to", Series.To))
Nothing counted in this range.
@L("trend.empty")
is + // what the server sent and is not a string in any bundle. "read as" rather than "encoded + // in", because that is the honest verb — it is what we decoded the bytes with, and a game + // that declares one encoding and sends another makes those two different sentences. + ["ansi.suppressed"] = "The owner asked us not to republish this game's connect screen.", + ["ansi.absent"] = "No connect screen has been captured from this game.", + ["ansi.tooSmall"] = "{count, plural, one {Only # row came back — too little to show.} other {Only # rows came back — too little to show.}}", + ["ansi.asSent"] = "as sent by the server", + ["ansi.size"] = "{columns}×{rows}", + ["ansi.size.doubleWidth"] = "{columns}×{rows}, double-width", + ["ansi.depth.colour"] = "16-colour SGR", + ["ansi.depth.plain"] = "no colour", + ["ansi.readAs"] = "read as {charset}", + ["ansi.captured"] = "captured", + ["ansi.frozen"] = "frozen — the last screen we saw", + ["ansi.alt.named"] = "ASCII art: the connect screen of {game}. Its text is under \"read as text\", below.", + ["ansi.alt"] = "ASCII art: this game's connect screen. Its text is under \"read as text\", below.", + ["ansi.plain.rows"] = "{count, plural, one {connect screen: # line, text only} other {connect screen: # lines, text only}}", + ["ansi.plain.rows.readAs"] = "{count, plural, one {connect screen: # line, text only, read as {charset}} other {connect screen: # lines, text only, read as {charset}}}", + + // ── the capability matrix's disagreements, said in prose ────────────────────────────── + ["capability.disagree.declaredNotOffered"] = "the game declares it, and the server has never offered it in a handshake.", + ["capability.disagree.offeredNotDeclared"] = "the server offers it, and the game's own record says it does not.", + ["capability.disagree.note"] = "Usually a stale hand-typed field, not a lie. Shown because a client should not rely on what the two disagree about.", + + // ── the rest of the game page ───────────────────────────────────────────────────────── + ["game.notFound"] = "Not found", + ["game.notFound.hint"] = "No game at this address. Check the spelling.", + ["game.endpoint.lastAnswered"] = "last answered", + ["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:", + ["game.referral.since"] = "since {date}", + ["game.referral.dropped"] = "no longer listed, last seen", + + // The archive plate. "Still probed" is the promise §7.5 makes and the reason the page is + // still here at all, so it is said rather than implied by the page continuing to exist. + ["game.archived.lastAnswered"] = "Last answered {date}, {ago} ago.", + ["game.archived.stillProbed"] = "Still probed weekly; this page updates the day it answers.", + ["game.archived.knownLive"] = "{span} known live", + + // The two ways out of the listing that are not archiving. They say different things because + // the decisions are different: an exclusion is OURS and has to be arguable, so it prints the + // argument; an unlisting is THEIRS, so it prints no reason at all. + ["game.excluded"] = "Not in the listing, the rankings or the daily figure: we do not think this address is a game somebody can play. Everything below is what it told us, unchanged.", + ["game.excluded.reason"] = "Our reason: {why}", + ["game.unlisted"] = "Not in the listing, the rankings or the daily figure, at the request of the people who run it. Everything below is preserved as it was, this page and every address it has ever had go on answering, and we are not dialling it.", + + // ── the plain surface's own headings for this page ──────────────────────────────────── + ["game.plain.playersNow"] = "Players now: {count}", + // The graphical caption pluralises this and the plain one did not, so one surface said "1 of + // 6 disagrees" and its own mirror said "1 of 6 disagree" about the same six rows. + ["game.plain.capabilities"] = "Capabilities ({disagreeing, plural, one {# of {total} disagrees} other {# of {total} disagree}})", + ["game.plain.declared"] = "Declared by the game", + ["game.plain.connectScreen"] = "Connect screen", + ["game.plain.whatChanged"] = "What changed", + ["game.plain.measured"] = "measured", + ["game.plain.declared.column"] = "declared", + ["game.plain.disagree"] = "** disagree", + + // ══ appended block: the listing row's own labels and the last three chips ══════════════ + // + // The two column heads, repeated per row for assistive technology only. The visible head is + // said once over the column and stays `aria-hidden`, because a reader who can see it does + // not need it five hundred times; a reader who cannot see it has no column at all, and a + // bare "12" and "2h ago" name neither the count nor the age. Two ids rather than splitting + // `listing.columns` on its separator: a locale is free to order or punctuate that head + // however it reads best, and a split would make the row's labels a function of its + // typography. + ["listing.row.connected"] = "connected", + ["listing.row.reached"] = "reached", + + // The chips the active-filter row builds itself rather than reading off a facet group. + // They are the last three that were still English on a translated page: two are questions + // the query asks that no facet answers, and "included" is the only chip value that is a + // state of ours rather than a token a game gave us. + ["facet.group.search"] = "search", + ["facet.group.archived"] = "archived", + ["facet.group.adult"] = "adult", + ["facet.value.included"] = "included", + + // ── added by the accessibility review, second pass ──────────────────────────────────── + // Kept in one block at the end because several agents are appending to this file at once. + + // The game page's live figure when there is no count to put in it — rule 2's third state on + // the hero rather than on a heatmap cell. + // + // `PlayersNow` is null for an hour nobody measured, for a probe that answered with nothing + // countable, and for a count older than the window this figure covers: one null over three + // situations the page cannot tell apart. It said `state.notCounted`, which the glossary + // reserves for an unreadable count — so a game we have never once reached was described as + // one we probed and failed to count, which is our silence published as their measurement. + // What is left is the absence itself, naming no cause. + ["game.count.none"] = "no count", + ["game.count.none.why"] = "no current count, and nothing here says why", + ["game.plain.playersNoCount"] = "Players now: no count (nothing here says why)", + + // ══ appended: the last surfaces that were still English on a localized page ═══════════ + // + // Three gaps, found after the sweep that walked visible text and title/aria-label/ + // placeholder — none of which reaches a element or a mirror. + // + // ── the crawler strip, whose sentence was English around a localized age ────────────── + // The age used to be the only localized part, so a German reader met "crawler live · last + // probe 4m" — one German fragment inside an English line, on the one strip whose whole job + // is to let a reader discount every number above it. The age comes in as an argument + // because the ladder that builds it already localizes, and because a language that puts + // the age first has nowhere to say so if the two are concatenated. + ["crawler.live"] = "crawler live · last probe {age}", + ["crawler.quiet"] = "crawler quiet · last probe {age}", + ["crawler.noProbe"] = "no probe has finished here yet", + + // Three counters, each agreeing with its own number. English inflects none of them and + // several languages inflect all three, which is exactly the case a concatenation cannot be + // translated out of. + ["crawler.cycle.nothingDue"] = "nothing due this cycle", + ["crawler.cycle"] = "{considered, plural, one {# due} other {# due}}" + + " · {answered, plural, one {# answered} other {# answered}}" + + " · {failed, plural, one {# failed} other {# failed}}", + ["crawler.registry"] = "{targets, plural, one {# address in the registry} other {# addresses in the registry}}" + + ", {due, plural, one {# due now} other {# due now}}", + + // ── what this site says about itself where it is not this site ──────────────────────── + // The , the meta description and the Open Graph tags. A German page advertised + // itself in English to a reader, a search engine and every link preview — the three places + // a reader has least ability to check what they were told, and the one surface the demo + // banner cannot follow a link into. + // + // The wordmark is not here. "mu*index" is the site's name, machine voice like a hostname or + // a codebase string, so it arrives as {site} rather than as text a translator could edit. + ["preview.documentTitle"] = "{page} — {site}", + ["preview.site"] = "A directory of the MU* hobby — MUSHes, MUDs, MUCKs, MOOs — where every " + + "fact carries how it was obtained and how old it is.", + ["preview.demo"] = "Demo data — nothing here was measured. {description}", + ["preview.cardAlt"] = "{site} — measured, not asserted", + ["preview.cardAlt.named"] = "{title} on {site}", + + // One id per page, title and description apart: a title is a noun phrase and a description + // is a sentence, and a language that declines the first differently from the second has + // nowhere to stand if they share an id. + ["preview.title.games"] = "Games", + ["preview.title.archive"] = "The archive", + ["preview.title.rankings"] = "Rankings", + ["preview.title.ecosystem"] = "The ecosystem", + ["preview.title.reference"] = "Reference", + ["preview.title.about"] = "About", + ["preview.title.notFound"] = "Not found", + ["preview.title.random"] = "Random game", + ["preview.title.account"] = "Your games", + ["preview.title.claim"] = "Claim {game}", + + ["preview.desc.games"] = "Every MU* we have reached, faceted on what we measured: codebase, " + + "the protocols a server offered in the handshake, TLS, charset, language, and when we " + + "last got in.", + ["preview.desc.archive"] = "The games that went dark, kept. Each keeps its page, history and " + + "URL, is still probed weekly, and returns to the listing on one successful connection.", + ["preview.desc.rankings"] = "Busiest, most reachable, longest running — computed from " + + "measurements only. No votes, stars or ratings anywhere on this site.", + ["preview.desc.ecosystem"] = "Codebase share and protocol adoption across the games we " + + "measure, with what servers offer set beside what they declare. Shares, never totals.", + ["preview.desc.reference"] = "Hand-written pages on the codebases, clients and protocols of " + + "the MU* hobby, cross-linked to counts taken from the crawl.", + ["preview.desc.about"] = "How this catalogue is built: what the crawler does, what it refuses " + + "to do, and how to make it stop.", + ["preview.desc.notFound"] = "No game at this address. Nothing here is ever deleted, so a game " + + "that once lived at this URL still does — check the spelling.", + ["preview.desc.random"] = "One game from the catalogue, chosen at random and never the same " + + "one twice.", + ["preview.desc.account"] = "The listings you have claimed, and what a claim lets you change.", + ["preview.desc.claim"] = "Prove you run this game by publishing a token where only its " + + "operator could put it.", + + // A game's own preview. The name, the host and the port stay machine voice; everything the + // site says *about* them is here. The unknown count is a sentence and never a zero (rule 4), + // and the archived plate says "still probed" because that is §7.5's promise. + ["preview.game.archived"] = "Archived — last reachable {age}, and still probed", + ["preview.game.archived.undated"] = "Archived, and still probed", + ["preview.game.countUnknown"] = "Player count unknown — the game answers, and publishes no " + + "number we can read", + ["preview.game.count"] = "{count, plural, one {# player} other {# players}}, {how} {age}", + + // ── the plain mirror's feed headings and home counts ────────────────────────────────── + // Uppercased at the call site like every other plain heading, so the id carries the words + // and not the casing — a locale whose script has no case gets the words unharmed. + // The empty states are not here: feed.nothingNew and its two siblings already exist and are + // already translated, and the graphical cards and this mirror say the same sentence. + ["feed.plain.newlyDiscovered"] = "Newly discovered", + ["feed.plain.wentDark"] = "Went dark", + ["feed.plain.cameBack"] = "Came back", + + // The four figures the front page tiles carry, as whole sentences rather than a number + // glued to a tile label — the mirror has no tiles to put a label beside. English inflects + // only the first; the other three carry both branches anyway, because a language that + // inflects them has nowhere else to say so. + ["home.plain.known"] = "{count, plural, one {# game known} other {# games known}}", + ["home.plain.connectedNow"] = "{count, plural, one {# connected now (measured)} other {# connected now (measured)}}", + ["home.plain.uncounted"] = "{count, plural, one {# answering, uncounted} other {# answering, uncounted}}", + ["home.plain.archived"] = "{count, plural, one {# archived, still probed} other {# archived, still probed}}", + + // ── THE REFERENCE SECTION'S CHROME ──────────────────────────────────────────────────── + // ═════════════════════════════════════════════════════════════════════════════════════ + // + // Appended as one marked block on purpose: another surface is appending to this file at the + // same time, and a block at the end is a merge that adds rather than one that collides. + // + // **The articles are not here.** The Markdown under content/reference is a set of documents + // somebody owns, translated as documents and by their own route; a bundle holds strings the + // site says, and an article is not one. What is here is the furniture round them — and it + // says nothing about what language an article is in, because that is the content layer's + // fact to state and it is changing. + // + // **Every acronym stays out of the bundle.** MSSP, GMCP, TTYPE, CHARSET and the codebase and + // client names arrive as arguments or sit in the markup. This is the surface most *about* + // machine voice and so the one where a locale is likeliest to reach for a word — and a + // translated TTYPE is destroyed evidence rather than a localized string. + ["reference.title"] = "Reference", + ["reference.lede"] = "What the codebases are, what the clients do, and what the protocols " + + "mean. Written by hand and kept in the repository beside the crawler — not a wiki, and " + + "there is nothing on this page to edit. Every {number} here is a different thing: it " + + "comes from the catalogue and is recomputed each time you load the page.", + + // The emphasised word, placed by the message rather than wrapped round a fragment of it: a + // language that stresses a different word in that clause has somewhere to move the emphasis + // to, and the bundle still holds no markup. Sentences.Place walks it. + ["reference.lede.number"] = "number", + ["reference.plain.lede"] = "Hand-written, single-author, and versioned in git. The prose here " + + "is ours; every number beside it was measured by the crawler and is recomputed on each " + + "request. This is not a wiki, and there is no way to edit it from this page.", + + // The four section headings, and the four kind words beside a page's title. One English word + // does both jobs for three of them — a heading over a list, and a label naming what one page + // is — and inflected languages routinely need different forms, so they are eight ids. + ["reference.section.orientation"] = "Start here", + ["reference.section.codebase"] = "Codebases", + ["reference.section.client"] = "Clients", + ["reference.section.protocol"] = "Protocols", + ["reference.kind.orientation"] = "orientation", + ["reference.kind.codebase"] = "codebase", + ["reference.kind.client"] = "client", + ["reference.kind.protocol"] = "protocol", + + // A gap in a hand-written section is work nobody has done, and the page says so rather than + // implying something was taken away — §7.5 in the one place a reader could reasonably read a + // deletion into a 404. + ["reference.notFound.title"] = "Not found", + ["reference.notFound.body"] = "No reference page here. This section is hand-written, so a gap " + + "is work nobody has done rather than something that was removed — {index}.", + ["reference.notFound.index"] = "see what there is", + + // ── a codebase page's measured half ─────────────────────────────────────────────────── + // The zero is a sentence and never a bare 0 (rule 4): none identified is a statement about + // this crawler's reach, and reads as a statement about the codebase unless it is spelled out. + ["reference.codebase.heading"] = "Games running it", + ["reference.codebase.none"] = "We have not identified any yet. That is a fact about what this " + + "crawler has measured, not about what exists — a game we have not reached, or whose " + + "codebase we could not read, is not counted here.", + ["reference.codebase.listed"] = "{count, plural, one {# listed} other {# listed}}", + ["reference.codebase.archived"] = "{count, plural, one {# archived} other {# archived}}", + + // Rule 1, in three words, and its own id in every register it appears in. It must not soften + // into "verified" or "from our data": what it says is that nothing on this line was taken + // from anybody's self-description. + ["reference.measuredNeverAsserted"] = "measured, never asserted", + ["reference.codebase.note"] = "Counted from the catalogue on this request, over the same " + + "filter the link above carries — so this number and that listing are one query and " + + "cannot drift apart.", + + // The protocol list is machine voice and arrives whole, joined by the markup. A locale + // orders the sentence round it and never touches what is inside. + ["reference.codebase.offered"] = "offered in their handshakes: {protocols}", + + // ── a protocol page's measured half ─────────────────────────────────────────────────── + ["reference.protocol.heading"] = "Measured adoption", + ["reference.protocol.none"] = "Nothing measured yet.", + ["reference.protocol.share"] = "of {listed, plural, one {# listed game} other {# listed games}} — {percent}", + + // **The remainder is not a measurement and the page has to say so.** A locale that shortened + // this to "the rest do not support it" would file our own unread handshakes as a fact about + // somebody's game, which is rule 5 exactly. The sentence names both halves of what the + // remainder mixes, and a translation has to keep them two. + ["reference.protocol.remainder"] = "The games not counted here are not games without the " + + "protocol. A game is counted when we observed its server offering the option in a " + + "handshake; the rest are servers that did not offer it to us and servers whose " + + "handshake we have not read, and we cannot tell you which.", + ["reference.protocol.caption"] = "Games observed offering {protocol} in a handshake, by the " + + "codebase we identified them as running.", + ["reference.protocol.column.codebase"] = "codebase", + ["reference.protocol.column.offered"] = "offered it", + ["reference.protocol.column.identified"] = "identified", + ["reference.seeAlso"] = "See also", + + // ── the client capability matrix, which is documentation rather than measurement ─────── + // Every cell here is somebody's documentation read by us, and the table is a sibling of the + // game pages' measured matrix rather than a copy of it. The caveat carries that difference + // and takes the unknown word as an argument, so the sentence and the cells cannot disagree + // about which word they are quoting. + ["reference.capabilities.heading"] = "Capabilities", + ["reference.capabilities.established"] = "{count, plural, one {# of {total} established from the project's own documentation} other {# of {total} established from the project's own documentation}}", + ["reference.capabilities.caveat"] = "Read off each project's own documentation, not measured " + + "by us — a client has no handshake for us to observe. \"{unknown}\" means we looked and " + + "did not establish it. It never means no.", + ["reference.capabilities.caption"] = "Client capabilities, each read off the project's own " + + "documentation. Unknown means we did not establish it, and never that the client lacks " + + "it.", + ["reference.capabilities.column.documented"] = "documented", + ["reference.capabilities.column.source"] = "source", + ["reference.capabilities.noSource"] = "we did not find one", + + // Three words for three states, and the third is the one that matters. An unknown is what we + // looked for and did not establish; a locale that rendered it as the no beside it would turn + // our reading into the project's absence. They are ids of their own rather than the game + // pages' capability words, which answer a different question — offered on a wire. + ["reference.capability.yes"] = "yes", + ["reference.capability.no"] = "no", + ["reference.capability.unknown"] = "unknown", + + // ── the plain mirror's own wording ──────────────────────────────────────────────────── + // Where the plain surface says the same sentence as the page it mirrors it shares the id + // above; these are the lines it words differently because it has no panel round them. + ["reference.plain.runsOn"] = "Runs on: {platforms}", + ["reference.plain.codebase.heading"] = "Games we have identified as running this codebase", + ["reference.plain.codebase.none"] = "None yet. That is a statement about what we have " + + "measured, not about what exists — a game we have not reached, or whose codebase we " + + "could not read, is not counted here.", + ["reference.plain.codebase.counts"] = "{listed, plural, one {# listed} other {# listed}}, {archived, plural, one {# archived} other {# archived}}", + ["reference.plain.codebase.offered"] = "Measured in their handshakes: {protocols}", + ["reference.plain.codebase.nothingOffered"] = "Nothing was offered in any handshake we have " + + "read from them.", + ["reference.plain.protocol.share"] = "{offering} of {listed, plural, one {# listed game} other {# listed games}} were observed offering it ({percent})", + ["reference.plain.protocol.byCodebase"] = "By codebase, of the games we identified", + ["reference.plain.protocol.row"] = "{offering} of {identified} offered it", + ["reference.plain.capabilities.unknown"] = "{count, plural, one {# of {total} rows is unknown} other {# of {total} rows are unknown}}: we did not find the project's own documentation saying either way. A short honest table beats a long guessed one.", + + // ══ APPENDED BLOCK: the owner dashboard and the claim flow ════════════════════════════ + // The last two page surfaces that were still English whatever language they were asked + // for. Kept as one block at the end of the dictionary so that a parallel append merges + // additively rather than interleaving. + // + // These surfaces address a game's operator in the second person — "your games", "a game + // you run" — and the English says "you" deliberately. A translator should render them in + // whatever second person their language uses for one person being addressed directly; the + // catalogue surfaces are impersonal and these are not. Do not neutralise them. + // + // Nothing here may blur the two provenances. An owner's answer is a DECLARATION stored + // beside what the crawler measured; a claim is a fact about our records and never a + // measurement of the game; an opt-out is honoured and is never a deletion. Where a + // sentence carries that distinction it is called out on the id. + + // ── the dashboard's frame ───────────────────────────────────────────────────────────── + // account.title is its own id rather than preview.title.account's: a heading is the first + // line of a document and a title is a noun phrase in a browser tab, and the languages that + // decline the two differently have nowhere to stand if they share one. + ["account.title"] = "Your games", + ["account.noDatabase"] = "Accounts need a database behind them, and this site is running on " + + "the demo fixture.", + ["account.signInButton"] = "Sign in", + ["account.signedInAs"] = "Signed in as {name}.", + ["account.signOut"] = "Sign out", + + // The empty state's sentence places its own link and its own quoted control, so the word + // order belongs to the language rather than to the markup. {claimControl} names the button + // on a game's page; it is a separate id from game.claim because that one is the control's + // own label and this one is prose quoting it — a language that capitalises or declines a + // quoted control differently has nowhere else to say so. + ["account.empty.body"] = "You have not claimed anything yet. Find your game in {listing} " + + "and press {claimControl} on its page.", + ["account.empty.listing"] = "the listing", + ["account.empty.claimControl"] = "claim this game", + + // ── the one banner a POST comes back with ───────────────────────────────────────────── + // Resigning is not deleting: §7.5 keeps the record, and §8.4 lets the same person prove + // control again. A translation that renders this as "removed" or "deleted" contradicts the + // rule the sentence exists to state. + ["account.resigned.lead"] = "Given up.", + ["account.resigned.body"] = "The record of it is kept, and you can prove control again any " + + "time by publishing a fresh token.", + ["account.saved.lead"] = "Saved.", + + // One sentence per write. The game's name is its own bytes and arrives as an argument, so + // a language that puts the subject elsewhere can move it; {game} is never translated. + // These say what WE did — stopped republishing, stopped dialling, took out of the listing — + // and never that anything about the game was measured or removed. + ["account.saved.thatGame"] = "That game", + ["account.saved.fields"] = "{game}'s page now shows it as owner-declared.", + ["account.saved.screenHidden"] = "We have stopped republishing {game}'s connect screen. The " + + "page says so plainly rather than leaving a hole.", + ["account.saved.screenShown"] = "{game}'s connect screen is on its page again.", + ["account.saved.crawlStopped"] = "We have stopped dialling {game}, on every address we have " + + "for it. Its page keeps everything measured before you asked.", + ["account.saved.crawlResumed"] = "We are dialling {game} again, from its next turn in the " + + "schedule.", + ["account.saved.unlisted"] = "{game} is out of the listing, the rankings and the daily " + + "figure. Its page and every URL it has ever had go on answering.", + ["account.saved.relisted"] = "{game} is back in the listing. One probe that answers is all " + + "it needs to be measured again.", + + // Refused out loud (§8.5). {field} is a registry field name — machine voice, an argument. + // The second sentence is the site's whole claim and may not soften: nobody edits a + // measurement, and that includes us. + ["account.refused.lead"] = "{field} was not changed.", + ["account.refused.tooLong"] = "These are one-line answers; {max} characters is the most we " + + "store.", + ["account.refused.measured"] = "That field is measured. A claim lets you add what MSSP has " + + "no room for; it never lets anybody edit what we observed, and that includes us.", + + // ── a claimed game's block ──────────────────────────────────────────────────────────── + // "verified {date}" is a fact about OUR record — the date we read this owner's token — and + // not a measurement of the game. Two ids rather than a suffix glued on, because the beacon + // note lands in a different place in most languages. + ["account.claimed.heading"] = "Claimed", + ["account.claim.verified"] = "verified {date}", + ["account.claim.verifiedAndSeen"] = "verified {date}, token last seen {seen}", + ["account.claim.mssp"] = "check your MSSP", + ["account.claim.coOwners"] = "{count, plural," + + " one {Also owned by {names} — who verified a token of their own.}" + + " other {Also owned by {names} — each having verified a token of their own.}}", + ["account.coOwner.unnamed"] = "another account", + + // The badge snippet. {unknown} and {archived} are the badge's own bytes rather than words + // to translate: a badge answers one address to everybody, so a German page promising a + // German word would be promising something the image never says. {json} is an acronym and + // machine voice for the same reason every protocol name here is. + ["account.badge.summary"] = "put your player count on your own site", + ["account.badge.carries"] = "The badge carries the count and when we measured it, because a " + + "number with no age is the thing this site exists to replace.", + ["account.badge.states"] = "It says {unknown} rather than nought when we could not count, " + + "and {archived} if the game stops answering.", + ["account.badge.json"] = "There is {json} too, if you would rather draw your own.", + + // ── the audit log ───────────────────────────────────────────────────────────────────── + // The vocabulary is ClaimEventKind's, spelled for a person. beaconMissing reads as an + // observation and never as a warning: a probe not reading the token happens for reasons + // that have nothing to do with the owner, and absence never revokes (§8.4). + ["account.history.summary"] = "history", + ["account.event.issued"] = "token issued", + ["account.event.reissued"] = "token issued again", + ["account.event.verified"] = "verified — we read your token", + ["account.event.beaconSeen"] = "token still published", + ["account.event.beaconMissing"] = "token not read this time", + ["account.event.revoked"] = "claim given up", + ["account.event.expired"] = "token expired unused", + ["account.event.counterClaimed"] = "another account proved control and took the game over", + ["account.event.checkRequested"] = "check requested", + + // Giving up a claim. {word} is the literal an operator types into the box and is never + // translated — a translated confirmation word would be one the form does not accept. + ["account.resign.summary"] = "give up this claim", + ["account.resign.confirm"] = "Type {word} to confirm. Nothing is deleted and you can prove " + + "control again by publishing a fresh token; the game stays claimed if anybody else " + + "owns it.", + ["account.resign.button"] = "Give up {game}", + + // ── waiting on a token, and the passkeys ────────────────────────────────────────────── + ["account.pending.heading"] = "Waiting on a token", + ["account.pending.dates"] = "token issued {issued}, good until {expires}", + ["account.passkeys.heading"] = "Passkeys", + ["account.passkey.unnamed"] = "unnamed", + ["account.passkey.added"] = "added {date}", + ["account.passkey.addedOneDevice"] = "added {date} · on one device only", + ["account.passkey.single"] = "This passkey lives on one device. If you lose it you can " + + "still get back in by publishing a fresh token on your game, but a second passkey is " + + "quicker.", + ["account.passkey.add"] = "Add another passkey", + + // ── claiming a game you run ─────────────────────────────────────────────────────────── + // claim.title is its own id rather than preview.title.claim's, for the reason account.title + // is: a heading and a browser-tab title are not the same noun phrase. + ["claim.noGame"] = "No such game", + ["claim.title"] = "Claim {game}", + ["claim.noDatabase"] = "Claiming needs a database behind it, and this site is running on " + + "the demo fixture.", + ["claim.needAccount"] = "You need an account first. It takes a passkey and a name.", + ["claim.signIn"] = "Sign in or create an account", + ["claim.yourGames"] = "Your games", + + // The game already has owners, and nothing in a probe can tell joining from taking over — + // both publish the identical line. So the choice is made here, in words, before the token + // exists. The owner count agrees inside the message rather than being chosen in C#. + ["claim.hasOwners"] = "{count, plural," + + " one {This game already has an owner who proved control of the server.}" + + " other {This game already has # owners who proved control of the server.}}" + + " You can prove it too — the test is the same either way — but we need to know what " + + "you mean by it, because we cannot tell from the token.", + ["claim.join.button"] = "I run it too — add me as an owner", + ["claim.join.note"] = "Everyone keeps their claim. This is two people running one game.", + ["claim.assume.button"] = "I have taken it over — transfer it to me", + ["claim.assume.note"] = "{count, plural," + + " one {When your token verifies, the existing claim is revoked and the game is yours.}" + + " other {When your token verifies, the existing claims are revoked and the game is yours.}}" + + " They will see why in their own history. Nothing is deleted, and they can prove " + + "control again the same way you are about to.", + + // Verified. Two ids and not one with the channel slotted in: "from the game's MSSP report" + // and "from the connect screen" take different prepositions and different cases in the + // languages that have them, and a single sentence with a {channel} hole has nowhere to + // say so. Both state what WE read, which is a fact about our records. + ["claim.verified.lead"] = "Verified.", + ["claim.verified.viaMssp"] = "We read your token from the game's MSSP report on {date}.", + ["claim.verified.viaScreen"] = "We read your token from the connect screen on {date}.", + ["claim.verified.leaveIt"] = "Leave the token where it is. It doubles as an identity " + + "signal, so this game stays recognisable if it moves host or changes name. Removing " + + "it will not un-claim you.", + + // Publishing the token. Every variable name, file name and prefix below is machine voice + // and arrives as an argument: a translated MSSP variable is one no crawler reads. + ["claim.publish"] = "Publish this token anywhere the game shows it to an anonymous " + + "connection. The next probe picks it up, which proves you can write to that server.", + ["claim.transfer.lead"] = "This is a transfer.", + ["claim.transfer.body"] = "{count, plural," + + " one {When we read this token, the current owner's claim on this game is revoked and it becomes yours.}" + + " other {When we read this token, the current owners' claims on this game are revoked and it becomes yours.}}", + ["claim.either.heading"] = "Either of these will do", + ["claim.mssp.heading"] = "An MSSP variable", + ["claim.mssp.note"] = "In {codebase} that is a line in {file}; every codebase with MSSP has " + + "an equivalent.", + ["claim.mssp.aliases"] = "{aliases} are accepted too.", + ["claim.screen.heading"] = "A line on the connect screen", + ["claim.screen.note"] = "Anywhere in the screen, and colour codes around it are fine.", + ["claim.then.heading"] = "Then", + ["claim.then.body"] = "We check on the ordinary crawl schedule. This token is good until " + + "{date}. Come back any time; nothing needs writing down.", + + // Asking us to look sooner moves the game to the front of the queue; the crawler still + // does the dialling on its own schedule and under CRAWL DELAY. Neither sentence may + // promise a probe, because pressing a button here is our decision and not a measurement. + ["claim.check.button"] = "Look sooner", + ["claim.check.can"] = "Brings your game to the front of the queue. We dial on our own " + + "schedule, so this is sooner rather than now.", + ["claim.check.rationed"] = "Just asked. Try again in a few minutes — it is rationed because " + + "it dials a real server sooner than we would have.", + + // ── what a claim actually grants (the owner panel, §8.5 and §11) ────────────────────── + // Everything on this panel is enrichment. An owner's answer is DECLARED: stored under its + // own field source beside whatever the crawler measured, shown with its age, and it never + // replaces, hides or silences a measurement. A translation that lets "you told us" read as + // "we measured" breaks the one rule the whole site rests on. + ["owner.declare.heading"] = "What only you can tell us about {game}", + ["owner.declare.lede"] = "These are the things MSSP has no field for. They appear on your " + + "game's page as {declared}, with the date you last confirmed them, beside what we " + + "measured — never instead of it. Nothing measured can be edited from here, by you or " + + "by us.", + ["owner.field.declared"] = "declared {age}. Empty this box to withdraw it — the record of " + + "what it said is kept either way.", + ["owner.save"] = "Save what you declared", + + ["owner.override.heading"] = "What {game} reports, and what you would rather we showed", + ["owner.override.lede"] = "Your MSSP is what every crawler reads, and we go on showing it " + + "beside anything you put here — an answer of yours does not hide one of your game's.", + ["owner.override.nothingMeasured"] = "Nothing measured can be edited from here: not a " + + "player count, not a capability, not an hour of reachability.", + ["owner.override.fixItThere"] = "If a line below is wrong in your {file}, fixing it there " + + "fixes it everywhere.", + ["owner.report.value"] = "your game reports {value}, confirmed {age}", + ["owner.report.none"] = "your game reports nothing here", + ["owner.rename.note"] = "Changing the name changes what {game} is listed as and the " + + "address of its page. The old address goes on working for ever — every URL this game " + + "has ever had redirects to its current one — and clearing the box hands the name back " + + "to whatever your MSSP says.", + + ["owner.screen.heading"] = "Your connect screen", + ["owner.screen.suppressed"] = "We are not republishing it. The page says so plainly rather " + + "than leaving a hole, and the crawler goes on reading it — it is how we recognise " + + "your game if it moves.", + ["owner.screen.show"] = "Show it again", + ["owner.screen.shown"] = "We show it because your server sends it to every anonymous " + + "connection. If you would rather we did not, say so and we stop. We will not ask why.", + ["owner.screen.stop"] = "Stop showing our connect screen", + + // §11. An opt-out is honoured, never a deletion — and the empty hours it leaves may not be + // given a cause, because "you asked us to stop" is OUR fact and not a measurement of the + // game (rule 5, rule 2). {ourFact} is placed by the message so a language can put the + // emphasised clause where it belongs. + ["owner.crawl.heading"] = "Being crawled", + ["owner.crawl.stopped"] = "We have stopped. Nothing on {game} is dialled, and the page " + + "keeps everything measured before you asked — the empty hours name no cause, because " + + "{ourFact} is our fact and not a measurement of your game.", + ["owner.crawl.ourFact"] = "you asked us to stop", + ["owner.crawl.resume"] = "Start crawling us again", + ["owner.crawl.standing"] = "This one came from your own server rather than from here — " + + "{routes}. To be crawled again, stop publishing it; we will hear that on the next " + + "cycle.", + ["owner.crawl.route.mssp"] = "your MSSP report publishes {variable}", + ["owner.crawl.route.dns"] = "a {label} TXT record asks us to stop", + ["owner.crawl.route.recorded"] = "a request we recorded", + // Neither state, and shown as neither: rounding this to "stopped" would tell an owner we + // had left them alone while we went on dialling the port that is still open. + ["owner.crawl.partial"] = "We have stopped on {stopped} and are still dialling {dialling}. " + + "That is usually a port added after the opt-out.", + ["owner.crawl.stopAll"] = "Stop on every address too", + ["owner.crawl.dialling"] = "We dial {game} on a schedule and read what any anonymous " + + "connection is shown. If you would rather we did not, say so and we stop — within " + + "one cycle, on every address we have for you, and we will not ask why.", + ["owner.crawl.selfService"] = "Nothing already measured is deleted: your page keeps its " + + "history and its URL, and one probe after you take this back starts it again. You " + + "can also say it without us, in your own config — {mssp} in MSSP, or a {dns} TXT " + + "record — and we honour those whether or not anybody has ever claimed the game here.", + ["owner.crawl.stop"] = "Stop crawling us", + + // Migration 0025's second decision, and a second one rather than a stronger version of the + // first. Nothing is deleted here either: the page answers, every URL it ever had still + // redirects to it, and it stops being somewhere a reader arrives by browsing. + ["owner.listing.heading"] = "Being listed", + ["owner.listing.unlisted"] = "{game} is out of the listing, out of the rankings and out of " + + "the daily figure. Its page and every URL it has ever had go on answering, and " + + "everything measured before you asked is still on it. Nothing was deleted; it is " + + "simply not somewhere a reader arrives by browsing.", + ["owner.listing.relist"] = "Put us back in the listing", + ["owner.listing.probeRelists"] = "One probe that answers does this too. While your opt-out " + + "stands we do not dial, so nothing will — but the day you take it back, the address " + + "comes up within a week and the probe that gets an answer puts you back. You do not " + + "have to ask us twice.", + ["owner.listing.mayUnlist"] = "We have stopped dialling you, and your page is still in the " + + "listing with what we measured before that. If you would rather it were not, say so " + + "and it comes out — of the listing, the rankings and the daily figure.", + ["owner.listing.reversible"] = "Nothing is deleted and nothing breaks: the page answers, " + + "every URL it has ever had still redirects to it, and anyone you send there sees it. " + + "It stops being somewhere a reader can arrive by browsing. Reversible from here, and " + + "by any probe that answers after you take your opt-out back.", + ["owner.listing.unlist"] = "Take us out of the listing too", + // ══ END APPENDED BLOCK ════════════════════════════════════════════════════════════════ + + // ══ WHAT WE COULD MEASURE ═════════════════════════════════════════════════════════════ + // The handoff's last panel group: two independent switches over the two reasons a listing + // row carries no number. Every string here describes *our* reach and none of them describes + // a game, which is rule 5 — the note exists because the gesture these controls offer is the + // one most likely to be read as a claim about the games it removes. + ["facet.group.measure"] = "what we could measure", + + // The design's own sentence, with its first clause made true of the control that shipped: + // the panel's rows are tri-state, so hiding is the "−" rather than an untick. The second + // clause is the load-bearing one and is carried verbatim. + ["facet.measure.note"] = "Hiding these takes them out of your listing; it does not mean the " + + "game is empty.", + + // Named for what we did and never for what the game is. "could not count" is a fact about + // our parsers meeting a dialect; "no players" would be that same fact filed in somebody + // else's public record. + ["facet.group.uncounted"] = "could not count", + ["facet.group.unreachable"] = "could not reach", + + // What the chip says when a reader has dropped one of the two. Deliberately not "not + // uncounted": a double negative reads as an assertion about the games, and this is an + // assertion about the listing. Two ids carrying one English sentence, because they are two + // different facts and a language that inflects will not spell them the same way. + ["facet.excluded.uncounted"] = "hidden from this listing", + ["facet.excluded.unreachable"] = "hidden from this listing", + + // The plain surface's own key to its left column. It drew "only these" and "anything but + // these" with one star until these two switches arrived, whose ordinary gesture is the + // second — so the surface with the least else to go on was the one that could not show the + // third state at all. + ["facet.plain.marks"] = "In the left column, * is a value this listing is filtered to and - " + + "is one it is filtered against. Both are choices in the query, not facts about a game.", + // ══ END WHAT WE COULD MEASURE ═════════════════════════════════════════════════════════ + }; + + /// + /// The bundles, by tag. + /// + /// + /// + /// Only English is complete, and that is the honest state of this work rather than a gap in + /// it. The order of work is explicit: the glossary is written and human-translated before a + /// reader is sent anywhere, and no locale is offered until it is. What ships here is the + /// machinery, exercised end to end by the two test-only bundles below. + /// + /// + /// qps-ploc is a pseudolocale — accented and expanded English. It is not a language and + /// nobody claims it is one; it exists so routing, fallback, plural selection and the nav's 1.4x + /// width budget are all exercised by something real. ru-x-canary is machine-translated, + /// never shipped, and deliberately incomplete: it is what makes a missing plural form fail a + /// build instead of reaching a reader. + /// + /// + private static readonly Dictionary> TestBundles = + new(StringComparer.OrdinalIgnoreCase) + { + // Every message, mechanically transformed. Generated rather than typed so it cannot + // fall behind the source bundle it is derived from. + ["qps-ploc"] = English.ToDictionary(e => e.Key, e => Pseudo(e.Value), StringComparer.Ordinal), + + // Three plural categories, and one message that is missing its `few` and `many` branches + // on purpose. That omission is what the completeness test turns into a build failure. + ["ru-x-canary"] = new(StringComparer.Ordinal) + { + ["facet.count"] = "{count, plural, one {# игра} few {# игры} many {# игр} other {# игры}}", + ["listing.total"] = "{count, plural, one {# игра} other {# игр}}, каждый факт измерен.", + ["provenance.count.measured"] = "измерена", + ["provenance.game.measured"] = "измерено", + ["provenance.capability.measured"] = "измерены", + ["kicker.measured"] = "ИЗМЕРЕНО", + }, + }; + + /// + /// One message, rendered for a locale — or the English, where that locale has no approved one. + /// + /// + /// The fallback is silent to the reader and loud to the build. A reader meeting one English + /// phrase inside a German sentence learns something true: this particular claim has not been + /// translated yet. What must never happen is the other thing — a smoothed-over approximation of + /// a locked string, which teaches them something false and gives them no way to tell. + /// + public static string For(string tag, string id, IReadOnlyDictionary? args = null) + { + ArgumentNullException.ThrowIfNull(tag); + ArgumentNullException.ThrowIfNull(id); + + var pattern = Pattern(tag, id) + ?? throw new KeyNotFoundException($"No message '{id}' in any bundle, including English."); + + return IcuMessage.Format(pattern, tag, args); + } + + /// + /// The same, with the arguments named inline rather than built into a dictionary first. + /// + /// + /// One helper, because there were eleven. Every component that renders more than one + /// message had grown its own private wrapper turning a tuple array into an ordinal dictionary + /// and calling — the same six lines, copied, and twice inside one file. + /// StringComparer.Ordinal is the part that mattered and the part a twelfth copy would + /// eventually get wrong: an argument name is a token in a pattern, matched exactly, and a + /// dictionary that folded case would answer a lookup the parser never asked for. + /// + public static string Say(string tag, string id, params (string Key, object? Value)[] args) + { + ArgumentNullException.ThrowIfNull(args); + + return For(tag, id, args.ToDictionary(a => a.Key, a => a.Value, StringComparer.Ordinal)); + } + + /// A count and its noun, agreeing — the commonest call by a long way. + public static string Count(string tag, int count) => + For(tag, "facet.count", new Dictionary { ["count"] = count }); + + /// The raw pattern a locale would use, English included, or null. + public static string? Pattern(string tag, string id) + { + ArgumentNullException.ThrowIfNull(tag); + ArgumentNullException.ThrowIfNull(id); + + return Own(tag, id) ?? English.GetValueOrDefault(id); + } + + /// Whether a locale carries its own text for an id, rather than falling back. + public static bool HasOwn(string tag, string id) => Own(tag, id) is not null; + + /// + /// What a locale itself says for an id — from its resx, or from a test bundle — or null. + /// + /// + /// ResourceNotFound is the load-bearing check. answers a + /// missing key with the key itself rather than with null, so a lookup that trusted the string it + /// got back would render facet.count to a reader and call it a translation. + /// + private static string? Own(string tag, string id) + { + if (TestBundles.TryGetValue(tag, out var bundle)) + { + return bundle.GetValueOrDefault(id); + } + + // The source language reads its own compiled-in copy: it is the fallback for every other + // locale, and a fallback that depends on a satellite assembly having loaded is not one. + if (string.Equals(tag, Locales.SourceTag, StringComparison.OrdinalIgnoreCase)) + { + return English.GetValueOrDefault(id); + } + + if (Culture(tag) is not { } culture) + { + return null; + } + + // The resource set for this culture *alone*, with tryParents off — which is the whole point. + // GetString walks up to the neutral resources, so it answers the English for a locale that + // has translated nothing, and a caller asking "does this locale carry its own words for this + // id" would be told yes for every id in the site. The fallback is deliberate elsewhere and + // wrong here. + // + // A ResourceManager rather than IStringLocalizer, and that is not a rejection of the pattern + // — IStringLocalizer *is* a ResourceManager with the culture read off the ambient thread. + // This lookup is static and is called from Razor markup, from the plain-text renderer and + // from headless component tests alike, and it is handed the locale rather than inferring + // one; the DI wrapper would mean it could not answer at all without a host behind it, which + // is most of where it is called from. AddMuiLocalization still registers the injected form + // for anything that wants it. + try + { + return Resources.GetResourceSet(culture, createIfNotExists: true, tryParents: false) + ?.GetString(id); + } + catch (MissingManifestResourceException) + { + return null; + } + } + + /// + /// The satellite assemblies, keyed off the marker type so the base name cannot drift. + /// + /// + /// One per culture, compiled by the SDK from Resources/Messages.<culture>.resx with + /// no <EmbeddedResource> entries in the project file. A culture with no satellite + /// answers null here rather than throwing, which is the fallback path. + /// + private static readonly ResourceManager Resources = new(typeof(Web.Resources.Messages)); + + private static CultureInfo? Culture(string tag) + { + try + { + return CultureInfo.GetCultureInfo(tag); + } + catch (CultureNotFoundException) + { + return null; + } + } + + /// The source text for an id, which is what a resx has to agree with. + public static string? Source(string id) => English.GetValueOrDefault(id); + + + + /// Every id the site says, in the order the source bundle declares them. + public static IReadOnlyList Ids { get; } = [.. English.Keys]; + + /// The ids a locale has not translated yet. + /// + /// The release checklist's own question, answerable from code rather than from a spreadsheet. + /// A locale may not be moved to while any locked id + /// is in this list — which is the rule the completeness test enforces. + /// + public static IReadOnlyList MissingFor(string tag) => + [.. Ids.Where(id => !HasOwn(tag, id))]; + + /// + /// Accented, expanded English — a language nobody speaks, which is the point. + /// + /// + /// Two jobs. The accents prove a string came through the pipeline rather than being hard-coded + /// in a template, and the padding gives every string the 1.4x width the handoff says to review + /// a locale at — German and Russian run 30–40% longer on short UI nouns, and the nav bar was + /// tightened to fit English exactly. The ICU syntax is stepped over rather than transformed: + /// mangling a branch keyword would make the message unparseable and prove nothing. + /// + private static string Pseudo(string pattern) + { + var b = new System.Text.StringBuilder(pattern.Length * 2); + var depth = 0; + + foreach (var c in pattern) + { + if (c == '{') { depth++; b.Append(c); continue; } + if (c == '}') { depth--; b.Append(c); continue; } + + // Inside braces the text is syntax — argument names and branch keywords — and accenting + // it would make the message unparseable, which proves nothing. + b.Append(depth > 0 ? c : Accent(c)); + } + + // The padding goes outside the braces so no argument name is touched. + return "⟦" + b + "⟧"; + } + + private static char Accent(char c) => c switch + { + 'a' => 'á', 'e' => 'é', 'i' => 'í', 'o' => 'ó', 'u' => 'ú', 'n' => 'ñ', 'c' => 'ç', + 'A' => 'Á', 'E' => 'É', 'I' => 'Í', 'O' => 'Ó', 'U' => 'Ú', 'N' => 'Ñ', 'C' => 'Ç', + _ => c, + }; +} diff --git a/src/MUI.Web/Localization/PluralOperands.cs b/src/MUI.Web/Localization/PluralOperands.cs new file mode 100644 index 0000000..ebaf00c --- /dev/null +++ b/src/MUI.Web/Localization/PluralOperands.cs @@ -0,0 +1,106 @@ +using System.Globalization; + +namespace MUI.Web.Localization; + +/// +/// The six values a CLDR plural rule is written in terms of. +/// +/// The absolute value of the number. +/// Its integer digits. +/// How many fraction digits are visible, trailing zeros included. +/// How many are visible with trailing zeros removed. +/// The visible fraction digits as an integer, trailing zeros included. +/// The same with trailing zeros removed. +/// The compact-decimal exponent, which is zero for everything this site formats. +/// +/// +/// Visible is the load-bearing word, and it is why this type exists rather than an int. +/// In English 1 is one and 1.0 is other — "1.0 stars" is correct and +/// "1.0 star" is not — and the two are the same quantity. A rule cannot tell them apart from the +/// value alone; it needs to know how the number was written. That is what v and +/// f carry, and it is the single most commonly missed thing in a hand-rolled plural +/// implementation. +/// +/// +/// Every count this site pluralises is an integer, so v is zero throughout and none of this +/// changes an answer today. It is here because a plural implementation that only works for integers +/// is one that silently gives the wrong form the first time somebody formats a rate or an average, +/// and because the rules below are transcribed from CLDR in the operands CLDR states them in — a +/// transcription into a different vocabulary is a transcription that cannot be checked. +/// +/// +/// These are absolute values and this type cannot print a number. CLDR takes the absolute +/// value to choose a category, never to display one, so a Format here would drop the sign of +/// every number it was handed — and it did, along with the group separator, which put "1234 games" +/// in a sentence beside "1,234" in the column. Rendering belongs to , which +/// still holds the signed value the caller passed. +/// +/// +public readonly record struct PluralOperands( + decimal N, + long I, + int V, + int W, + long F, + long T, + int E) +{ + /// The operands of an integer, where every fractional one is zero. + public static PluralOperands Of(long value) + { + var n = Math.Abs(value); + + return new PluralOperands(n, n, 0, 0, 0, 0, 0); + } + + /// + /// The operands of a number as it will actually be written. + /// + /// The quantity. + /// + /// How many fraction digits the rendered string shows, or null to read them off the value. + /// + /// + /// The parameter is what makes 1 and 1.0 different: a caller formatting to two + /// decimal places has to say so, because by the time the number reaches here as a + /// the trailing zeros it will be printed with are a fact about the format + /// string and not about the value. + /// + public static PluralOperands Of(decimal value, int? visibleFractionDigits = null) + { + var n = Math.Abs(value); + var i = (long)decimal.Truncate(n); + + // decimal keeps its own scale, so 1.50m already knows it has two fraction digits. That is + // the right default: a caller who did not say otherwise gets the digits the value carries. + var scale = (byte)((decimal.GetBits(n)[3] >> 16) & 0x7f); + var v = visibleFractionDigits ?? scale; + + var fractional = n - i; + var f = v == 0 ? 0L : (long)decimal.Round(fractional * Pow10(v), 0, MidpointRounding.AwayFromZero); + + // w and t are v and f with trailing zeros taken off: 1.50 has v=2, f=50, w=1, t=5. + var w = v; + var t = f; + + while (w > 0 && t % 10 == 0) + { + t /= 10; + w--; + } + + return new PluralOperands(n, i, v, w, f, t, E: 0); + } + + private static decimal Pow10(int power) + { + var result = 1m; + + for (var at = 0; at < power; at++) + { + result *= 10m; + } + + return result; + } +} diff --git a/src/MUI.Web/Localization/PluralRules.cs b/src/MUI.Web/Localization/PluralRules.cs new file mode 100644 index 0000000..be62fe8 --- /dev/null +++ b/src/MUI.Web/Localization/PluralRules.cs @@ -0,0 +1,402 @@ +namespace MUI.Web.Localization; + +/// The CLDR plural categories. +/// +/// Six of them exist across all languages; no single language uses more than five, and most use one +/// or two. is the one every language has and the only one a message must +/// declare. +/// +public enum PluralCategory +{ + Zero, + One, + Two, + Few, + Many, + Other, +} + +/// Cardinal counts one thing; ordinal ranks it. +/// +/// They are different rule sets and getting them from one table is a bug rather than a shortcut. In +/// English the cardinal rule has two forms — 1 game, 2 games — and the ordinal rule has +/// four — 1st, 2nd, 3rd, 4th — and neither can produce the other's answers. +/// +public enum PluralKind +{ + Cardinal, + Ordinal, +} + +/// +/// Which plural form a number takes, per locale, per kind. +/// +/// +/// +/// Transcribed from CLDR 46's plural charts, in the operands CLDR states them in. Each rule +/// below is written the way the chart writes it — i = 1 and v = 0 rather than +/// count == 1 — so it can be checked against the source line by line. A transcription into a +/// different vocabulary is a transcription nobody can verify. +/// +/// +/// Hand-written rather than taken from a library, and that is a judgement rather than a +/// preference. The .NET options are thin: ICU4N is an alpha pinned to ICU 60, whose CLDR data +/// predates several of the rules below, and the MessageFormat ports are 0.1.x forks of an abandoned +/// project. Neither is a dependency worth the credibility of a site whose entire product is being +/// right about what it knows. The cost is that this table has to be maintained against CLDR when a +/// locale is added, which is why exists and is asserted against the +/// locales the site actually commits to. +/// +/// +/// An unlisted language answers . That is correct for a +/// language with one form and safe for one whose rule is not written here — the message still +/// renders, in the form every language has. What stops that being a silent wrong answer is the test +/// that walks every offered locale and refuses one this table does not cover. +/// +/// +public static class PluralRules +{ + /// The CLDR release these rules were transcribed from. + public const string CldrVersion = "46"; + + /// + /// The languages this table states a rule for, cardinal or ordinal. + /// + /// + /// The gate on adding a locale: a tag whose language is not here falls back to other for + /// every count, which is right for Chinese and wrong for German. Asserted in the tests against + /// . + /// + public static IReadOnlyList LocalesCovered { get; } = + [ + "en", "de", "nl", "sv", "da", "no", "fi", "et", "el", "it", "es", "fr", "pt", + "ru", "uk", "be", "pl", "cs", "sk", "hi", "th", "vi", "id", "ms", "ja", "ko", "zh", + "tr", "he", "ar", "qps", + ]; + + /// The category takes. + public static PluralCategory Of(string tag, long count, PluralKind kind = PluralKind.Cardinal) => + Of(tag, PluralOperands.Of(count), kind); + + /// The category a number written a particular way takes. + public static PluralCategory Of(string tag, PluralOperands o, PluralKind kind = PluralKind.Cardinal) + { + ArgumentNullException.ThrowIfNull(tag); + + var language = Language(tag); + + return kind is PluralKind.Ordinal ? Ordinal(language, o) : Cardinal(language, o); + } + + private static PluralCategory Cardinal(string language, PluralOperands o) => language switch + { + // one: i = 1 and v = 0 + // + // The v = 0 is why this file carries operands at all: "1.0 stars" is other, not one, and + // the two quantities are equal. `qps` is the pseudolocale and is English underneath, so it + // has to select exactly what English selects or it exercises the wrong branch. + "en" or "de" or "nl" or "sv" or "fi" or "et" or "qps" => + o is { I: 1, V: 0 } ? PluralCategory.One : PluralCategory.Other, + + // one: n = 1 + // + // Not the line above, and 1.0 is the whole of the difference. Greek, Norwegian, + // Spanish and Turkish say `one` for it where English says `other`. All four had been given + // English's rule, because on the integers the two agree — and the integers are all anybody + // checks. Turkish had `n = 0..1` besides, which is a real CLDR rule belonging to Akan and + // Punjabi and puts zero in the form Turkish keeps for exactly one thing. + "el" or "no" or "tr" => o.N == 1m ? PluralCategory.One : PluralCategory.Other, + + // one: n = 1 or t != 0 and i = 0,1 + // + // Danish alone, and the only `one` in this table that reaches a quantity which is not 1: + // "0,5 stjerne" rather than "0,5 stjerner". Copied from Swedish, it loses that clause + // silently — the integers, again, agree. + "da" => o.N == 1m || (o.T != 0 && o.I is 0 or 1) + ? PluralCategory.One + : PluralCategory.Other, + + // one: i = 1 and v = 0 (it) + // n = 1 (es) + // i = 0,1 (fr, pt) + // many: e = 0 and i != 0 and i % 1000000 = 0 and v = 0 (all four) + // + // The Romance millions rule — "un millón de juegos", with the preposition the other forms + // do not take. fr and pt carried it; it and es were folded into English's rule above and so + // had no `many` at all, which is a form a translator would have been asked to write and + // never given anywhere to put. + "it" => Millions(o) ? PluralCategory.Many + : o is { I: 1, V: 0 } ? PluralCategory.One + : PluralCategory.Other, + + "es" => Millions(o) ? PluralCategory.Many + : o.N == 1m ? PluralCategory.One + : PluralCategory.Other, + + "fr" or "pt" => Millions(o) ? PluralCategory.Many + : o.I is 0 or 1 ? PluralCategory.One + : PluralCategory.Other, + + // one: v = 0 and i % 10 = 1 and i % 100 != 11 + // few: v = 0 and i % 10 = 2..4 and i % 100 != 12..14 + // many: v = 0 and (i % 10 = 0 or i % 10 = 5..9 or i % 100 = 11..14) + // other: everything with a visible fraction + // + // 11 and 12 end in 1 and 2 and take neither `one` nor `few`. A rule written from the first + // three examples anybody tries is wrong for both. + "ru" or "uk" => o.V != 0 ? PluralCategory.Other + : (o.I % 10, o.I % 100) switch + { + (1, not 11) => PluralCategory.One, + (2 or 3 or 4, not (12 or 13 or 14)) => PluralCategory.Few, + _ => PluralCategory.Many, + }, + + // one: n % 10 = 1 and n % 100 != 11 + // few: n % 10 = 2..4 and n % 100 != 12..14 + // many: n % 10 = 0 or n % 10 = 5..9 or n % 100 = 11..14 + // + // Belarusian states Russian's shape on `n` rather than on `i` with `v = 0`, so 1.0 is + // `one` here and `other` there. It had been folded in with Russian above: right for every + // integer, wrong for every number written with a decimal place. + "be" => !Whole(o) ? PluralCategory.Other + : (o.I % 10, o.I % 100) switch + { + (1, not 11) => PluralCategory.One, + (2 or 3 or 4, not (12 or 13 or 14)) => PluralCategory.Few, + _ => PluralCategory.Many, + }, + + // one: i = 1 and v = 0 + // few: v = 0 and i % 10 = 2..4 and i % 100 != 12..14 + // many: v = 0 and i != 1 and (i % 10 = 0..1 or i % 10 = 5..9 or i % 100 = 12..14) + "pl" => o.V != 0 ? PluralCategory.Other + : o.I == 1 ? PluralCategory.One + : (o.I % 10, o.I % 100) switch + { + (2 or 3 or 4, not (12 or 13 or 14)) => PluralCategory.Few, + _ => PluralCategory.Many, + }, + + // one: i = 1 and v = 0 + // few: i = 2..4 and v = 0 + // many: v != 0 + "cs" or "sk" => o.V != 0 ? PluralCategory.Many + : o.I switch + { + 1 => PluralCategory.One, + 2 or 3 or 4 => PluralCategory.Few, + _ => PluralCategory.Other, + }, + + // one: i = 0 or n = 1 + "hi" => o.I == 0 || o.N == 1m ? PluralCategory.One : PluralCategory.Other, + + // one: i = 1 and v = 0 or i = 0 and v != 0 + // two: i = 2 and v = 0 + // + // The `many` this rule used to carry for multiples of ten was withdrawn from CLDR before + // 46, and so was every one of Hebrew's ordinals. A table still stating them selects a + // branch nobody was ever asked to translate — which the `other` fallback cannot catch, + // because the branch is present and simply wrong. + "he" => o switch + { + { I: 1, V: 0 } or { I: 0, V: not 0 } => PluralCategory.One, + { I: 2, V: 0 } => PluralCategory.Two, + _ => PluralCategory.Other, + }, + + // zero: n = 0 one: n = 1 two: n = 2 + // few: n % 100 = 3..10 many: n % 100 = 11..99 + // + // Six categories, which is the count the review cites — and Arabic is render-only here, so + // this rule exists for correctness of the table rather than for a locale that ships. + "ar" => o.N switch + { + 0 => PluralCategory.Zero, + 1 => PluralCategory.One, + 2 => PluralCategory.Two, + _ when !Whole(o) => PluralCategory.Other, + _ => (o.I % 100) switch + { + >= 3 and <= 10 => PluralCategory.Few, + >= 11 and <= 99 => PluralCategory.Many, + _ => PluralCategory.Other, + }, + }, + + // No plural inflection at all. This is exactly why Chinese cannot be the locale a string + // architecture is validated against: it agrees with any shape, including a wrong one. + "zh" or "ja" or "ko" or "th" or "vi" or "id" or "ms" => PluralCategory.Other, + + _ => PluralCategory.Other, + }; + + private static PluralCategory Ordinal(string language, PluralOperands o) => language switch + { + // one: n % 10 = 1 and n % 100 != 11 (1st, 21st, but 11th) + // two: n % 10 = 2 and n % 100 != 12 (2nd, 22nd, but 12th) + // few: n % 10 = 3 and n % 100 != 13 (3rd, 23rd, but 13th) + "en" or "qps" => !Whole(o) ? PluralCategory.Other + : (o.I % 10, o.I % 100) switch + { + (1, not 11) => PluralCategory.One, + (2, not 12) => PluralCategory.Two, + (3, not 13) => PluralCategory.Few, + _ => PluralCategory.Other, + }, + + // one: n % 10 = 1,2 and n % 100 != 11,12 (1:a and 2:a, then 3:e — and 11:e, 12:e) + "sv" => !Whole(o) ? PluralCategory.Other + : (o.I % 10, o.I % 100) switch + { + (1 or 2, not (11 or 12)) => PluralCategory.One, + _ => PluralCategory.Other, + }, + + // one: n = 1 (1er / 1re, then 2e, 3e …) + "fr" => o.N == 1m ? PluralCategory.One : PluralCategory.Other, + + // few: n % 10 = 3 and n % 100 != 13 + "uk" => !Whole(o) ? PluralCategory.Other + : (o.I % 10, o.I % 100) switch + { + (3, not 13) => PluralCategory.Few, + _ => PluralCategory.Other, + }, + + // few: n % 10 = 2,3 and n % 100 != 12,13 + "be" => !Whole(o) ? PluralCategory.Other + : (o.I % 10, o.I % 100) switch + { + (2 or 3, not (12 or 13)) => PluralCategory.Few, + _ => PluralCategory.Other, + }, + + // many: n = 11,8,80,800 + "it" => Whole(o) && o.I is 11 or 8 or 80 or 800 ? PluralCategory.Many : PluralCategory.Other, + + // one: n = 1 two: n = 2,3 few: n = 4 many: n = 6 + "hi" => !Whole(o) ? PluralCategory.Other + : o.I switch + { + 1 => PluralCategory.One, + 2 or 3 => PluralCategory.Two, + 4 => PluralCategory.Few, + 6 => PluralCategory.Many, + _ => PluralCategory.Other, + }, + + // one: n = 1 + "vi" or "ms" => o.N == 1m ? PluralCategory.One : PluralCategory.Other, + + // Every other language this site knows about has one ordinal form: German, Spanish, Danish, + // Norwegian, Greek, Dutch, Finnish, Estonian, Polish, Czech, Slovak, Russian, Portuguese, + // Thai, Indonesian, Chinese, Japanese, Korean, Turkish, Arabic — and Hebrew, whose six-way + // ordinal table CLDR withdrew before 46. + _ => PluralCategory.Other, + }; + + /// The Romance millions rule, which CLDR states identically for all four languages. + /// + /// many: e = 0 and i != 0 and i % 1000000 = 0 and v = 0. e is the compact-decimal + /// exponent and is zero for everything this site formats, so the clause CLDR adds for compact + /// notation cannot be reached from here. + /// + private static bool Millions(PluralOperands o) => + o is { E: 0, V: 0, I: not 0 } && o.I % 1_000_000 == 0; + + /// Whether the number is a whole one, which is all a CLDR range can ever match. + /// + /// n % 100 = 3..10 is a range over integers: 3.5 is not in it, however its integer part + /// reads. Testing i in its place gives every fraction the category of the whole number + /// below it — the wrong form for a rate and for an average alike, and invisible in a table + /// exercised only with counts. + /// + private static bool Whole(PluralOperands o) => o.N == o.I; + + /// + /// Every category a locale can produce, which is what a message has to cover. + /// + /// + /// The assertion the Russian canary is for. A message declaring only one and other + /// is complete in English and silently wrong in Russian, where a count of two takes a form + /// neither branch supplies — and a wrong plural does not read as a typo to a native speaker, it + /// reads as illiterate. + /// + public static IReadOnlyList CategoriesOf( + string tag, PluralKind kind = PluralKind.Cardinal) + { + ArgumentNullException.ThrowIfNull(tag); + + // Derived by exercising the rule rather than by keeping a second table beside it, so the + // two cannot disagree. The probes cover every boundary the rules above test — the teens, + // the tens, the millions, and a fraction, which is the case that separates `one` from + // `other` in English. + var seen = new List(); + + foreach (var probe in Probes) + { + var category = Of(tag, probe, kind); + + if (!seen.Contains(category)) + { + seen.Add(category); + } + } + + return seen; + } + + /// Numbers that between them reach every branch of every rule above. + private static IReadOnlyList Probes { get; } = + [ + .. new long[] + { + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 20, 21, 22, 23, 24, 25, + 80, 100, 101, 102, 103, 111, 112, 113, 800, 1000, 1_000_000, 2_000_000, + }.Select(n => PluralOperands.Of(n)), + + // A visible fraction, which is `other` in English and in Russian and `many` in Czech. + PluralOperands.Of(1.0m, visibleFractionDigits: 1), + PluralOperands.Of(1.5m, visibleFractionDigits: 1), + ]; + + /// The keyword a message branch is spelled with. + public static string Keyword(PluralCategory category) => category switch + { + PluralCategory.Zero => "zero", + PluralCategory.One => "one", + PluralCategory.Two => "two", + PluralCategory.Few => "few", + PluralCategory.Many => "many", + _ => "other", + }; + + /// Whether a word is one of the six category keywords. + public static bool IsCategory(string word) => + word is "zero" or "one" or "two" or "few" or "many" or "other"; + + /// Whether this table states a rule for a tag's language at all. + public static bool Covers(string tag) + { + ArgumentNullException.ThrowIfNull(tag); + + return LocalesCovered.Contains(Language(tag), StringComparer.Ordinal); + } + + /// + /// The language subtag, which is what a plural rule is keyed on. + /// + /// + /// zh-Hans and zh-Hant pluralise identically, and so do ru and the CI + /// canary's ru-x-canary — the script and the private-use subtag change which glyphs are + /// drawn and which bundle is read, never how a number agrees. + /// + private static string Language(string tag) + { + var dash = tag.IndexOf('-', StringComparison.Ordinal); + + return (dash < 0 ? tag : tag[..dash]).ToLowerInvariant(); + } +} diff --git a/src/MUI.Web/Localization/Sentences.cs b/src/MUI.Web/Localization/Sentences.cs new file mode 100644 index 0000000..a55cf20 --- /dev/null +++ b/src/MUI.Web/Localization/Sentences.cs @@ -0,0 +1,100 @@ +namespace MUI.Web.Localization; + +/// +/// A sentence whose word order belongs to the locale and whose markup belongs to the site. +/// +/// +/// +/// Some sentences have a link or an emphasised word inside them. Gluing English round an anchor +/// gives a language that wants the link first, or a different preposition before it, nowhere to say +/// so; formatting the anchor into the string and trusting the result through a +/// MarkupString would make every bundle a place a tag could be put. So the message places a +/// marker, this walks it, and what a translator writes is text either way. +/// +/// +/// The markers are private-use code points, which no translation contains by accident and no script +/// the site offers uses. They are assigned in the order the slots are passed and never appear in +/// what a reader is shown: a marker that survives into the output is a slot the message did not +/// place, and the run for it is simply absent — which is visible rather than silent. +/// +/// +/// This was two copies before it was one file. The random-game empty state placed two links this +/// way and the reference section needed the same thing for its own two sentences, at which point the +/// marker constant, the walk and the "translator writes no markup" argument were about to exist +/// twice. +/// +/// +public static class Sentences +{ + /// The first marker. One code point per slot, upwards from here. + private const char Marker = '\uE000'; + + /// + /// The most slots one sentence may place. Well past anything readable, and a bound rather than + /// an open range so a stray private-use character in a translation cannot be read as a slot. + /// + private const int MaxSlots = 8; + + /// One piece of a sentence: plain text, or the text of a named slot. + /// What the reader sees, already in their language. + /// + /// The argument name the message placed, or null for the prose between the slots. The caller + /// switches on this to decide what markup goes round the run. + /// + public sealed record Run(string Text, string? Slot); + + /// + /// A message, split into the runs its markup renders — the slots in the order the + /// message puts them, which is the locale's order and not this call's. + /// + public static IReadOnlyList Place(string tag, string id, params (string Name, string Text)[] slots) + { + ArgumentNullException.ThrowIfNull(slots); + ArgumentOutOfRangeException.ThrowIfGreaterThan(slots.Length, MaxSlots); + + var sentence = Messages.For(tag, id, slots + .Select((slot, i) => (slot.Name, Value: (object?)((char)(Marker + i)).ToString())) + .ToDictionary(s => s.Name, s => s.Value, StringComparer.Ordinal)); + + var runs = new List(); + var run = new System.Text.StringBuilder(); + + foreach (var c in sentence) + { + var slot = c - Marker; + + if (slot < 0 || slot >= slots.Length) + { + run.Append(c); + continue; + } + + if (run.Length > 0) + { + runs.Add(new Run(run.ToString(), null)); + run.Clear(); + } + + runs.Add(new Run(slots[slot].Text, slots[slot].Name)); + } + + if (run.Length > 0) + { + runs.Add(new Run(run.ToString(), null)); + } + + return runs; + } + + /// + /// The same sentence with the slots substituted rather than marked — for the plain surface, + /// which has no markup to put round them and must still say every word the page does. + /// + public static string Flat(string tag, string id, params (string Name, string Text)[] slots) + { + ArgumentNullException.ThrowIfNull(slots); + + return Messages.For(tag, id, slots.ToDictionary( + s => s.Name, s => (object?)s.Text, StringComparer.Ordinal)); + } +} diff --git a/src/MUI.Web/MUI.Web.csproj b/src/MUI.Web/MUI.Web.csproj index 2da38cb..4320d0f 100644 --- a/src/MUI.Web/MUI.Web.csproj +++ b/src/MUI.Web/MUI.Web.csproj @@ -30,6 +30,15 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + {count, plural, one {# Spiel} other {# Spiele}} + + + {value}, {count, plural, one {# Spiel} other {# Spiele}}, nur + + + {value}, {count, plural, one {# Spiel} other {# Spiele}}, ausgeschlossen + + + {value}, {count, plural, one {# Spiel} other {# Spiele}} + + + {facet}: beliebig, {count, plural, one {# Spiel} other {# Spiele}} + + + {count, plural, =0 {Keine Spiele eingetragen.} one {# Spiel, jede Tatsache nennt ihre Herkunft.} other {# Spiele, jede Tatsache nennt ihre Herkunft.}} + + + {days, plural, one {# Tag} other {# Tage}} gemessen · {probes, plural, one {# Abfrage} other {# Abfragen}} + + + {days, plural, one {# Tag} other {# Tage}} · {count, plural, one {# Zählung} other {# Zählungen}} + + + {disagreeing, plural, =0 {Keine der {total} widerspricht.} one {# von {total} widerspricht dem, was das Spiel angibt.} other {# von {total} widersprechen dem, was das Spiel angibt.}} + + + {count, plural, =0 {Keine Spiele passen zu allen Antworten.} one {# Spiel passt zu allen Antworten.} other {# Spiele passen zu allen Antworten.}} + + + von {listed, plural, one {# eingetragenem Spiel} other {# eingetragenen Spielen}} · {answers, plural, =0 {keine Antworten gegeben} one {# Antwort gegeben} other {# Antworten gegeben}} + + + {count, plural, one {Das eine Spiel zeigen} other {Diese # Spiele zeigen}} + + + „{answer}“ entfernen — {count, plural, one {# Spiel} other {# Spiele}} + + + Antwort zurücksetzen: {question} + + + Ein Spiel finden + + + passend zu allen Antworten + + + {count, plural, one {Spiel} other {Spiele}} + + + alle Antworten zurücksetzen + + + neu beginnen + + + {count, plural, one {# weiteres} other {# weitere}} + + + gegebene Antworten + + + das gesamte Verzeichnis + + + diese Anfrage wurde abgelehnt + + + ein Name, falls Sie einen haben + + + Name oder Namensteil + + + Nach Namen suchen + + + Spielt gerade jemand? + + + Was möchten Sie spielen? + + + Welche Art von Spiel? + + + In welcher Sprache? + + + Braucht Ihr Client etwas? + + + Verstummte Spiele einbeziehen? + + + egal + + + beliebiges Genre + + + beliebige Art + + + beliebige Sprache + + + egal + + + nein, nur aktive Spiele + + + ja, auch diese zeigen + + + verstummte Spiele + + + TLS — verschlüsselt, Handshake von uns abgeschlossen + + + MSSP — Selbstauskunft des Servers + + + MCCP — komprimierte Ausgabe + + + MXP — anklickbare Links + + + GMCP — strukturierte Client-Daten + + + MSDP — strukturierte Client-Daten + + + CHARSET — Aushandlung der Kodierung + + + UTF-8 — nichtlateinischer Text wird dargestellt + + + TTYPE — Client nennt seinen Typ + + + ATCP — strukturierte Client-Daten + + + MSP — Sound-Auslöser + + + EOR — Prompt-Markierung + + + {token} — im Handshake gemessen + + + gemessen + + + gemessen + + + gemessen + + + gemessen + + + gemessen + + + angegeben + + + angegeben + + + angegeben + + + angegeben + + + abgeleitet + + + abgeleitet + + + nicht gemessen + + + ungezählt + + + nicht erreichbar + + + nicht gezählt + + + keine Zählung + + + von hier aus + + + archiviert + + + beansprucht + + + Nichts zur Auswahl + + + Kein Spiel passt zu diesem Filter. {listing} versuchen oder {archive}. + + + das gesamte Verzeichnis + + + das Archiv einbeziehen + + + Verbindungen + + + nicht beansprucht + + + vom Betreiber beansprucht + + + wird weiter abgefragt + + + typisch + + + Spitze + + + als Text lesen + + + Textfassung + + + zum Inhalt springen + + + ASCII-Banner: der Verbindungsbildschirm von {game}. + + + Kataloge + + + Diese Website und Ihr Konto + + + stöbern + + + lernen + + + diese Website + + + Menü + + + Spiele + + + finden + + + zufällig + + + Archiv + + + Referenz + + + Ökosystem + + + Ranglisten + + + über uns + + + eintragen + + + Spiel eintragen + + + anmelden + + + Ihre Spiele + + + Design + + + automatisch + + + hell + + + dunkel + + + Demodaten. + + + Es ist keine Datenbank konfiguriert, daher sind dies Beispieldaten. Nichts davon wurde gemessen. + + + alle Spiele + + + Archiv + + + vom Spiel angegeben + + + was sich geändert hat + + + Ein Verzeichnis des MU*-Hobbys + + + Jede Tatsache nennt ihre Herkunft und ihr Alter: von unserem Crawler gemessen oder vom Spiel angegeben und als solche gekennzeichnet. + + + Spiele nach Name, Thema, Codebase oder Host suchen + + + nach Name, Thema, Codebase oder Host suchen + + + suchen + + + bekannte Spiele + + + jetzt verbunden + + + antwortend, ungezählt + + + archiviert + + + neu entdeckt + + + verstummt — wird weiter abgefragt + + + wieder da + + + Nichts Neues. + + + Nichts ist verstummt. + + + Nichts ist zurückgekehrt. Wir klopfen weiter. + + + live + + + Spiele + + + sortiert nach {order} + + + zufällig + + + Verbindungen · erreicht + + + von hier aus + + + Keine Treffer. + + + Weniger Wörter versuchen oder einen Filter entfernen. + + + Filter zurücksetzen + + + über {codebase} + + + nie + + + vom Betreiber beansprucht + + + Unbekannte Codebase + + + wir konnten die Codebase dieses Spiels nicht identifizieren + + + und {count, plural, one {# weiteres} other {# weitere}}: {names} + + + Sortierung + + + Zeitraum + + + jetzt + + + typisch + + + Spitze + + + Name + + + erreicht + + + 7 Tage + + + 30 Tage + + + 90 Tage + + + Spiele suchen + + + Spiele suchen + + + Filter + + + angezeigt + + + alle zurücksetzen + + + — nicht mehr danach filtern + + + beliebig + + + weitere Filter ({count}) + + + {count, plural, one {# weiterer} other {# weitere}} + + + zusätzlich zeigen + + + Standardmäßig aus. Keines von beiden ist ein Urteil über das Spiel. + + + archiviert + + + Erwachseneninhalte + + + archivierte Spiele, {shown, select, true {angezeigt} other {verborgen}} + + + Spiele, die Erwachseneninhalte angeben, {shown, select, true {angezeigt} other {verborgen}} + + + Zahlen sind gemessene Spiele, nie Schätzungen. + + + was die Kennzeichen und die Leerstellen bedeuten + + + Eine Leerstelle ist eine Lücke in unserer Messung, kein Nein. Jede Facette benennt ihre eigene: nicht identifiziert, nicht angegeben, nichts ausgehandelt. + + + Eine gemessene Null ist eine Zählung. Eine unbekannte Zählung ist keine Null und wird nie als solche sortiert. + + + Offene Facetten listen ihre {count} häufigsten Werte. Der Rest ist über die Suche und über die URL erreichbar. + + + Nicht angehakt heißt nicht gemessen — nicht, dass es dem Spiel fehlt. + + + Aktivität + + + zuletzt gesehen + + + angebotene Protokolle + + + verschlüsselt + + + Kodierung + + + Codebase + + + Version + + + Abstammung + + + Familie + + + Genre + + + Sprache + + + jetzt verbunden + + + diese Woche aktiv + + + ruhig — nie mehr als 0 gezählt + + + verstummt — seit einem Monat nicht erreicht + + + archiviert + + + in den letzten 24 Stunden + + + in den letzten 7 Tagen + + + in den letzten 30 Tagen + + + länger her + + + nie erreicht + + + nichts ausgehandelt + + + nicht identifiziert + + + nicht angegeben + + + über TLS verbunden + + + nicht {value} + + + etwas ausgehandelt + + + überhaupt identifiziert + + + überhaupt angegeben + + + wir haben das selbst beobachtet + + + das Spiel sagt es, und wir haben es nicht geprüft + + + wir haben gruppiert, was das Spiel uns mitgeteilt hat + + + Name + + + jetzt verbunden + + + zuletzt erreicht + + + typisch verbunden · 7 Tage + + + typisch verbunden · 30 Tage + + + typisch verbunden · 90 Tage + + + meiste gleichzeitig · 7 Tage + + + meiste gleichzeitig · 30 Tage + + + meiste gleichzeitig · 90 Tage + + + jetzt in der Zeile + + + typisch + + + Spitze + + + Unbekannte Zählung + + + noch nie erreicht — nicht etwa vor langer Zeit erreicht + + + weniger als {minimum} Zählungen im Zeitraum oder gar keine — nicht etwa eine typische Zählung von null + + + nichts, was wir im Zeitraum zählen konnten — nicht etwa ein Spiel, mit dem niemand verbunden war + + + Median {value} · {days, plural, one {# Tag} other {# Tage}} · {count, plural, one {# Zählung} other {# Zählungen}} + + + meiste {value} gleichzeitig · {days, plural, one {# Tag} other {# Tage}} · {count, plural, one {# Zählung} other {# Zählungen}} + + + Verbindungsbildschirm + + + Verbindungen nach Stunde + + + Wie viele, im Zeitverlauf + + + Erreichbar + + + Was sich geändert hat + + + Fähigkeiten + + + Vom Spiel angegeben + + + Verweise + + + Nicht beansprucht — alles hier wurde gemessen. + + + Vom Betreiber beansprucht — die gemessenen Tatsachen unten sind weiterhin unsere. + + + Dieses Spiel beanspruchen + + + antwortet seit {date} + + + als Text lesen — {count, plural, one {# Zeile} other {# Zeilen}} + + + Fähigkeit + + + Alter + + + angeboten + + + stumm + + + fehlend + + + verweigert + + + behauptet + + + widerspricht + + + wo sie widersprechen ({count}) + + + {day} {time} — {count, plural, =0 {0 Spieler, gemessen} one {durchschnittlich # Spieler} other {durchschnittlich # Spieler}} + + + {day} {time} — abgefragt, keine Zählung ablesbar + + + {day} {time} — keine Messung in dieser Stunde + + + Wir haben die Aktivität dieses Spiels noch nicht gemessen. + + + Keine Stunde der Woche hat eine Zählung ergeben. + + + Jede Stunde gemessen, und in keiner war jemand verbunden. + + + {count, plural, one {Für # Stunde am {day} liegt noch keine Messung vor.} other {Für # Stunden am {day} liegt noch keine Messung vor.}} + + + {count, plural, one {Für # Stunde der Woche liegt noch keine Messung vor.} other {Für # Stunden der Woche liegt noch keine Messung vor.}} + + + {count, plural, one {# Stunde am {day} hat geantwortet, aber keine Zählung ergeben.} other {# Stunden am {day} haben geantwortet, aber keine Zählung ergeben.}} + + + {count, plural, one {# Stunde der Woche hat geantwortet, aber keine Zählung ergeben.} other {# Stunden der Woche haben geantwortet, aber keine Zählung ergeben.}} + + + Jeden Tag am belebtesten, {window}. + + + Jeden Tag am belebtesten, {part}, {window}. + + + {days} am belebtesten, {window}. + + + {days} am belebtesten, {part}, {window}. + + + Verlässlich ruhig {who}, {window}. + + + Verlässlich ruhig {who} {part}, {window}. + + + jeden Tag + + + an jedem gemessenen Tag + + + an Wochentagen + + + an {days} + + + morgens + + + nachmittags + + + abends + + + nachts + + + morgens + + + nachmittags + + + abends + + + nachts + + + {list}, {next} + + + {first} und {second} + + + noch nicht genug Messungen + + + Für keine Stunde der Woche liegt bisher eine Messung vor. + + + {count, plural, one {# Stunde hat geantwortet und keine Zählung ergeben.} other {# Stunden haben geantwortet und keine Zählung ergeben.}} + + + Das Raster erscheint, sobald für jeden Wochentag eine Messung vorliegt. + + + {days, plural, one {Bisher an # von sieben Tagen gemessen; das Raster erscheint, sobald jeder Tag eine gemessene Stunde hat.} other {Bisher an # von sieben Tagen gemessen; das Raster erscheint, sobald jeder Tag eine gemessene Stunde hat.}} + + + {count, plural, one {# Stunde gemessen, darin war niemand verbunden.} other {# Stunden gemessen, in allen war niemand verbunden.}} + + + {count, plural, one {# Stunde gemessen, die belebteste mit {peak} am {day} um {time} UTC.} other {# Stunden gemessen, die belebteste mit {peak} am {day} um {time} UTC.}} + + + {count, plural, one {# weitere Stunde hat geantwortet und keine Zählung ergeben.} other {# weitere Stunden haben geantwortet und keine Zählung ergeben.}} + + + {day} — {facts} + + + {first}, {second} + + + den ganzen Tag null gemessen + + + Spitze {count} um {time} + + + niemand verbunden {window} + + + in keiner Stunde eine Zählung + + + {count, plural, one {# Stunde nicht gemessen} other {# Stunden nicht gemessen}} + + + {count, plural, one {# Stunde abgefragt, aber nicht zählbar} other {# Stunden abgefragt, aber nicht zählbar}} + + + Tag + + + ruhigste + + + belebteste + + + um + + + keine Zählung + + + Verbundene Spieler nach Tag, in UTC. {window}. + + + Zeiten in UTC · {window} + + + {weeks, plural, one {gleitender Durchschnitt über # Woche} other {gleitender Durchschnitt über # Wochen}} + + + gezählt, auch eine gemessene Null + + + abgefragt, keine Zählung ablesbar + + + keine Messung in dieser Stunde + + + Wann Spieler verbunden sind (UTC) + + + gezählt + + + wir sind hineingekommen und haben eine Zahl abgelesen, auch eine gemessene Null + + + wir sind hineingekommen, aber keine Zahl war ablesbar + + + für diese Stunde liegt uns keine Messung vor + + + Sprache + + + Sprache wechseln + + + Über mu*index + + + Jedes Spiel hier wurde von einer Maschine gemessen, die sich mit ihm verbunden hat, und jeder Wert nennt seine Herkunft und seinen Zeitpunkt. Diese Seite behandelt, was das belegt, was wir falsch machen, wessen Verzeichnisse wir lesen und wie sich der Crawler stoppen lässt. + + + Was hier eine Tatsache ist + + + Gemessen schlägt angegeben, und beides wird gezeigt. + + + Der MSSP-Bericht eines Spiels ist das Spiel, das sich selbst beschreibt. Der Telnet-Handshake ist das, was wir es haben tun sehen. Beides steht auf seiner Seite, versehen mit dem Wie und dem Wann. Wo beides sich widerspricht, zeigen wir den Widerspruch. + + + Eine Spielerzahl nennt ihre Herkunft. + + + Entweder ein WHO oder DOING, am Verbindungsbildschirm gelesen und von uns gezählt, oder das MSSP-Feld PLAYERS des Spiels selbst, das es veröffentlicht hat. Niemals zusammengeführt. + + + Eine Antwort, die wir nicht lesen können, ist unbekannt, niemals null. + + + Server passen ihre WHO-Kopfzeilen frei an, und ab einem gewissen Punkt kann unser Parser eine solche nicht mehr lesen. Das ist unzählbar, ein eigener Zustand. Eine gemessene Null — wir sind hineingekommen, und niemand war da — ist eine Zählung und wird als solche ausgegeben. + + + Erreichbar, niemals Uptime. + + + Wir öffnen in Abständen einen Socket von einem einzigen Host aus. Ein Spiel, zu dem wir keine Route finden, ist nicht erreichbar und dennoch völlig lebendig. Nichts hier behauptet die Uptime eines Spiels, denn nichts hier hat sie gemessen. + + + Eine Stunde ist gezählt, unzählbar oder nicht gemessen. + + + Das Aktivitätsraster hat drei Zustände. Der dritte ist leer und nennt keine Ursache: eine Stunde, die wir nicht erreichen konnten, und eine Stunde, die wir nie abgefragt haben, sind dieselbe Leerstelle, und keine von beiden ist die Ausfallzeit dieses Servers. + + + Was wir falsch machen, soweit wir es wissen + + + Die Schonfrist bis zum Archiv wird ab dem Tag gemessen, an dem wir Sie gefunden haben. + + + Ein Spiel, das nicht mehr antwortet, verlässt nach seiner Schonfrist das Standardverzeichnis: ein Viertel der erreichbaren Zeit, die wir abgefragt haben, mindestens 60 und höchstens 365 Tage. Ein Spiel, das seit 1995 läuft, beginnt am Tag seiner Entdeckung beim Minimum. Wir importieren nichts, um die Jahre vor unserer Ankunft aufzufüllen. + + + MSSP CREATED wird auf diese Schonfrist nicht angerechnet. + + + Es ist eine von Hand getippte Zeile in einer Konfigurationsdatei; sie anzurechnen würde die Archivschwelle manipulierbar machen. Sie wird als Angabe gezeigt und bringt nichts ein. + + + Ein beanspruchtes Spiel erhält das Maximum. + + + Der Nachweis von Serverzugriff ist das volle Jahr Schonfrist wert, gleich wie lange wir schon zusehen. + + + Alles hier ist ein einziger Host, der in Abständen hinsieht. + + + Ein Prozentsatz erreichbarer Zeit ist ein Anteil des von uns beobachteten Zeitraums, nie eines anderen. Keine Grafik hier füllt den Rest auf. + + + Nichts wird jemals gelöscht. + + + Das Archivieren nimmt ein Spiel aus dem Standardverzeichnis, aus den Ranglisten und aus der Zahl der heute aktiven Spiele heraus, und sonst nichts. Seine Seite, seine URL, seine Geschichte und seine Adresse bleiben, es wird weiter abgefragt, und eine einzige erfolgreiche Abfrage bringt es zurück. + + + Was diese Website nicht tut + + + Keine Stimmen, Sterne, Bewertungen oder Empfehlungen. + + + Ranglisten werden ausschließlich aus gemessenen Daten berechnet. Ein Verzeichnis, das danach sortiert, wer die meisten Klicks mobilisieren kann, beschreibt den Wahlkampf und nicht das Hobby — und genau daran sind die Vorgänger zugrunde gegangen. + + + Keine Foren, Rezensionen, Wikis, Kommentare oder Spielerprofile. + + + Einführendes Material — was ein MUSH ist, welche Codebase zu gemeinsamem Rollenspiel passt — wird geschrieben, namentlich gezeichnet und versioniert wie der Rest der Website. + + + Spielernamen werden nie gespeichert. + + + Eine WHO-Antwort wird im Arbeitsspeicher auf eine Zahl und die Form der Kopfzeile hin ausgewertet. Die Namen werden nicht festgehalten; Aggregate verwenden einen gesalzenen Hash mit rotierendem Salt. + + + Es wird keine absolute Spielerzahl veröffentlicht. + + + Anteile pro Codebase und pro Protokoll werden veröffentlicht: Ein Verhältnis über die gemessene Menge übersteht die Spiele, die wir nicht zählen können. „Wie viele Menschen spielen MU*“ übersteht das nicht, denn diese Zahl würde es nicht überstehen, zitiert zu werden. + + + Der Crawler und wie er sich stoppen lässt + + + Eine Abfrage ist eine Verbindung, die sich nie anmeldet. + + + Sie öffnet einen Socket, handelt Telnet-Optionen aus, liest den Verbindungsbildschirm, fragt MSSP über Option 70 an, sendet {commands} und trennt die Verbindung. Kein Charakter, keine Anmeldung, nichts auf der Gegenseite verändert. Ein Timeout begrenzt die Sitzung, damit eine hängengebliebene Abfrage keinen Verbindungsplatz belegt. + + + CRAWL DELAY hat Vorrang. + + + Ein Spiel, das in seinem MSSP-Bericht einen bevorzugten Mindestabstand nennt, bekommt ihn — in beide Richtungen vor unserem eigenen Zeitplan: 720 Stunden heißt monatlich, nicht wöchentlich. Ein verstummtes Spiel wird im längeren Abstand für immer weiter versucht; so trägt es sich selbst wieder ein, wenn es zurückkommt. + + + Eine per Verweis genannte Adresse wird geprüft, nie geglaubt. + + + MSSP erlaubt es einem Spiel, andere Spiele zu nennen. Jeder Name wird aufgelöst, bevor irgendetwas angewählt wird, und abgelehnt, sofern nicht jede Adresse, auf die er auflöst, global routbar ist. Eine gemischte Antwort lehnt das ganze Ziel ab. Unsere Ablehnung wird als unsere verbucht und erscheint nie als Ausfallzeit im Datensatz eines Spiels. + + + Verbindungsbildschirme werden gezeigt, weil sie an alle gesendet werden. + + + Ein Server malt seinen Verbindungsbildschirm ohne Anmeldung an jede anonyme Verbindung. Wir zeigen ihn als Beleg und kennzeichnen ihn. Auf Bitte hin verschwindet er. + + + Ein Stopp genügt, und wir hören auf — auf drei Wegen. + + + Veröffentlichen Sie {variable} 1 in Ihrem MSSP-Bericht, dann ist die Abfrage, die es liest, die letzte. Oder veröffentlichen Sie einen TXT-Eintrag unter {label}.your.host mit dem Inhalt „{value}“, wofür es keine MSSP-Unterstützung und kein Konto hier braucht. Oder schreiben Sie einer Person. Alle drei werden innerhalb eines Crawl-Zyklus befolgt, mit Datum und dem Gelesenen festgehalten und auch im Eintragungsformular durchgesetzt. + + + Das MSSP-Feld stoppt diesen Listener; der TXT-Eintrag stoppt den Host. + + + MSSP wird von dem Port veröffentlicht, der geantwortet hat, und spricht daher für diesen Port — beim MU*-Hosting laufen regelmäßig unabhängige Spiele auf einer Domain, und keines darf sein Nachbarspiel zum Schweigen bringen. Ein TXT-Eintrag umfasst jeden Port, sofern er nicht einen einzelnen nennt, etwa „{value}=4201“. Alles dort, was wir nicht als Portliste lesen können, meint den ganzen Host; deshalb funktioniert auch „{value}=all“. + + + Der Weg über DNS ist der, den Sie ohne Rückfrage bei uns rückgängig machen können. + + + Ein TXT-Eintrag lässt sich lesen, ohne sich mit einem Server zu verbinden, der uns das untersagt hat; deshalb lesen wir ihn vor jeder Anwahl neu. Löschen Sie ihn, und wir wählen binnen einer Woche wieder an. Ein MSSP-Feld lässt sich nicht neu lesen, ohne genau das zu tun, was Sie uns untersagt haben; deshalb gelten ein Ausschluss per MSSP und schriftliche Bitten so lange, bis Sie etwas anderes sagen. Dieser TXT-Abruf ist alles, was eine ausgeschlossene Adresse noch bekommt: Er berührt Ihren Nameserver, nie Ihr Spiel. + + + Aufhören ist kein Löschen, und es ist keine Ausfallzeit. + + + Ein Spiel, das sich ausschließen lässt, behält seine Seite, seine Adresse und alles, was wir vor der Bitte gemessen haben. Nur neue Daten hören auf: Das Aktivitätsraster gewinnt keine Stunden mehr hinzu und nennt keine Ursache, denn unsere Entscheidung, nicht mehr anzuklopfen, ist eine Tatsache über uns. Sie wird bei dem Crawl festgehalten, der nicht stattgefunden hat, und im Register derer, die darum gebeten haben. + + + Wenn Aufhören nicht genügt, kann auch der Eintrag verschwinden. + + + Sobald wir für jede Adresse, unter der Ihr Spiel antwortet, aufgehört haben, bietet Ihre Übersicht eine weitere Möglichkeit: es aus dem Verzeichnis, aus den Ranglisten und aus der Tageszahl herauszunehmen. Die Seite und jede Adresse, die es je hatte, antworten weiterhin, und nichts wird gelöscht — es ist nur kein Ort mehr, an dem man beim Stöbern ankommt. Dafür braucht es einen bestätigten Anspruch, denn es ist eine Entscheidung über Ihr Spiel, und wir halten fest, wer sie getroffen hat. Und eine Abfrage macht sie rückgängig: Nehmen Sie Ihren Ausschluss zurück, dann bringt die nächste Anwahl, die eine Antwort erhält, Sie wieder ins Verzeichnis, ohne dass Sie uns ein zweites Mal fragen müssen. + + + Der Crawler nennt sich {name}, wenn ein Server fragt, was er ist. + + + Der Crawler ist so konfiguriert, dass er sich {name} nennt, kann das aber noch nicht mitteilen. Seine Telnet-Bibliothek bietet einem Client keine Möglichkeit, den Terminaltyp zu setzen; deshalb steht in Ihren Logs der Standardwert dieser Bibliothek, und NEW-ENVIRON wird aus der Umgebung des Crawler-Hosts beantwortet. Beides sind Lücken in der Bibliothek und von uns dort zu beheben. Bis dahin erkennen Sie eine Abfrage an ihrer Form: eine Verbindung, keine Anmeldung, ein kurzer, nur lesender Befehlssatz, weg. + + + Crawler + + + Kontakt + + + Crawler: {name} + + + Kontakt: {url} + + + — Platzhalter; diese Installation hat keine Kontaktadresse gesetzt + + + Es ist keine Kontaktadresse konfiguriert; die obige ist daher ein Platzhalter und antwortet niemandem. + + + Woher die Liste der Spiele stammt + + + Wir übernehmen Adressen. Sonst nichts. + + + Ein Backfill übernimmt einen Host und einen Port. Keine Spielerzahlen, keine Erreichbarkeitsverläufe, keine Beschreibungen, keine Felder und keinen Vermerk, von welcher Website eine Adresse stammt. + + + Bewusst weniger, als diese Websites hergeben. + + + Mehrere davon halten jahrelange, datierte Spielerzahlen bereit. Diese zu importieren würde die Aktivitätsraster genau der Spiele füllen, die schon jemand anderes beobachtet hat, und die zentrale Behauptung dieser Website auf die Abfragen eines Dritten stützen. + + + Die Herkunft eines Spiels ist keine einzelne Tatsache. + + + Jedes Spiel, das einen Eintrag wert ist, steht in mehreren dieser Verzeichnisse; „importiert aus“ würde also nur den Abruf nennen, der zuerst lief. Dass ein Spiel existiert, ist öffentlich bekannt; wo wir es gelesen haben, fügt nichts hinzu und ist der Teil fremder Arbeit, auf den wir am wenigsten Anspruch haben. + + + Die Website eines anderen zu lesen bleibt: die Website eines anderen zu lesen. + + + Wir bitten vor dem Scrapen um einen Massenexport oder einen dokumentierten Endpunkt, lesen zuerst robots.txt und drosseln Scrapes stark. Eine Quelle, die das Einverständnis ihrer Betreuung braucht, wird erst abgerufen, wenn eine Person bestätigen kann, dass gefragt wurde. + + + gelesen — nur Adressen + + + nicht gelesen — Erlaubnis ausstehend + + + Eine Seite, eine Anfrage. Veröffentlicht von einem Crawler, der sich mit jedem Spiel verbindet und ausgibt, was er gelesen hat. + + + Die MSDP-Liste desselben Crawlers. Fast eine Teilmenge ihres MSSP-Gegenstücks; gelesen wegen der wenigen Adressen, die sie erreicht und das andere nicht. + + + Veröffentlicht seinen gesamten Katalog auf einer Seite, das Lesen kostet also eine einzige Anfrage. Unsere größte Quelle für Adressen und für keinerlei Messungen. + + + Eine Übersichtsseite und je eine Seite pro Welt, also ein Scrape statt eines Exports. Am 30. Juli 2026 haben wir 143 ihrer Seiten abgerufen, im Abstand von fünfzehn Sekunden und unter Beachtung von robots.txt, aber bevor ihnen jemand geschrieben hatte. Das hätte nicht geschehen dürfen. Die Sperre verlangt nun eine Person, die bestätigt, dass die Betreuung gefragt wurde. + + + Implementiert, getestet, nie ausgeführt. Die stärkste Quelle hier in jeder Hinsicht außer der Erlaubnis; es wird nichts abgerufen, bevor ihnen jemand geschrieben hat. + + + Lizenz + + + Der Code steht unter MIT. + + + Die Website, der Crawler und die Parser sind Open Source unter der MIT-Lizenz. + + + Die Lizenz für die Daten ist eine offene Frage. + + + Eine vom Code getrennte Entscheidung, und noch nicht getroffen. Die Bedingungen unten sind die derzeitige Antwort dieser Installation, nicht die feststehende Haltung des Projekts. Dass ein konkurrierendes Verzeichnis den gesamten Katalog übernimmt, gilt hier als Erfolg; was auch immer festgelegt wird, wird einem solchen nicht im Weg stehen. + + + Code + + + Daten, so wie diese Installation sie ausliefert + + + Namensnennung als + + + Code: {licence} + + + Daten: {licence} + + + Namensnennung als: {credit} + + + (was diese Installation ausliefert. Die eigene Antwort des Projekts ist noch offen.) + + + Ein Spiel eintragen + + + Uns mitteilen, wo ein Spiel ist. Ein Host und ein Port sind das ganze Formular; alles andere auf dieser Website misst unser eigener Crawler. + + + Host + + + Port + + + mud.example.org, oder mud.example.org:4201 einfügen und den Port leer lassen + + + Eintragen + + + Das Eintragen braucht eine Datenbank, und diese Website läuft auf den Demodaten. Es gibt kein Crawl-Register zum Hineinschreiben, deshalb fehlt das Formular, statt still nichts zu tun. + + + Nicht hier + + + Was mit einer Adresse geschieht + + + Wir lösen die Adresse auf, bevor wir sie anwählen, und lehnen alles ab, was außerhalb des öffentlichen Internets auflöst. Das ist eine Entscheidung über unseren eigenen Socket, nie eine Tatsache über ein Spiel. + + + Wenn die Betreiber dieses Hosts uns gebeten haben, ihn nicht zu crawlen, nehmen wir die Adresse nicht an, gleich wer sie einträgt. Fremde können Ihr Spiel nicht zurück auf diese Website bringen. + + + Antwortet sie, lesen wir, was der Server über sich selbst sagt, und lesen es nach seinem eigenen Zeitplan für immer weiter. Eine Adresse muss nur einmal genannt werden. + + + Nichts erscheint auf der Website, bis jemand nachweist, dass er es betreibt. Ein Anspruch braucht einen Passkey und eine im Spiel selbst veröffentlichte Zeile. + + + Eine Adresse, die wir bereits haben, fällt mit dem bestehenden Eintrag zusammen. Zweimal senden erzeugt keinen zweiten Eintrag und zieht keine Abfrage vor. + + + diese Adresse + + + Im Register. + + + {address} wird im nächsten Crawl-Zyklus angewählt, danach für immer nach eigenem Zeitplan. Sobald jemand nachweist, dass er es betreibt, erscheint es hier — kommen Sie mit derselben Adresse zu diesem Formular zurück, dann bekommen Sie hier den Link dazu. + + + Wir haben es, nicht beansprucht. + + + {address} messen wir bereits. Es bleibt von der Website fern, bis jemand nachweist, dass er es betreibt. Sind Sie das, geht es hier hinein. + + + Das haben wir schon. + + + {address} ist ein Spiel, das wir bereits messen. Es wurde nichts angelegt und nichts geändert. + + + Diese Adresse haben wir schon. + + + {address} ist uns bereits bekannt. Es wurde nichts angelegt und nichts geändert. + + + Wartet bereits. + + + {address} steht im Crawl-Register und hat noch nicht geantwortet. Erneutes Senden zieht sie nicht vor: Ein Ziel behält seinen eigenen Zeitplan, damit niemand uns am Server eines anderen antreiben kann. + + + Keine Adresse, die wir anwählen können. + + + Ein Host braucht einen Punkt oder einen Doppelpunkt, und ein Port ist eine Zahl zwischen 1 und 65535. Beide Felder ausfüllen oder mud.example.org:4201 in das erste einfügen. + + + Das können wir nicht anwählen. + + + Für {address} führen drei Dinge zu dieser Antwort: Der Name löst womöglich nicht auf, er löst womöglich außerhalb des öffentlichen Internets auf, oder die Betreiber dieses Hosts haben uns gebeten, fernzubleiben. Wir sagen bewusst nicht, welches davon, denn das für Fremde zu beantworten kartiert ein Netz von außen. Über die Adresse wurde nichts festgehalten; die Entscheidung war unsere und ist als unsere verbucht. + + + Genug für jetzt. + + + Dieses Formular ist pro Absender begrenzt, und Sie haben die Grenze erreicht. Kommen Sie in einer Stunde wieder. Nichts ging verloren — was wir angenommen haben, steht bereits im Register. + + + dieses Spiel beanspruchen + + + Anmelden + + + Melden Sie sich mit einem Passkey an, um ein Spiel zu beanspruchen, das Sie betreiben. Es gibt kein Passwort, das verloren gehen, und keines, das gestohlen werden kann. + + + Das Beanspruchen braucht eine Datenbank, und diese Website läuft auf den Demodaten. Es gibt nichts, wo man sich anmelden könnte. + + + Die Anmeldung ist ein Passkey. + + + Ihr Gerät oder Ihr Passwortmanager hält den privaten Schlüssel; wir halten nur die öffentliche Hälfte. Kein Passwort, keine E-Mail. + + + Mit einem Passkey anmelden + + + Die einzige Seite hier, die JavaScript braucht. Passkeys funktionieren ohne es nicht. + + + Noch kein Konto? + + + Sie brauchen eines nur, um ein Spiel zu beanspruchen, das Sie betreiben. Wählen Sie einen Namen, unter dem Sie erscheinen — eine Beschriftung neben Ihrem Anspruch, kein echter Name. + + + Name + + + z. B. corvid-admin + + + Konto mit einem Passkey anlegen + + + Was wir speichern + + + Den Namen, den Sie gewählt haben. + + + Den öffentlichen Schlüssel jedes Passkeys, den Sie registrieren, und den Namen, den Ihr Gerät ihm gegeben hat. + + + Welche Spiele Sie beansprucht haben und wann. + + + Keine E-Mail-Adresse, kein Passwort, kein an Ihr Konto gebundenes IP-Protokoll. Verlieren Sie alle Passkeys, können Sie einen frischen Anspruchs-Token in Ihrem Spiel veröffentlichen und von vorn beginnen: Der Beweis ist das Spiel, nicht das Konto. + + + {day}. {month} {year} + + + {date} {time} UTC + + + jetzt + + + {count, plural, one {# Min} other {# Min}} + + + {count, plural, one {# Std} other {# Std}} + + + {count, plural, one {# T} other {# T}} + + + {count, plural, one {# W} other {# W}} + + + {count, plural, one {# Mon} other {# Mon}} + + + {count, plural, one {# J} other {# J}} + + + gerade eben + + + {count, plural, one {vor # Min} other {vor # Min}} + + + {count, plural, one {vor # Std} other {vor # Std}} + + + {count, plural, one {vor # T} other {vor # T}} + + + {count, plural, one {vor # W} other {vor # W}} + + + {count, plural, one {vor # Mon} other {vor # Mon}} + + + {count, plural, one {vor # J} other {vor # J}} + + + gerade eben + + + {count, plural, one {vor # Min} other {vor # Min}} + + + {count, plural, one {vor # Std} other {vor # Std}} + + + {count, plural, one {vor # T} other {vor # T}} + + + {count, plural, one {vor # W} other {vor # W}} + + + {count, plural, one {vor # Mon} other {vor # Mon}} + + + {count, plural, one {vor # J} other {vor # J}} + + + {age}, {stamp} + + + , {stamp} + + + {value} — {how}, Quelle: {source}, zuletzt bestätigt {date} + + + {value} — {how}, Quelle: {source}, zuletzt bestätigt {date} (Aktualisierung überfällig) + + + ({how}, {age}) + + + ({how}, {age}, überfällig) + + + vom Betreiber angegeben + + + Redaktion + + + der Telnet-Handshake + + + der Betreiber + + + WHO + + + I3 + + + MSSP + + + INFO + + + die I3-Mudlist + + + der Verbindungsbildschirm + + + Das Ökosystem + + + Anteile, niemals Gesamtzahlen. Wir veröffentlichen keine Zahl dazu, wie viele Menschen MU* spielen: Ein Verhältnis über die von uns gemessenen Spiele übersteht die, die wir nicht erreichen können; eine Kopfzahl nicht. + + + {count, plural, one {{value} Spiel eingetragen} other {{value} Spiele eingetragen}} + + + {count, plural, one {{value} Spiel, dessen Handshake wir abgeschlossen haben} other {{value} Spiele, deren Handshake wir abgeschlossen haben}} + + + {count, plural, one {{value} Spiel, dessen MSSP-Bericht uns vorliegt} other {{value} Spiele, deren MSSP-Bericht uns vorliegt}} + + + Ältester Handshake hier: bestätigt vor {age}. + + + {count, number} von {total, number} ({fraction, number, ::percent .0}) + + + {count, number} von {total, number} — noch nichts gemessen + + + {fraction, number, ::percent .0} + + + Codebases + + + Von {listed, plural, one {# eingetragenen Spiel} other {# eingetragenen Spielen}} haben uns {identified, number} mitgeteilt, was sie ausführen, und jeder Anteil unten bezieht sich auf diese {identified, number}. Eine Codebase, die wir nicht lesen konnten, bleibt aus dem Nenner heraus und wird nie als etwas anderes gezählt. + + + Noch kein eingetragenes Spiel hat uns seine Codebase mitgeteilt. + + + {share} führen eine Codebase aus, die kein anderes eingetragenes Spiel ausführt — je ein Spiel, was ein Name ist und kein Anteil. Sie stecken im Nenner oben und sind aus den Balken herausgefaltet, nicht entfernt: + + + Abstammungen + + + Dieselben Spiele, gruppiert nach der Tradition, von der ihr Server abstammt — unsere Lesart der Codebase, nicht etwas, das ein Spiel veröffentlicht hätte. Kein Spiel meldet „MUSH“: MSSP kennt keinen solchen Wert, und der größte Teil der MUSH-Welt veröffentlicht überhaupt kein MSSP, deshalb lässt sich die Frage nur so stellen. + + + Noch kein eingetragenes Spiel führt eine Codebase aus, die wir einer Abstammung zuordnen. + + + {count, plural, one {# dieser Spiele führt eine Codebase aus} other {# dieser Spiele führen Codebases aus}}, die wir keiner Abstammung zuordnen — mehrere sagen das selbst und veröffentlichen {family}. Sie stecken im Nenner oben und in niemandes Anteil. + + + Protokolle + + + Jede gemessene Zahl unten ist als Untergrenze zu lesen. MSSP fragen wir namentlich an, dort ist Schweigen also eine Antwort. Sonst wird hier nichts angefragt, und ein Server kann ein Protokoll unterstützen, ohne es je anzubieten. + + + {instrument} ist die einzige Zeile unten, die keine Untergrenze ist: Wir fragen jeden Server namentlich danach, die Spiele, die es nicht angeboten haben, wurden also gefragt und haben abgelehnt. Es ist zugleich die einzige ohne angegebene Zahl, denn jedes Spiel, dessen Bericht uns vorliegt, unterstützt es durch Vorführung, und eine Zählung derer, die es zusätzlich aufgeführt haben, würde daran nur eine Gewohnheit messen. + + + Uns liegen {reports, number} Berichte vor, und {offered, plural, one {# Spiel bietet} other {# Spiele bieten}} MSSP heute an: Die übrigen {gap, number} haben nach unserem Lesen aufgehört, einen zu veröffentlichen, und ein Bericht wird nicht verworfen, weil er nicht mehr neu ausgegeben wird. + + + Verbreitung der Protokolle. Gemessen ist, was ein Server in einem abgeschlossenen Handshake angeboten hat; angegeben ist, was sein MSSP behauptet. Zwei Mengen von Spielen, also zwei Nenner. + + + Protokoll + + + gemessen — von {basis} + + + angegeben — von {basis} + + + nicht gemessen — nie beobachtet + + + {share} · {declined, plural, one {# Spiel hat} other {# Spiele haben}} auf Nachfrage abgelehnt + + + {share} · bei {unobserved, plural, one {# Spiel} other {# Spielen}} weder angeboten noch nachgefragt + + + {share} · {declined, plural, one {# Spiel hat} other {# Spiele haben}} auf Nachfrage abgelehnt · bei {unobserved, plural, one {# Spiel} other {# Spielen}} weder angeboten noch nachgefragt + + + nicht nachgefragt — jeder Bericht hier ist die Antwort + + + Verbreitung im Zeitverlauf + + + Jeder Punkt ist ein Anteil an den Spielen, die wir an jenem Tag gemessen hatten; diese Linie bewegt sich also aus zwei Gründen: weil ein Spiel ändert, was es anbietet, und weil sich die Menge der messbaren Spiele darum herum ändert. Nur das Erste ist Verbreitung. Die Zahl der Wechsel unten ist der Teil, der rein aus Spielen besteht, die es sich anders überlegt haben. + + + Gemessener Anteil je Protokoll, älteste Messung zuerst + + + damals + + + jetzt + + + nicht gemessen + + + Eine Momentaufnahme, keine Kurve + + + Eine Momentaufnahme dessen, was wir jetzt messen können. Eine Verbreitungskurve zeichnet Spiele, die es sich anders überlegen; wir halten eine Änderung fest, wenn sie geschieht, die Kurve wird also zeichenbar, sobald genug davon erfasst sind. Zu zeichnen, wann wir jedes Spiel zuerst erreicht haben, würde den Crawl messen und nicht das Hobby. + + + {count, plural, one {# geänderte Fähigkeit} other {# geänderte Fähigkeiten}} bisher erfasst — das Material, aus dem eine Kurve gezeichnet wird. + + + Noch hat sich keine gemessene Fähigkeit geändert, es gibt also nichts zu zeichnen. + + + Gemessen bezieht sich auf {measured}; angegeben auf {declared}. Zwei Mengen von Spielen, also zwei Nenner. + + + {listed} · {handshakes} · {mssp}. + + + Der älteste Handshake in diesem Bild wurde zuletzt vor {age} bestätigt. + + + Dieselben Spiele, gruppiert nach der Tradition, von der ihr Server abstammt. Das ist {evidence} — {meaning} — und nicht etwas, das ein Spiel veröffentlicht hätte: Kein Spiel meldet „MUSH“, denn MSSP kennt keinen solchen Wert, und der größte Teil der MUSH-Welt veröffentlicht überhaupt kein MSSP. + + + gemessen: {value} + + + angegeben: {value} + + + Ranglisten + + + Ausschließlich aus gemessenen Daten berechnet. Keine Stimmen, Sterne oder Bewertungen, niemals. Nichts hier ordnet nach Qualität. Die haben wir nicht gemessen. + + + Am belebtesten, nach gemessenen gleichzeitigen Spielern + + + Zeitraum der Rangliste + + + {days, plural, one {# Tag} other {# Tage}} + + + Median der Spielerzahlen, die wir in {days, plural, one {dem letzten # Tag} other {den letzten # Tagen}} gemessen haben. + + + Noch kein Spiel hat die {samples, number} Zählungen über {days, plural, one {# Tag} other {# Tage}}, die ein Median braucht. + + + {eligible, plural, one {# von {listed, number} Spielen hat} other {# von {listed, number} Spielen haben}} die nötigen {samples, number} Zählungen über {days, plural, one {# Tag} other {# Tage}}. + + + Eine gemessene Null zählt; eine unlesbare Zählung nicht. + + + Eine Woche sagt, wer jetzt belebt ist; ein Quartal sagt, wer belebt gewesen ist. Das sind verschiedene Fragen, und ein Spiel kann die eine anführen und die andere nicht. Tage sind ganze Tage, in UTC. + + + Noch hat kein eingetragenes Spiel genug Zählungen für eine Rangfolge — eine Aussage darüber, wie lange wir schon messen, nicht darüber, wie belebt irgendjemand ist. + + + Spiele, geordnet nach dem Median der Spielerzahlen, die wir in {days, plural, one {dem letzten # Tag} other {den letzten # Tagen}} gemessen haben. Spiele mit demselben Median teilen sich einen Platz; nichts hier entscheidet den Gleichstand. + + + # + + + Spiel + + + Median + + + Spitze + + + Zählungen + + + gemessene Tage + + + Längste durchgehende Erreichbarkeit + + + Jede Abfrage seit dem genannten Datum hat das Spiel erreichbar vorgefunden. Erreichbar, nicht online: Wir messen einen Socket von einem einzigen Host aus, und ein Spiel, zu dem wir keine Route finden, ist dennoch völlig lebendig. Eine solche Strecke kann nicht länger sein, als wir zusehen, deshalb ist das Datum die Tatsache und die Dauer folgt daraus. + + + Zurzeit ist kein eingetragenes Spiel durchgehend erreichbar. + + + Spiele, bei denen jede Abfrage seit dem genannten Datum sie erreichbar vorgefunden hat. Spiele, die seit demselben Datum erreichbar sind, teilen sich einen Platz; nichts hier entscheidet den Gleichstand. + + + erreichbar seit + + + das sind + + + Archivierte Spiele sind aus beiden Tabellen heraus und aus sonst nichts; eine einzige erfolgreiche Abfrage bringt sie zurück. + + + Am belebtesten — Median der gemessenen Spieler, {days, plural, one {letzter # Tag} other {letzte # Tage}} + + + Zeiträume: + + + dieser + + + Median {median, number} · Spitze {peak, number} · {samples, plural, one {# Zählung} other {# Zählungen}} an {days, number} von {window, number} Tagen + + + bei jeder Abfrage seit {date} erreichbar · {duration} + + + Das Archiv + + + Spiele, die nicht mehr antworten. Nichts wurde gelöscht. Sie werden weiterhin wöchentlich abgefragt, und eine einzige erfolgreiche Abfrage bringt ein Spiel noch am selben Tag zurück ins Verzeichnis. + + + das Archiv durchsuchen + + + Archivierte Spiele suchen + + + Name, Codebase oder Beschreibung + + + zeigen + + + {count, plural, =0 {Keine archivierten Spiele} one {# archiviertes Spiel} other {# archivierte Spiele}} + + + archiviert + + + zuletzt erreichbar + + + nachweislich aktiv + + + (vor {age}) + + + Keine Treffer. + + + nie, in nichts, was wir gemessen haben + + + keine erreichbare Zeit gemessen + + + unbekannt + + + {years, number, ::.#} Jahre + + + {days, plural, one {# Tag} other {# Tage}} + + + {from} – {to} · {span} + + + {count, plural, one {# Monat} other {# Monate}} + + + {count, plural, one {# Jahr} other {# Jahre}} + + + {count, plural, one {# Spiel passt} other {# Spiele passen}} zu „{query}“ + + + {count, plural, one {# Spiel} other {# Spiele}} + + + Zuletzt erreichbar: + + + Nachweislich aktiv: + + + {value} gemessener erreichbarer Zeit + + + Laufzeit: + + + Codebase: + + + zufälliges Spiel + + + {d, date, medium} — durchschnittlich {typical}, {low}–{high} über {probes, plural, one {# Abfrage} other {# Abfragen}} + + + {d, date, medium} — {count, plural, =0 {0 Spieler} one {# Spieler} other {# Spieler}}, in jeder von {probes, plural, one {# Abfrage} other {# Abfragen}} + + + {d, date, medium} — abgefragt, keine Zählung ablesbar + + + {d, date, medium} — keine Messung + + + Typischerweise {typical} verbunden, Spitze {peak}, an {counted} von {days, plural, one {# Tag} other {# Tagen}}. + + + Über den Zeitraum hinweg gleichbleibend. + + + Vom Anfang des Zeitraums bis zum Ende um etwa {change, number, percent} gestiegen. + + + Vom Anfang des Zeitraums bis zum Ende um etwa {change, number, percent} gefallen. + + + In diesem Zeitraum abgefragt, und aus nichts davon war eine Spielerzahl ablesbar. + + + Keine Messung in diesem Zeitraum. + + + In diesem Zeitraum nichts gezählt. + + + {from, date, d MMM}–{to, date, d MMM} + + + {d, date, d MMM} + + + {span}: typischerweise {typical}, Spitze {peak}, {days, plural, one {# Tag} other {# Tage}} gezählt + + + {span}: abgefragt, keine Zählung ablesbar + + + {span}: nicht gemessen + + + {count, plural, one {# Tag ohne Zählung abgefragt} other {# Tage ohne Zählung abgefragt}} + + + {count, plural, one {# Tag nicht gemessen} other {# Tage nicht gemessen}} + + + {line}, {clause} + + + Tage in UTC · {from, date, medium} – {to, date, medium} + + + {value} ganz oben + + + {counted} von {days, plural, one {# Tag} other {# Tagen}} gezählt + + + {d, date, MMM} + + + {d, date, MMM yyyy} + + + Mittel der an dem Tag gelesenen Zählungen + + + bis zur höchsten Zählung des Tages + + + niedrigste bis höchste Zählung des Tages + + + abgefragt, keine Zählung ablesbar + + + nicht gemessen — gar kein Balken + + + nicht gemessen — eine Lücke in der Linie + + + Zeitraum des Verlaufs + + + Form des Verlaufs + + + {days, plural, one {# Tag} other {# Tage}} + + + ← früher + + + später → + + + Linie + + + Balken + + + Wie viele, im Zeitverlauf + + + {from, date, medium} – {to, date, medium}, UTC + + + früher + + + eine Woche wird über die in ihr gezählten Tage zusammengefasst; eine Woche ohne solche sagt das + + + erreichbar · {days, plural, one {letzter # Tag} other {letzte # Tage}} + + + erreichbar + + + längste Unterbrechung + + + letzte Ursache + + + keine im Zeitraum + + + nichts festgehalten + + + {days, plural, one {vor # Tag} other {vor # Tagen}} + + + heute + + + erreichbar + + + eingeschränkt — geantwortet, nicht abgeschlossen + + + nicht erreichbar + + + nicht gemessen + + + erreichbar + + + eingeschränkt + + + nicht erreichbar + + + nicht gemessen + + + {d, date, d MMM} — den ganzen Tag erreichbar + + + {d, date, d MMM} — eingeschränkt ({cause}): geantwortet, nicht abgeschlossen + + + {d, date, d MMM} — nicht erreichbar ({cause}) + + + {d, date, d MMM} — nicht gemessen; wir haben dieses Spiel damals noch nicht beobachtet + + + {days, plural, one {Für den letzten # Tag noch nicht gemessen.} other {Für die letzten # Tage noch nicht gemessen.}} + + + {days, plural, one {Erreichbar in {percent} des letzten Tages.} other {Erreichbar in {percent} der letzten # Tage.}} + + + {days, plural, one {Erreichbar in {percent} des einen Tages, den wir gemessen haben.} other {Erreichbar in {percent} der # Tage, die wir gemessen haben.}} + + + {days, plural, one {Die Erreichbarkeit des letzten Tages ist noch nicht gemessen.} other {Die Erreichbarkeit der letzten # Tage ist noch nicht gemessen.}} + + + An keinem Tag im Zeitraum war das Spiel nicht erreichbar. + + + An keinem von uns gemessenen Tag war das Spiel nicht erreichbar. + + + {count, plural, one {# Tag nicht erreichbar.} other {# Tage nicht erreichbar.}} + + + {count, plural, one {# Tag eingeschränkt — wir sind hineingekommen und konnten nicht abschließen.} other {# Tage eingeschränkt — wir sind hineingekommen und konnten nicht abschließen.}} + + + Längste Unterbrechung {duration}. + + + Längste Unterbrechung {duration} ({cause}). + + + {count, plural, one {# Tag liegt vor allem, was wir gemessen haben.} other {# Tage liegen vor allem, was wir gemessen haben.}} + + + {from, date, d MMM} – {to, date, d MMM} + + + {d, date, d MMM} + + + {range}: {count, plural, one {# Tag} other {# Tage}} {word} + + + {range}: {count, plural, one {# Tag} other {# Tage}} {word} ({cause}) + + + Erreichbar + + + {days, plural, one {letzter # Tag} other {letzte # Tage}} + + + Erreichbar: {percent} {days, plural, one {des letzten Tages} other {der letzten # Tage}} + + + Längste Unterbrechung: {duration} + + + dns hat nicht aufgelöst + + + Verbindung abgelehnt + + + tls fehlgeschlagen + + + Zeitüberschreitung + + + Handshake hängen geblieben + + + keine Ursache festgehalten + + + Der Betreiber hat uns gebeten, den Verbindungsbildschirm dieses Spiels nicht erneut zu veröffentlichen. + + + Von diesem Spiel wurde kein Verbindungsbildschirm aufgezeichnet. + + + {count, plural, one {Nur # Zeile kam zurück — zu wenig zum Anzeigen.} other {Nur # Zeilen kamen zurück — zu wenig zum Anzeigen.}} + + + so, wie der Server ihn gesendet hat + + + {columns}×{rows} + + + {columns}×{rows}, doppelte Breite + + + 16-Farben-SGR + + + keine Farbe + + + gelesen als {charset} + + + aufgezeichnet + + + eingefroren — der letzte Bildschirm, den wir gesehen haben + + + ASCII-Grafik: der Verbindungsbildschirm von {game}. Sein Text steht unten unter „als Text lesen“. + + + ASCII-Grafik: der Verbindungsbildschirm dieses Spiels. Sein Text steht unten unter „als Text lesen“. + + + {count, plural, one {Verbindungsbildschirm: # Zeile, nur Text} other {Verbindungsbildschirm: # Zeilen, nur Text}} + + + {count, plural, one {Verbindungsbildschirm: # Zeile, nur Text, gelesen als {charset}} other {Verbindungsbildschirm: # Zeilen, nur Text, gelesen als {charset}}} + + + das Spiel gibt es an, und der Server hat es in keinem Handshake je angeboten. + + + der Server bietet es an, und die eigene Angabe des Spiels sagt, dass er es nicht tut. + + + Meist ein veraltetes, von Hand getipptes Feld und keine Lüge. Gezeigt, weil ein Client sich auf das, worin beide sich widersprechen, nicht verlassen sollte. + + + Nicht gefunden + + + Kein Spiel unter dieser Adresse. Schreibweise prüfen. + + + zuletzt geantwortet + + + {count, plural, one {# ist über das Aktualisierungsfenster hinaus. Alt, nicht falsch.} other {# sind über das Aktualisierungsfenster hinaus. Alt, nicht falsch.}} + + + Die eigene Verweisliste dieses Spiels nennt: + + + Genannt von der Verweisliste von: + + + seit {date} + + + nicht mehr gelistet, zuletzt gesehen + + + Zuletzt geantwortet am {date}, vor {ago}. + + + Wird weiterhin wöchentlich abgefragt; diese Seite aktualisiert sich an dem Tag, an dem es antwortet. + + + {span} nachweislich aktiv + + + Nicht im Verzeichnis, nicht in den Ranglisten und nicht in der Tageszahl: Wir halten diese Adresse nicht für ein Spiel, das sich spielen lässt. Alles unten ist das, was sie uns mitgeteilt hat, unverändert. + + + Unser Grund: {why} + + + Nicht im Verzeichnis, nicht in den Ranglisten und nicht in der Tageszahl, auf Bitte der Leute, die es betreiben. Alles unten bleibt erhalten, wie es war, diese Seite und jede Adresse, die es je hatte, antworten weiterhin, und wir wählen es nicht an. + + + Jetzt verbunden: {count} + + + Fähigkeiten ({disagreeing} von {total} widersprechen) + + + Vom Spiel angegeben + + + Verbindungsbildschirm + + + Was sich geändert hat + + + gemessen + + + angegeben + + + ** widersprechen + + + Verbindungen + + + erreicht + + + Suche + + + archiviert + + + Erwachseneninhalte + + + einbezogen + + + keine Zählung + + + keine aktuelle Zählung, und nichts hier nennt einen Grund + + + Jetzt verbunden: keine Zählung (nichts hier nennt einen Grund) + + + Crawler aktiv · letzte Abfrage {age} + + + Crawler im Leerlauf · letzte Abfrage {age} + + + hier ist noch keine Abfrage abgeschlossen + + + in diesem Zyklus nichts fällig + + + {considered, plural, one {# fällig} other {# fällig}} · {answered, plural, one {# geantwortet} other {# geantwortet}} · {failed, plural, one {# fehlgeschlagen} other {# fehlgeschlagen}} + + + {targets, plural, one {# Adresse im Register} other {# Adressen im Register}}, {due, plural, one {# jetzt fällig} other {# jetzt fällig}} + + + {page} — {site} + + + Ein Verzeichnis des MU*-Hobbys — MUSHes, MUDs, MUCKs, MOOs — in dem jede Tatsache ihre Herkunft und ihr Alter nennt. + + + Demodaten — nichts davon wurde gemessen. {description} + + + {site} — gemessen, nicht behauptet + + + {title} auf {site} + + + Spiele + + + Das Archiv + + + Ranglisten + + + Das Ökosystem + + + Referenz + + + Über uns + + + Nicht gefunden + + + Zufälliges Spiel + + + Ihre Spiele + + + {game} beanspruchen + + + Jedes MU*, das wir erreicht haben, gefiltert nach Gemessenem: Codebase, Protokolle aus dem Handshake, TLS, Kodierung, Sprache, letzter Zugang. + + + Verstummte Spiele, aufbewahrt. Jedes behält Seite, Verlauf und URL, wird wöchentlich abgefragt und kehrt bei einer erfolgreichen Verbindung zurück. + + + Am belebtesten, am besten erreichbar, am längsten laufend — allein aus Messungen berechnet. Keine Stimmen, Sterne oder Bewertungen auf dieser Website. + + + Codebase-Anteile und Protokollverbreitung über die von uns gemessenen Spiele, das Angebotene neben dem Angegebenen. Anteile, niemals Gesamtzahlen. + + + Von Hand geschriebene Seiten zu den Codebases, Clients und Protokollen des MU*-Hobbys, verlinkt mit Zahlen aus dem Crawl. + + + Wie dieser Katalog entsteht: was der Crawler tut, was er zu tun verweigert und wie er sich stoppen lässt. + + + Kein Spiel unter dieser Adresse. Hier wird nichts gelöscht, ein Spiel, das einmal unter dieser URL lag, liegt also weiterhin dort — Schreibweise prüfen. + + + Ein Spiel aus dem Katalog, zufällig gewählt und nie zweimal dasselbe. + + + Die Einträge, die Sie beansprucht haben, und was ein Anspruch Ihnen zu ändern erlaubt. + + + Weisen Sie nach, dass Sie dieses Spiel betreiben, indem Sie einen Token dort veröffentlichen, wo nur sein Betreiber ihn hinstellen könnte. + + + Archiviert — zuletzt erreichbar {age}, wird weiter abgefragt + + + Archiviert, wird weiter abgefragt + + + Spielerzahl unbekannt — das Spiel antwortet und veröffentlicht keine Zahl, die wir lesen können + + + {count, plural, one {# Spieler} other {# Spieler}}, {how} {age} + + + Neu entdeckt + + + Verstummt + + + Wieder da + + + {count, plural, one {# Spiel bekannt} other {# Spiele bekannt}} + + + {count, plural, one {# jetzt verbunden (gemessen)} other {# jetzt verbunden (gemessen)}} + + + {count, plural, one {# antwortend, ungezählt} other {# antwortend, ungezählt}} + + + {count, plural, one {# archiviert, wird weiter abgefragt} other {# archiviert, werden weiter abgefragt}} + + + Referenz + + + Was die Codebases sind, was die Clients tun und was die Protokolle bedeuten. Von Hand geschrieben und im Repository neben dem Crawler abgelegt — kein Wiki, und auf dieser Seite gibt es nichts zu bearbeiten. Jede {number} hier ist etwas anderes: Sie stammt aus dem Katalog und wird bei jedem Aufruf der Seite neu berechnet. + + + Zahl + + + Von Hand geschrieben, namentlich gezeichnet und in git versioniert. Der Text hier ist unserer; jede Zahl daneben wurde vom Crawler gemessen und wird bei jeder Anfrage neu berechnet. Das ist kein Wiki, und von dieser Seite aus lässt sich nichts bearbeiten. + + + Hier anfangen + + + Codebases + + + Clients + + + Protokolle + + + Orientierung + + + Codebase + + + Client + + + Protokoll + + + Nicht gefunden + + + Hier gibt es keine Referenzseite. Dieser Abschnitt ist von Hand geschrieben; eine Lücke ist also Arbeit, die niemand gemacht hat, und nichts, was entfernt worden wäre — {index}. + + + sehen, was es gibt + + + Spiele, die sie ausführen + + + Wir haben noch keines identifiziert. Das ist eine Tatsache darüber, was dieser Crawler gemessen hat, und keine darüber, was es gibt — ein Spiel, das wir nicht erreicht haben oder dessen Codebase wir nicht lesen konnten, wird hier nicht mitgezählt. + + + {count, plural, one {# eingetragen} other {# eingetragen}} + + + {count, plural, one {# archiviert} other {# archiviert}} + + + gemessen, nie behauptet + + + Bei dieser Anfrage aus dem Katalog gezählt, über denselben Filter, den der Link oben trägt — diese Zahl und jenes Verzeichnis stammen also aus einer einzigen Abfrage und können nicht auseinanderlaufen. + + + in ihren Handshakes angeboten: {protocols} + + + Gemessene Verbreitung + + + Noch nichts gemessen. + + + von {listed, plural, one {# eingetragenen Spiel} other {# eingetragenen Spielen}} — {percent} + + + Die Spiele, die hier nicht mitgezählt sind, sind keine Spiele ohne das Protokoll. Ein Spiel zählt hier, wenn sein Server die Option in einem von uns beobachteten Handshake angeboten hat; der Rest sind Server, die sie uns nicht angeboten haben, und Server, deren Handshake wir nicht gelesen haben, und welches davon zutrifft, können wir Ihnen nicht sagen. + + + Spiele, die {protocol} in einem von uns beobachteten Handshake angeboten haben, nach der Codebase, die wir bei ihnen identifiziert haben. + + + Codebase + + + angeboten + + + identifiziert + + + Siehe auch + + + Fähigkeiten + + + {count, plural, one {# von {total} aus der Dokumentation des Projekts selbst festgestellt} other {# von {total} aus der Dokumentation des Projekts selbst festgestellt}} + + + Aus der Dokumentation des jeweiligen Projekts abgelesen, nicht von uns gemessen — ein Client hat keinen Handshake, den wir beobachten könnten. „{unknown}“ heißt, dass wir nachgesehen und es nicht feststellen konnten. Es heißt nie nein. + + + Fähigkeiten der Clients, jede aus der Dokumentation des Projekts selbst abgelesen. Unbekannt heißt, dass wir sie nicht feststellen konnten, und nie, dass sie dem Client fehlt. + + + dokumentiert + + + Quelle + + + wir haben keine gefunden + + + ja + + + nein + + + unbekannt + + + Läuft auf: {platforms} + + + Spiele, bei denen wir diese Codebase identifiziert haben + + + Noch keines. Das ist eine Aussage darüber, was wir gemessen haben, und keine darüber, was es gibt — ein Spiel, das wir nicht erreicht haben oder dessen Codebase wir nicht lesen konnten, wird hier nicht mitgezählt. + + + {listed, plural, one {# eingetragen} other {# eingetragen}}, {archived, plural, one {# archiviert} other {# archiviert}} + + + In ihren Handshakes gemessen: {protocols} + + + In keinem Handshake, den wir von ihnen gelesen haben, wurde etwas angeboten. + + + {offering} von {listed, plural, one {# eingetragenen Spiel} other {# eingetragenen Spielen}} haben es in einem von uns beobachteten Handshake angeboten ({percent}) + + + Nach Codebase, unter den Spielen, die wir identifiziert haben + + + {offering} von {identified} haben es angeboten + + + {count, plural, one {# von {total} Zeilen ist unbekannt} other {# von {total} Zeilen sind unbekannt}}: Wir haben in der Dokumentation des Projekts selbst nichts gefunden, was das eine oder das andere sagt. Eine kurze ehrliche Tabelle ist besser als eine lange geratene. + + + Ihre Spiele + + + Konten brauchen eine Datenbank, und diese Website läuft auf den Demodaten. + + + Anmelden + + + Angemeldet als {name}. + + + Abmelden + + + Sie haben noch nichts beansprucht. Suchen Sie Ihr Spiel in {listing} und drücken Sie auf seiner Seite {claimControl}. + + + dem Verzeichnis + + + Dieses Spiel beanspruchen + + + Aufgegeben. + + + Der Vermerk darüber bleibt erhalten, und Sie können jederzeit erneut nachweisen, dass Sie das Spiel betreiben, indem Sie einen frischen Token veröffentlichen. + + + Gespeichert. + + + Dieses Spiel + + + Die Seite von {game} zeigt es jetzt als vom Betreiber angegeben. + + + Wir veröffentlichen den Verbindungsbildschirm von {game} nicht mehr erneut. Die Seite sagt das deutlich, statt eine Leerstelle zu lassen. + + + Der Verbindungsbildschirm von {game} steht wieder auf seiner Seite. + + + Wir wählen {game} nicht mehr an, unter keiner Adresse, die wir dafür haben. Seine Seite behält alles, was vor Ihrer Bitte gemessen wurde. + + + Wir wählen {game} wieder an, ab seinem nächsten Termin im Zeitplan. + + + {game} ist aus dem Verzeichnis, aus den Ranglisten und aus der Tageszahl heraus. Seine Seite und jede URL, die es je hatte, antworten weiterhin. + + + {game} steht wieder im Verzeichnis. Eine einzige Abfrage, die eine Antwort erhält, genügt, damit es wieder gemessen wird. + + + {field} wurde nicht geändert. + + + Das sind einzeilige Angaben; mehr als {max} Zeichen speichern wir nicht. + + + Dieses Feld ist gemessen. Ein Anspruch erlaubt Ihnen hinzuzufügen, wofür MSSP keinen Platz hat; er erlaubt niemandem, das zu ändern, was wir beobachtet haben, uns eingeschlossen. + + + Beansprucht + + + bestätigt {date} + + + bestätigt {date}, Token zuletzt gesehen {seen} + + + Ihr MSSP prüfen + + + {count, plural, one {Außerdem beansprucht von {names} — mit einem eigenen bestätigten Token.} other {Außerdem beansprucht von {names} — jeweils mit einem eigenen bestätigten Token.}} + + + ein anderes Konto + + + Ihre Spielerzahl auf Ihrer eigenen Website zeigen + + + Das Abzeichen trägt die Zählung und den Zeitpunkt, an dem wir sie gemessen haben, denn eine Zahl ohne Alter ist genau das, was diese Website ersetzen soll. + + + Es sagt {unknown} statt null, wenn wir nicht zählen konnten, und {archived}, wenn das Spiel nicht mehr antwortet. + + + Es gibt auch {json}, falls Sie lieber selbst zeichnen möchten. + + + Verlauf + + + Token ausgegeben + + + Token erneut ausgegeben + + + bestätigt — wir haben Ihren Token gelesen + + + Token weiterhin veröffentlicht + + + Token diesmal nicht gelesen + + + Anspruch aufgegeben + + + Token ungenutzt abgelaufen + + + ein anderes Konto hat nachgewiesen, dass es das Spiel betreibt, und es übernommen + + + Prüfung angefragt + + + diesen Anspruch aufgeben + + + Tippen Sie {word} zur Bestätigung. Nichts wird gelöscht, und Sie können erneut nachweisen, dass Sie das Spiel betreiben, indem Sie einen frischen Token veröffentlichen; das Spiel bleibt beansprucht, solange es noch jemand anderes beansprucht. + + + {game} aufgeben + + + Warten auf einen Token + + + Token ausgegeben {issued}, gültig bis {expires} + + + Passkeys + + + ohne Namen + + + hinzugefügt {date} + + + hinzugefügt {date} · nur auf einem Gerät + + + Dieser Passkey liegt auf einem einzigen Gerät. Verlieren Sie ihn, kommen Sie weiterhin hinein, indem Sie einen frischen Token in Ihrem Spiel veröffentlichen, aber ein zweiter Passkey geht schneller. + + + Einen weiteren Passkey hinzufügen + + + Kein solches Spiel + + + {game} beanspruchen + + + Das Beanspruchen braucht eine Datenbank, und diese Website läuft auf den Demodaten. + + + Dafür brauchen Sie zuerst ein Konto. Es braucht einen Passkey und einen Namen. + + + Anmelden oder ein Konto anlegen + + + Ihre Spiele + + + {count, plural, one {Dieses Spiel hat bereits einen Betreiber, der den Zugriff auf den Server nachgewiesen hat.} other {Dieses Spiel hat bereits # Betreiber, die den Zugriff auf den Server nachgewiesen haben.}} Sie können ihn ebenfalls nachweisen — die Prüfung ist in beiden Fällen dieselbe —, aber wir müssen wissen, was Sie damit meinen, denn am Token ist es nicht zu erkennen. + + + Ich betreibe es auch — mich als Betreiber eintragen + + + Alle behalten ihren Anspruch. Das sind zwei Menschen, die ein Spiel betreiben. + + + Ich habe es übernommen — auf mich übertragen + + + {count, plural, one {Sobald Ihr Token bestätigt ist, wird der bestehende Anspruch widerrufen und das Spiel gehört Ihnen.} other {Sobald Ihr Token bestätigt ist, werden die bestehenden Ansprüche widerrufen und das Spiel gehört Ihnen.}} In ihrem eigenen Verlauf steht, warum. Nichts wird gelöscht, und sie können erneut nachweisen, dass sie es betreiben, auf demselben Weg, den Sie gerade gehen. + + + Bestätigt. + + + Wir haben Ihren Token am {date} aus dem MSSP-Bericht des Spiels gelesen. + + + Wir haben Ihren Token am {date} vom Verbindungsbildschirm gelesen. + + + Lassen Sie den Token, wo er ist. Er dient zugleich als Erkennungsmerkmal, damit dieses Spiel wiedererkennbar bleibt, wenn es den Host wechselt oder den Namen ändert. Ihn wegzunehmen hebt Ihren Anspruch nicht auf. + + + Veröffentlichen Sie diesen Token irgendwo, wo das Spiel ihn einer anonymen Verbindung zeigt. Die nächste Abfrage liest ihn auf, und damit ist nachgewiesen, dass Sie auf diesen Server schreiben können. + + + Das ist eine Übertragung. + + + {count, plural, one {Sobald wir diesen Token lesen, wird der Anspruch des bisherigen Betreibers auf dieses Spiel widerrufen und es gehört Ihnen.} other {Sobald wir diesen Token lesen, werden die Ansprüche der bisherigen Betreiber auf dieses Spiel widerrufen und es gehört Ihnen.}} + + + Eines von beidem genügt + + + Eine MSSP-Variable + + + In {codebase} ist das eine Zeile in {file}; jede Codebase mit MSSP hat eine Entsprechung. + + + {aliases} werden ebenfalls angenommen. + + + Eine Zeile auf dem Verbindungsbildschirm + + + Irgendwo im Bildschirm, und Farbcodes darum herum sind kein Problem. + + + Dann + + + Wir prüfen im gewöhnlichen Crawl-Zeitplan. Dieser Token ist gültig bis {date}. Kommen Sie jederzeit wieder; nichts davon muss notiert werden. + + + Früher nachsehen + + + Bringt Ihr Spiel an den Anfang der Warteschlange. Wir wählen nach unserem eigenen Zeitplan an, das heißt also früher und nicht jetzt. + + + Gerade eben schon angefragt. Versuchen Sie es in ein paar Minuten wieder — es ist begrenzt, weil es einen echten Server früher anwählt, als wir es getan hätten. + + + Was nur Sie uns über {game} sagen können + + + Das sind die Dinge, für die MSSP kein Feld hat. Sie erscheinen auf der Seite Ihres Spiels als {declared}, mit dem Datum, an dem Sie sie zuletzt bestätigt haben, neben dem, was wir gemessen haben — nie an dessen Stelle. Nichts Gemessenes lässt sich von hier aus ändern, weder von Ihnen noch von uns. + + + angegeben {age}. Leeren Sie dieses Feld, um die Angabe zurückzuziehen — was darin stand, bleibt so oder so festgehalten. + + + Ihre Angaben speichern + + + Was {game} meldet und was Sie stattdessen gezeigt sehen möchten + + + Ihr MSSP ist das, was jeder Crawler liest, und wir zeigen es weiterhin neben allem, was Sie hier eintragen — eine Angabe von Ihnen verdeckt keine Ihres Spiels. + + + Nichts Gemessenes lässt sich von hier aus ändern: keine Spielerzahl, keine Fähigkeit, keine Stunde Erreichbarkeit. + + + Ist eine Zeile unten in Ihrer {file} falsch, dann genügt es, sie dort zu berichtigen — damit stimmt sie überall. + + + Ihr Spiel meldet {value}, bestätigt {age} + + + Ihr Spiel meldet hier nichts + + + Den Namen zu ändern ändert, unter welchem Namen {game} eingetragen ist, und die Adresse seiner Seite. Die alte Adresse funktioniert für immer weiter — jede URL, die dieses Spiel je hatte, leitet auf die aktuelle weiter —, und wenn Sie das Feld leeren, gilt wieder der Name, den Ihr MSSP nennt. + + + Ihr Verbindungsbildschirm + + + Wir veröffentlichen ihn nicht erneut. Die Seite sagt das deutlich, statt eine Leerstelle zu lassen, und der Crawler liest ihn weiterhin — daran erkennen wir Ihr Spiel wieder, wenn es umzieht. + + + Wieder zeigen + + + Wir zeigen ihn, weil Ihr Server ihn an jede anonyme Verbindung sendet. Ist Ihnen das nicht recht, sagen Sie es, und wir hören auf. Wir fragen nicht nach dem Grund. + + + Unseren Verbindungsbildschirm nicht mehr zeigen + + + Gecrawlt werden + + + Wir haben aufgehört. Nichts an {game} wird angewählt, und die Seite behält alles, was vor Ihrer Bitte gemessen wurde — die leeren Stunden nennen keine Ursache, denn {ourFact} ist eine Tatsache über uns und keine Messung an Ihrem Spiel. + + + dass Sie uns um den Stopp gebeten haben + + + Uns wieder crawlen + + + Dieser Ausschluss kam von Ihrem eigenen Server und nicht von hier — {routes}. Damit wir wieder crawlen, hören Sie auf, ihn zu veröffentlichen; wir bekommen das im nächsten Zyklus mit. + + + Ihr MSSP-Bericht veröffentlicht {variable} + + + ein TXT-Eintrag unter {label} bittet uns aufzuhören + + + eine von uns festgehaltene Bitte + + + Bei {stopped} haben wir aufgehört, und {dialling} wählen wir weiterhin an. Das ist meist ein Port, der nach dem Ausschluss hinzugekommen ist. + + + Auch unter jeder Adresse aufhören + + + Wir wählen {game} nach einem Zeitplan an und lesen, was jeder anonymen Verbindung gezeigt wird. Ist Ihnen das nicht recht, sagen Sie es, und wir hören auf — innerhalb eines Zyklus, unter jeder Adresse, die wir von Ihnen haben, und wir fragen nicht nach dem Grund. + + + Nichts bereits Gemessenes wird gelöscht: Ihre Seite behält ihre Geschichte und ihre URL, und eine einzige Abfrage, nachdem Sie das zurückgenommen haben, setzt es wieder in Gang. Sie können es auch ohne uns sagen, in Ihrer eigenen Konfiguration — {mssp} in MSSP oder ein TXT-Eintrag unter {dns} —, und wir befolgen das, ob dieses Spiel hier jemals beansprucht wurde oder nicht. + + + Uns nicht mehr crawlen + + + Eingetragen sein + + + {game} ist aus dem Verzeichnis, aus den Ranglisten und aus der Tageszahl heraus. Seine Seite und jede URL, die es je hatte, antworten weiterhin, und alles, was vor Ihrer Bitte gemessen wurde, steht weiterhin darauf. Es wurde nichts gelöscht; es ist nur kein Ort mehr, an dem man beim Stöbern ankommt. + + + Uns wieder ins Verzeichnis aufnehmen + + + Eine Abfrage, die eine Antwort erhält, tut das ebenfalls. Solange Ihr Ausschluss gilt, wählen wir nicht an, es wird also keine geben — aber an dem Tag, an dem Sie ihn zurücknehmen, kommt die Adresse binnen einer Woche an die Reihe, und die Abfrage, die eine Antwort erhält, bringt Sie zurück. Sie müssen uns kein zweites Mal fragen. + + + Wir wählen Sie nicht mehr an, und Ihre Seite steht weiterhin im Verzeichnis, mit dem, was wir davor gemessen haben. Ist Ihnen das nicht recht, sagen Sie es, und sie kommt heraus — aus dem Verzeichnis, aus den Ranglisten und aus der Tageszahl. + + + Nichts wird gelöscht und nichts geht kaputt: Die Seite antwortet, jede URL, die sie je hatte, leitet weiterhin auf sie, und wer von Ihnen dorthin geschickt wird, sieht sie. Sie ist nur kein Ort mehr, an dem ein Lesender beim Stöbern ankommt. Von hier aus rückgängig zu machen, und ebenso durch jede Abfrage, die nach der Rücknahme Ihres Ausschlusses eine Antwort erhält. + + + Uns auch aus dem Verzeichnis nehmen + + + was wir messen konnten + + + Diese auszublenden nimmt sie aus der Auflistung heraus; es heißt nicht, dass das Spiel leer ist. + + + ungezählt + + + nicht erreichbar + + + aus dieser Auflistung ausgeblendet + + + aus dieser Auflistung ausgeblendet + + + In der linken Spalte steht * für einen Wert, nach dem diese Auflistung gefiltert ist, und - für einen, gegen den gefiltert wird. Beides sind Entscheidungen in der Suche, keine Tatsachen über ein Spiel. + + diff --git a/src/MUI.Web/Resources/Messages.ja.resx b/src/MUI.Web/Resources/Messages.ja.resx new file mode 100644 index 0000000..84f452f --- /dev/null +++ b/src/MUI.Web/Resources/Messages.ja.resx @@ -0,0 +1,2877 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + {count, plural, other {#件のゲーム}} + + + {value}、{count, plural, other {#件のゲーム}}、のみ + + + {value}、{count, plural, other {#件のゲーム}}、除外 + + + {value}、{count, plural, other {#件のゲーム}} + + + {facet}を問わない、{count, plural, other {#件のゲーム}} + + + {count, plural, =0 {ここに掲載しているゲームはなし。} other {#件のゲーム。どの事実にも、その出所が記されている。}} + + + {days, plural, other {#日間実測}} · {probes, plural, other {#回の探査}} + + + {days, plural, other {#日}} · {count, plural, other {#件の計数}} + + + {disagreeing, plural, =0 {{total}件のうち不一致はなし。} other {{total}件のうち#件がゲームの自己申告と不一致。}} + + + {count, plural, =0 {すべての回答に一致するゲームはなし。} other {すべての回答に一致するゲームは#件。}} + + + {listed, plural, other {掲載#件のゲーム}}のうち · {answers, plural, =0 {回答なし} other {回答#件}} + + + {count, plural, other {この#件のゲームを表示}} + + + 「{answer}」を解除 — {count, plural, other {#件のゲーム}} + + + 回答を解除:{question} + + + ゲームを探す + + + すべての回答に一致 + + + {count, plural, other {ゲーム}} + + + 回答をすべて解除 + + + やり直す + + + {count, plural, other {他#件}} + + + 回答済み + + + 一覧全体 + + + そのクエリは拒否された + + + 名前(分かっていれば) + + + 名前、または名前の一部 + + + 名前で検索 + + + 今プレイしている人はいる? + + + 何をプレイしたい? + + + どんな種類のゲーム? + + + どの言語? + + + クライアントに必要なものは? + + + 消息不明のゲームも含める? + + + 問わない + + + ジャンルを問わない + + + 種類を問わない + + + 言語を問わない + + + 問わない + + + いいえ、ライブのゲームのみ + + + はい、それらも表示 + + + 消息不明のゲーム + + + TLS — 暗号化、ハンドシェイクは当サイトで完了 + + + MSSP — サーバーの自己申告 + + + MCCP — 出力の圧縮 + + + MXP — クリック可能なリンク + + + GMCP — 構造化クライアントデータ + + + MSDP — 構造化クライアントデータ + + + CHARSET — エンコーディングのネゴシエーション + + + UTF-8 — 非ラテン文字が表示される + + + TTYPE — クライアントが自分の種類を伝える + + + ATCP — 構造化クライアントデータ + + + MSP — サウンドのトリガー + + + EOR — プロンプトの区切り + + + {token} — ハンドシェイクで実測 + + + 実測 + + + 実測 + + + 実測 + + + 実測 + + + 実測 + + + 自己申告 + + + 自己申告 + + + 自己申告 + + + 自己申告 + + + 導出 + + + 導出 + + + 未計測 + + + 接続数読み取り不可 + + + 到達不可 + + + 読み取り不可 + + + 計数なし + + + ここから + + + アーカイブ済 + + + 申請済 + + + 選べるゲームがない + + + そのフィルターに一致するゲームはない。{listing}か、{archive}。 + + + 一覧全体 + + + アーカイブも含める + + + 接続数 + + + 所有者未申請 + + + 所有者により申請済み + + + 探査は継続中 + + + 通常 + + + ピーク + + + テキストで読む + + + プレーンテキスト + + + 本文へスキップ + + + ASCIIバナー:{game}の接続画面。 + + + カタログ + + + このサイトとアカウント + + + 閲覧 + + + 学ぶ + + + このサイト + + + メニュー + + + ゲーム + + + 探す + + + ランダム + + + アーカイブ + + + リファレンス + + + エコシステム + + + ランキング + + + 概要 + + + 登録 + + + ゲームを登録 + + + サインイン + + + 自分のゲーム + + + テーマ + + + 自動 + + + ライト + + + ダーク + + + デモデータ。 + + + データベースが未設定のため、これはダミーデータ。ここにある値は実測ではない。 + + + すべてのゲーム + + + アーカイブ + + + ゲームの自己申告 + + + 変更点 + + + MU*ホビーのディレクトリ + + + どの事実にも、その出所といつのものかが記されている:当サイトのクローラーが実測したものか、ゲームが自己申告し、そう明記したものか。 + + + 名前・テーマ・コードベース・ホストでゲームを検索 + + + 名前・テーマ・コードベース・ホストで検索 + + + 検索 + + + 既知のゲーム + + + 現在の接続数 + + + 応答あり・読み取り不可 + + + アーカイブ済み + + + 新規発見 + + + 消息不明 — 探査は継続中 + + + 復帰 + + + 新規なし。 + + + 消息不明になったゲームはなし。 + + + 復帰したゲームはなし。探査は続ける。 + + + ライブ + + + ゲーム + + + {order}順 + + + ランダム + + + 接続数 · 到達 + + + ここから + + + 該当なし。 + + + 語を減らすか、フィルターを外す。 + + + フィルターを解除 + + + {codebase}について + + + 到達なし + + + 所有者により申請済み + + + コードベース不明 + + + このゲームのコードベースは特定できなかった + + + ほか{count, plural, other {#件}}:{names} + + + 並び順 + + + 期間 + + + 現在 + + + 通常 + + + ピーク + + + 名前 + + + 到達 + + + 7日 + + + 30日 + + + 90日 + + + ゲームを検索 + + + ゲームを検索 + + + フィルター + + + 表示中 + + + すべて解除 + + + — この絞り込みを解除 + + + 問わない + + + 他のフィルター({count}) + + + {count, plural, other {他#件}} + + + 追加で表示 + + + 既定ではオフ。どちらもゲームへの評価ではない。 + + + アーカイブ済み + + + アダルト + + + アーカイブ済みのゲーム、{shown, select, true {表示} other {非表示}} + + + アダルト指定を自己申告したゲーム、{shown, select, true {表示} other {非表示}} + + + 件数は実測したゲーム数であり、推定値ではない。 + + + バッジと空欄の意味 + + + 空欄は実測の欠落であり、否定ではない。ファセットごとに表記が異なる:特定できず、申告なし、ネゴシエーションなし。 + + + 実測のゼロは計数である。接続数不明はゼロではなく、ゼロとして並べ替えられることもない。 + + + 値が定まらないファセットは最頻の{count}件の値のみを挙げる。残りは検索とURLから到達可能。 + + + 未チェックは未計測の意味 — ゲームに備わっていないという意味ではない。 + + + 活動 + + + 最終確認 + + + 提供プロトコル + + + 暗号化 + + + エンコーディング + + + コードベース + + + バージョン + + + 系統 + + + ファミリー + + + ジャンル + + + 言語 + + + 現在接続あり + + + 今週アクティブ + + + 閑散 — 計数は0を超えず + + + 消息不明 — 1か月間到達なし + + + アーカイブ済み + + + 24時間以内 + + + 7日以内 + + + 30日以内 + + + それ以前 + + + 一度も到達なし + + + ネゴシエーションなし + + + 特定できず + + + 申告なし + + + TLSで接続 + + + {value}以外 + + + ネゴシエーションあり + + + 特定済み + + + 申告あり + + + 実際に観測した + + + ゲームの申告であり、検証はしていない + + + ゲームの申告を分類したもの + + + 名前 + + + 現在の接続数 + + + 最終到達 + + + 通常の接続数 · 7日 + + + 通常の接続数 · 30日 + + + 通常の接続数 · 90日 + + + 同時接続ピーク · 7日 + + + 同時接続ピーク · 30日 + + + 同時接続ピーク · 90日 + + + 行の現在値 + + + 通常 + + + ピーク + + + 接続数不明 + + + 一度も到達なし — 到達が古いのではない + + + 期間内の計数が{minimum}件未満、または皆無 — 通常の接続数がゼロなのではない + + + 期間内に計数を読み取れず — 誰も接続していなかったのではない + + + 中央値 {value} · {days, plural, other {#日}} · {count, plural, other {#件の計数}} + + + 同時最大 {value} · {days, plural, other {#日}} · {count, plural, other {#件の計数}} + + + 接続画面 + + + 時間帯別の接続数 + + + 接続数の推移 + + + 到達可能 + + + 変更点 + + + 機能 + + + ゲームの自己申告 + + + リファラル + + + 所有者未申請 — ここにある情報はすべて実測。 + + + 所有者により申請済み — 以下の実測値は引き続き当サイトのもの。 + + + このゲームを申請する + + + {date}から応答 + + + テキストで読む — {count, plural, other {#行}} + + + 機能 + + + 経過 + + + 提供 + + + 無応答 + + + なし + + + 拒否 + + + 自己申告 + + + 不一致 + + + 不一致の箇所({count}) + + + {day} {time} — {count, plural, =0 {接続数0、実測} other {平均#人}} + + + {day} {time} — 探査したが接続数は読み取れず + + + {day} {time} — この時間は未計測 + + + このゲームの活動はまだ実測していない。 + + + 週のどの時間にも接続数は得られていない。 + + + すべての時間を実測し、どの時間も接続はなかった。 + + + {count, plural, other {{day}の#時間はまだ未計測。}} + + + {count, plural, other {週全体で#時間がまだ未計測。}} + + + {count, plural, other {{day}の#時間は応答したが接続数は読み取れず。}} + + + {count, plural, other {週全体で#時間が応答したが接続数は読み取れず。}} + + + 毎日、{window}が最も多い。 + + + 毎日、{part}の{window}が最も多い。 + + + {days}は{window}が最も多い。 + + + {days}は{part}、{window}が最も多い。 + + + {who}の{window}は常に接続が少ない。 + + + {who}の{part}、{window}は常に接続が少ない。 + + + 毎日 + + + 実測できたすべての日 + + + 平日 + + + {days} + + + 午前 + + + 午後 + + + 夜 + + + 深夜 + + + 午前 + + + 午後 + + + 夜 + + + 深夜 + + + {list}、{next} + + + {first}と{second} + + + 実測がまだ足りない + + + 週のどの時間もまだ未計測。 + + + {count, plural, other {#時間は応答したが接続数は読み取れず。}} + + + 週のすべての曜日に実測が入るとグリッドが表示される。 + + + {days, plural, other {これまでに7日のうち#日を実測。すべての曜日に1時間でも入るとグリッドが表示される。}} + + + {count, plural, other {#時間を実測、いずれも接続なし。}} + + + {count, plural, other {#時間を実測、最多は{day} {time} UTCの{peak}。}} + + + {count, plural, other {他に#時間が応答したが接続数は読み取れず。}} + + + {day} — {facts} + + + {first}、{second} + + + 終日ゼロを実測 + + + {time}にピーク{count} + + + {window}は接続なし + + + どの時間も接続数を読み取れず + + + {count, plural, other {#時間が未計測}} + + + {count, plural, other {#時間は探査したが読み取り不可}} + + + 曜日 + + + 最少 + + + 最多 + + + 時刻 + + + 読み取り不可 + + + 曜日別の接続数(UTC)。{window}。 + + + 時刻はUTC · {window} + + + {weeks, plural, other {#週の移動平均}} + + + 計数あり(実測のゼロを含む) + + + 探査したが接続数は読み取れず + + + その時間は未計測 + + + 人がいる時間帯(UTC) + + + 計数あり + + + 接続でき、数値を読み取れた(実測のゼロを含む) + + + 接続できたが、数値は読み取れなかった + + + その時間の実測がない + + + 言語 + + + 言語を変更 + + + mu*indexについて + + + ここにあるゲームはすべて、接続した機械が実測したもの。どの値にも、出所といつのものかが記されている。このページでは、それが何を証明するのか、何を誤ると分かっているのか、誰のディレクトリを読んでいるのか、そしてクローラーを止める方法を扱う。 + + + ここでの事実とは何か + + + 実測は自己申告に優先し、どちらも表示する。 + + + ゲームのMSSPレポートは、そのゲームが自分自身を説明したもの。telnetハンドシェイクは、当サイトがそのゲームの振る舞いを実際に見たもの。どちらもそのゲームのページに、方法と時期を添えて表示する。両者が食い違う場合は、その不一致を示す。 + + + 接続数は、その出所を明示する。 + + + 接続画面で読み取ったWHOかDOING(当サイトが数えたもの)、あるいはゲーム自身が公開したMSSPのPLAYERSフィールドのいずれか。両者を混ぜることはない。 + + + 読み取れなかった応答は不明であって、ゼロではない。 + + + サーバーはWHOのヘッダーを自由に書き換えるので、ある程度を超えると当サイトのパーサーは読み取れない。それは接続数読み取り不可という、それ自体で一つの状態。実測のゼロ — 接続でき、誰もいなかった — は計数であり、計数として表示する。 + + + 到達可能であって、稼働率ではない。 + + + 当サイトは1台のホストから一定の間隔でソケットを開く。経路が通らないゲームは到達不可であり、それでいて健在。実測していない以上、ここではどのゲームの稼働率も主張しない。 + + + 1時間は、計数あり、読み取り不可、未計測のいずれか。 + + + 活動グリッドには3つの状態がある。3つ目は空欄であり、原因を名指ししない:到達できなかった時間も、一度も探査しなかった時間も、同じ欠落であって、どちらもそのサーバーの停止時間ではない。 + + + 誤ると分かっていること + + + アーカイブまでの猶予は、こちらが見つけた日から数える。 + + + 応答しなくなったゲームは、猶予期間を過ぎると既定の一覧から外れる:猶予は探査した到達可能時間の4分の1で、下限60日、上限365日。1995年から動いているゲームでも、発見した日には下限から始まる。当サイトが来る前の年月を埋めるためのインポートは一切しない。 + + + MSSPのCREATEDはその猶予に算入しない。 + + + 設定ファイルに手で書かれた1行にすぎず、算入すればアーカイブのしきい値を操作できてしまう。自己申告として表示するが、それで得られるものはない。 + + + ゲームを申請すれば上限が得られる。 + + + サーバーへのアクセスを証明すれば、観測してきた期間の長さにかかわらず、猶予は満1年になる。 + + + ここにあるものはすべて、1台のホストが一定間隔で見た結果。 + + + 到達可能時間の割合は、観測した期間に対する割合であって、観測しなかった期間に対するものではない。ここにあるどの図も、残りを埋めることはしない。 + + + 削除は一切しない。 + + + アーカイブはゲームを既定の一覧、ランキング、本日アクティブの数値から外すだけで、それ以上のことはしない。ページ、URL、履歴、アドレスはそのまま残り、探査は続き、1回の探査が成功すれば元に戻る。 + + + このサイトがしないこと + + + 投票も、星も、評価も、おすすめもない。 + + + ランキングは実測データのみから算出する。最も多くクリックを動員できた者で順位が決まるディレクトリが表すのは、その動員活動であってこのホビーではない。先行したディレクトリを滅ぼしたのはそれだ。 + + + フォーラムも、レビューも、ウィキも、コメントも、プレイヤープロフィールもない。 + + + 入門的な解説 — MUSHとは何か、共同ロールプレイにはどのコードベースが向くか — は、サイトの他の部分と同じく、執筆され、署名され、バージョン管理される。 + + + プレイヤー名は一切保存しない。 + + + WHOの応答は、人数とヘッダーの形を得るためにメモリ上で解析する。名前は書き出さない。集計には、ローテーションするソルトを用いたソルト付きハッシュを使う。 + + + 人口の絶対数は公表しない。 + + + コードベース別・プロトコル別のシェアは公開する:実測した集合に対する比率は、計数できないゲームがあっても意味を保つ。「MU*を遊んでいる人は何人か」は保たない。その数字は引用に耐えないから。 + + + クローラーと、その止め方 + + + 探査とは、ログインしない1本の接続のこと。 + + + ソケットを開き、telnetオプションをネゴシエートし、接続画面を読み、オプション70のネゴシエーションでMSSPを要求し、{commands}を送り、切断する。キャラクターも作らず、ログインもせず、相手側では何も変わらない。セッションにはタイムアウトを設けてあり、詰まった探査が接続枠を占有し続けることはない。 + + + CRAWL DELAYが優先する。 + + + MSSPレポートで希望する最小間隔を示したゲームには、そのとおりにする。当サイト自身のスケジュールより双方向に優先する:720時間なら週次ではなく月次。消息不明のゲームも、その長い間隔で永久に試し続ける。復帰したときに自力で一覧に戻るのは、そのおかげ。 + + + 紹介されたアドレスは検証する。信用はしない。 + + + MSSPでは、ゲームが他のゲームを名指しできる。名前はダイヤルする前にすべて解決し、解決先のアドレスがすべてグローバルに到達可能でなければ拒否する。混在した応答は、その対象全体を拒否する。この拒否は当サイトの判断として記録し、ゲームの記録に停止時間として現れることはない。 + + + 接続画面を表示するのは、それが誰にでも送られるものだから。 + + + サーバーは接続画面を、認証なしで、すべての匿名接続に描き出す。当サイトはそれを証拠として表示し、ラベルを付ける。求められれば取り下げる。 + + + 止めろと言われれば止める — 方法は3つ。 + + + MSSPレポートに{variable} 1を公開すれば、それを読んだ探査が最後になる。または{label}.your.hostに「{value}」と書いたTXTレコードを公開する。MSSPへの対応も当サイトのアカウントも要らない。あるいは人に連絡する。3つとも1回のクロールサイクル以内に反映し、日付と読み取った内容とともに記録し、登録フォームにも適用する。 + + + MSSPフィールドはそのリスナーを止め、レコードはホストを止める。 + + + MSSPは応答したポートが公開するものなので、そのポートについてしか語らない — MU*のホスティングでは無関係なゲームが1つのドメインで動いているのが普通であり、一方が隣を黙らせてはならない。TXTレコードは、「{value}=4201」のようにポートを名指ししない限り、すべてのポートを対象とする。そこにポート一覧として読めないものが書かれていればホスト全体を意味するので、「{value}=all」でも通る。 + + + DNSによる方法は、こちらに頼まずに自分で取り消せる唯一の方法。 + + + TXTレコードは、来るなと言ったサーバーに接続しなくても読めるので、ダイヤルのたびに読み直す。削除すれば1週間以内にまたダイヤルする。MSSPフィールドは、止めてくれと言われたその行為をしなければ読み直せない。そのためMSSPによる離脱と書面での要請は、撤回の連絡があるまで有効なまま。離脱したアドレスに対して行うのはそのTXT参照だけ:触れるのはネームサーバーであって、ゲームではない。 + + + 停止は削除ではないし、停止時間でもない。 + + + 離脱したゲームも、ページ、アドレス、そして要請より前に実測したものはすべて残る。止まるのは新しいデータだけ:活動グリッドは時間が増えなくなり、原因は名指ししない。ノックをやめるという判断は当サイトについての事実だから。それは行われなかったクロールの記録として、また誰が要請したかの台帳に記録する。 + + + 停止だけで足りなければ、掲載も取り下げられる。 + + + そのゲームが応答するすべてのアドレスで探査を止めたあと、ダッシュボードにもう一つの選択肢が出る:一覧、ランキング、日次の数値から取り下げること。ページも、これまで持っていたすべてのアドレスも応答し続け、何も削除されない — 閲覧してたどり着ける場所ではなくなるだけ。これには検証済みの申請が要る。あなたのゲームについての判断であり、誰が下したかを記録するから。そして探査がそれを取り消す:離脱を撤回すれば、次に応答が返ったダイヤルで、二度頼まなくても一覧に戻る。 + + + サーバーから何者かと尋ねられたとき、クローラーは自らを{name}と名乗る。 + + + クローラーは自らを{name}と名乗るよう設定されているが、まだそう伝えられない。使っているtelnetライブラリには、クライアント側から端末種別を設定する手段がなく、そちらのログにはそのライブラリの既定値が見える。NEW-ENVIRONもクローラーホストの環境から応答される。どちらもライブラリ側の欠落であり、そこを直すのは当方の仕事。それまでは、探査はその形で見分けられる:1本の接続、ログインなし、読み取り専用の短いコマンド列、そして退出。 + + + クローラー + + + 連絡先 + + + クローラー:{name} + + + 連絡先:{url} + + + — プレースホルダー。このデプロイでは連絡先が設定されていない + + + 連絡先が設定されていないため、上のものはプレースホルダーであり、誰にもつながらない。 + + + ゲーム一覧の出どころ + + + 受け取るのはアドレス。それ以外は何も。 + + + バックフィルが受け取るのはホストとポート。接続数も、到達可能性の履歴も、説明も、各種フィールドも、アドレスがどのサイト由来かの記録も取らない。 + + + それらのサイトが渡せるものより、意図的に少なく。 + + + いくつかは何年分もの日付付き接続数を持っている。それを取り込めば、他人がすでに観測していたゲームのヒートマップが埋まり、このサイトの中心的な主張が他者の探査機に寄りかかることになる。 + + + ゲームの出どころは一つの事実ではない。 + + + 掲載する価値のあるゲームは、これらのディレクトリのいくつもに載っている。だから「インポート元」は、たまたま先に走った取得を指すだけになる。ゲームが存在することは公開情報。どこで読んだかは何も足さず、他人の仕事のうち当サイトのものだと最も言いにくい部分でもある。 + + + 他人のサイトを読むことは、やはり他人のサイトを読むこと。 + + + スクレイプの前に一括エクスポートか文書化されたエンドポイントを求め、まずrobots.txtを読み、スクレイプには厳しくレート制限をかける。管理者の了承が要る情報源は、依頼した旨を人が明言できるまで取得しない。 + + + 読み取り済み — アドレスのみ + + + 未読 — 許諾待ち + + + 1ページ、1リクエスト。各ゲームに接続して読み取った内容を出力するクローラーが公開している。 + + + 同じクローラーによるMSDPの一覧。ほぼMSSP版の部分集合で、そちらでは届かない少数のアドレスのために読んでいる。 + + + カタログ全体を1ページで公開しているので、読むのに要するリクエストは1回。最大のアドレス供給源であり、実測値は一つも取っていない。 + + + インデックスが1ページと、ワールドごとに1ページなので、エクスポートではなくスクレイプになる。2026年7月30日、robots.txtを守り15秒間隔で、先方の143ページを取得した。ただし誰も先方に連絡していなかった。あれは起きてはならなかった。現在の関門は、管理者に依頼したと明言する用意のある人を要求する。 + + + 実装済み、テスト済み、実行はしていない。許諾を除くあらゆる軸でここでは最も優れた情報源だが、誰かが先方に連絡するまで何も取得しない。 + + + ライセンス + + + コードはMIT。 + + + サイトもクローラーもパーサーも、MITライセンスのオープンソース。 + + + データのライセンスは未解決の問題。 + + + コードとは別の判断であり、まだ下していない。以下の条件は、このデプロイの現時点での答えであって、プロジェクトとして定まった立場ではない。競合するディレクトリがカタログ全体を持っていくことはここでは成功条件なので、どう決まるにせよそれを妨げることはない。 + + + コード + + + データ、このデプロイが提供する形での + + + クレジット表記 + + + コード:{licence} + + + データ:{licence} + + + クレジット表記:{credit} + + + (このデプロイが提供する内容。プロジェクト自身の答えはまだ未定。) + + + ゲームを登録 + + + ゲームの所在を教えてほしい。フォームはホストとポートだけ。このサイトのそれ以外はすべて、当サイト自身のクローラーが実測する。 + + + ホスト + + + ポート + + + mud.example.org、または mud.example.org:4201 を貼り付けてポートは空のまま + + + 登録 + + + 登録にはデータベースが必要だが、このサイトはデモ用のダミーデータで動いている。書き込む先のクロール登録簿がないので、黙って何もしないフォームを出すのではなく、フォーム自体を置いていない。 + + + ここにはない + + + アドレスに何が起きるか + + + ダイヤルの前にアドレスを解決し、公開インターネットの外に解決するものは拒否する。それは当サイト自身のソケットについての判断であり、ゲームについての事実ではない。 + + + そのホストを運用している人からクロールしないよう要請されている場合、誰が登録しようとそのアドレスは受け取らない。他人があなたのゲームをこのサイトに戻すことはできない。 + + + 応答があれば、サーバーが自分について語る内容を読み、そのゲーム自身のスケジュールで永久に読み続ける。アドレスを渡すのは一度でいい。 + + + 運用者だと誰かが証明するまで、サイトには何も出ない。申請にはパスキーと、ゲーム自体に公開する1行が要る。 + + + すでに持っているアドレスは既存の項目にまとめられる。二度送っても掲載が二つになることはなく、探査が早まることもない。 + + + そのアドレス + + + 登録簿に入った。 + + + {address}は次のクロールサイクルでダイヤルし、以降はそのゲーム自身のスケジュールで永久に続ける。運用者だと誰かが証明した時点でここに現れる — 同じアドレスでこのフォームに戻れば、そのリンクを渡す。 + + + こちらにはある、所有者未申請。 + + + {address}はすでに実測しているもの。運用者だと誰かが証明するまで、サイトには出ないまま。それがあなたなら、ここが入口。 + + + それはすでにある。 + + + {address}はすでに実測しているゲーム。何も作られず、何も変わっていない。 + + + そのアドレスはすでにある。 + + + {address}はすでに把握している。何も作られず、何も変わっていない。 + + + すでに待機中。 + + + {address}はクロール登録簿にあり、まだ応答がない。もう一度送っても順番は早まらない:対象はそれぞれ自分のスケジュールを保つので、他人のサーバーに対して当サイトを急かすことは誰にもできない。 + + + ダイヤルできるアドレスではない。 + + + ホストにはドットかコロンが要り、ポートは1から65535までの数値。両方の欄を埋めるか、最初の欄に mud.example.org:4201 を貼り付ける。 + + + それにはダイヤルできない。 + + + {address}にこの答えが出る理由は3つ:名前が解決しない、公開インターネットの外に解決する、あるいはそのホストを運用している人から近寄らないよう要請されている。どれなのかは意図的に言わない。見知らぬ相手にそれを答えることは、ネットワークを外側から地図にすることだから。アドレスについては何も記録していない。この判断は当サイトのものであり、当サイトのものとして記録する。 + + + 今はここまで。 + + + このフォームは送信者ごとにレート制限があり、その上限に達した。1時間後にまた来てほしい。失われたものはない — 受け取ったものはすでに登録簿にある。 + + + このゲームを申請する + + + サインイン + + + 自分が運用するゲームを申請するには、パスキーでサインインする。失くすパスワードも、盗まれるパスワードもない。 + + + 申請にはデータベースが必要だが、このサイトはデモ用のダミーデータで動いている。サインインする先がない。 + + + サインインはパスキーで行う。 + + + 秘密鍵はあなたの端末かパスワードマネージャーが持ち、当サイトが持つのは公開鍵の側だけ。パスワードもメールアドレスもない。 + + + パスキーでサインイン + + + このサイトでJavaScriptを必要とする唯一のページ。パスキーはそれなしでは動かない。 + + + アカウントがまだない場合 + + + アカウントが要るのは、自分が運用するゲームを申請するときだけ。名乗る名前を選ぶ — 申請の横に並ぶラベルであって、本名ではない。 + + + 名前 + + + 例:corvid-admin + + + パスキーでアカウントを作成 + + + 保存するもの + + + 選んだ名前。 + + + 登録した各パスキーの公開鍵と、端末が付けた名称。 + + + 申請したゲームと、その時期。 + + + アカウントに紐づくメールアドレスも、パスワードも、IPの記録もない。パスキーをすべて失っても、ゲームに新しい申請トークンを公開すればやり直せる:証明になるのはゲームであって、アカウントではない。 + + + {year}年{month}{day}日 + + + {date} {time} UTC + + + 今 + + + {count, plural, other {#分}} + + + {count, plural, other {#時間}} + + + {count, plural, other {#日}} + + + {count, plural, other {#週}} + + + {count, plural, other {#か月}} + + + {count, plural, other {#年}} + + + たった今 + + + {count, plural, other {#分前}} + + + {count, plural, other {#時間前}} + + + {count, plural, other {#日前}} + + + {count, plural, other {#週前}} + + + {count, plural, other {#か月前}} + + + {count, plural, other {#年前}} + + + たった今 + + + {count, plural, other {#分前}} + + + {count, plural, other {#時間前}} + + + {count, plural, other {#日前}} + + + {count, plural, other {#週前}} + + + {count, plural, other {#か月前}} + + + {count, plural, other {#年前}} + + + {age}、{stamp} + + + 、{stamp} + + + {value} — {source}による{how}、最終確認{date} + + + {value} — {source}による{how}、最終確認{date}(想定の更新期間を過ぎている) + + + ({how}、{age}) + + + ({how}、{age}、更新期間超過) + + + 所有者申告 + + + スタッフ + + + telnetハンドシェイク + + + 所有者 + + + WHO + + + I3 + + + MSSP + + + INFO + + + I3のmudlist + + + 接続画面 + + + エコシステム + + + 示すのはシェアであって、総数ではない。MU*を遊んでいる人数の数値は公表しない:実測したゲームに対する比率は、到達できないゲームがあっても意味を保つが、人数の総計は保たない。 + + + {count, plural, other {掲載ゲーム{value}件}} + + + {count, plural, other {ハンドシェイクを完了したゲーム{value}件}} + + + {count, plural, other {MSSPレポートを保有するゲーム{value}件}} + + + ここで最も古いハンドシェイク:{age}前に確認。 + + + {total, number}件中{count, number}件({fraction, number, ::percent .0}) + + + {total, number}件中{count, number}件 — 実測はまだなし + + + {fraction, number, ::percent .0} + + + コードベース + + + 掲載している{listed, plural, other {#件のゲーム}}のうち、{identified, number}件が動かしているものを伝えてきた。以下のシェアはすべてその{identified, number}件に対するもの。読み取れなかったコードベースは分母から外し、別の何かとして数えることはしない。 + + + コードベースを伝えてきた掲載ゲームはまだない。 + + + {share}は、他のどの掲載ゲームも使っていないコードベースを動かしている — それぞれ1件ずつで、シェアというより名前。上の分母には含まれており、棒グラフからまとめて外してあるだけで、除外はしていない: + + + 系統 + + + 同じゲームを、サーバーが受け継いだ伝統ごとにまとめたもの — コードベースについての当サイトの読み取りであって、ゲームが公開した内容ではない。「MUSH」と報告するゲームはない:MSSPにその値はなく、MUSH界の大半はそもそもMSSPを公開していないので、この問いを立てる方法はこれしかない。 + + + 系統に位置づけているコードベースを動かしている掲載ゲームは、まだない。 + + + {count, plural, other {そのうち#件は、どの系統にも位置づけていないコードベースを動かしている}} — {family}を公開して、そう自ら述べているものもいくつかある。上の分母には含まれており、どのシェアにも入っていない。 + + + プロトコル + + + 以下の実測値はいずれも下限として読むこと。MSSPは名指しで要求するので、そこでの沈黙は一つの答え。それ以外は要求していないので、サーバーが一度も提供しないまま対応していることもありうる。 + + + {instrument}は、以下で唯一、下限ではない行:すべてのサーバーに名指しで要求しているので、提供しなかったゲームは訊かれたうえで断ったことになる。自己申告の数値がない唯一の行でもある。レポートを保有しているゲームは実演によって対応を示しており、そのうえで自ら列挙したゲームを数えても、測れるのは習慣のほうだから。 + + + 保有しているレポートは{reports, number}件で、現在MSSPを提供しているのは{offered, plural, other {#件のゲーム}}:残る{gap, number}件は、こちらが読んだあとに公開をやめたもの。再発行されなくなったからといって、レポートを捨てることはしない。 + + + プロトコルの採用状況。実測は完了したハンドシェイクでサーバーが提供したもの、自己申告はそのMSSPが主張するもの。ゲームの集合が2つなので、分母も2つ。 + + + プロトコル + + + 実測 — {basis}中 + + + 自己申告 — {basis}中 + + + 未計測 — 一度も観測なし + + + {share} · {declined, plural, other {#件のゲーム}}が要求に対して辞退 + + + {share} · {unobserved, plural, other {#件のゲーム}}は提供も要求もなし + + + {share} · {declined, plural, other {#件のゲーム}}が要求に対して辞退 · {unobserved, plural, other {#件のゲーム}}は提供も要求もなし + + + 要求せず — ここにあるレポートのすべてが答え + + + 採用状況の推移 + + + 各点は、その日までに実測していたゲームに対するシェアなので、この線が動く理由は2つある:ゲームが提供内容を変えること、そして実測できるゲームの集合が周りで変わること。採用と言えるのは前者だけ。以下の変化件数は、純粋にゲームが考えを変えた分。 + + + プロトコルごとの実測シェア、古い読み取りから順に + + + 当時 + + + 現在 + + + 未計測 + + + 曲線ではなくスナップショット + + + 現在実測できるもののスナップショット。採用曲線が描くのはゲームが考えを変えた点であり、当サイトは変化が起きたときにそれを記録しているので、十分な数がたまれば曲線を描けるようになる。各ゲームに最初に到達した時点を描いても、測れるのはクロールであってホビーではない。 + + + {count, plural, other {これまでに記録した機能の変化は#件}} — 曲線を描くための材料。 + + + 実測した機能はまだ一つも変化していないので、描くものがない。 + + + 実測は{measured}に対するもの、自己申告は{declared}に対するもの。ゲームの集合が2つなので、分母も2つ。 + + + {listed} · {handshakes} · {mssp}。 + + + この図で最も古いハンドシェイクの最終確認は{age}前。 + + + 同じゲームを、サーバーが受け継いだ伝統ごとにまとめたもの。これは{evidence} — {meaning} — であって、ゲームが公開した内容ではない:「MUSH」と報告するゲームはない。MSSPにその値はなく、MUSH界の大半はそもそもMSSPを公開していないから。 + + + 実測:{value} + + + 自己申告:{value} + + + ランキング + + + 実測データのみから算出。投票も、星も、評価も一切ない。ここでは品質の順位づけはしない。それは実測していない。 + + + 最も賑わうゲーム、実測の同時接続数順 + + + ランキングの期間 + + + {days, plural, other {#日}} + + + 直近{days, plural, other {#日}}に実測した接続数の中央値。 + + + 中央値に必要な、{days, plural, other {#日}}にわたる{samples, number}件のサンプルを満たすゲームは、まだない。 + + + 掲載{listed, number}件のうち{eligible, plural, other {#件のゲーム}}が、必要な、{days, plural, other {#日}}にわたる{samples, number}件のサンプルを満たしている。 + + + 実測のゼロは数に入る。読み取れなかった計数は入らない。 + + + 1週間は今誰が賑わっているかを、四半期はこれまで誰が賑わってきたかを示す。別々の問いであり、一方で首位のゲームが他方でもそうとは限らない。日は暦日、UTC。 + + + 順位づけに足るだけの計数サンプルがある掲載ゲームは、まだない — これは実測を続けてきた期間についての言明であって、誰かの賑わいについてのものではない。 + + + 直近{days, plural, other {#日}}に実測した接続数の中央値でゲームを順位づけたもの。中央値が同じゲームは同順位で、ここでは同点を崩さない。 + + + # + + + ゲーム + + + 中央値 + + + ピーク + + + 計数サンプル + + + 実測日数 + + + 到達可能が途切れなかった最長期間 + + + 示した日付以降、すべての探査でそのゲームは到達可能だった。稼働ではなく到達可能:1台のホストからソケットを実測しているだけで、経路が通らないゲームも健在。この期間が観測してきた期間より長くなることはないので、事実は日付のほうであり、長さはそこから導かれる。 + + + 現在、到達可能が途切れずに続いている掲載ゲームはない。 + + + 示した日付以降のすべての探査で到達可能だったゲーム。到達可能の起点が同じゲームは同順位で、ここでは同点を崩さない。 + + + 到達可能の起点 + + + その長さ + + + アーカイブ済みのゲームは両方の表から外れるだけで、それ以外は何も変わらない。1回の探査が成功すれば戻る。 + + + 最も賑わうゲーム — 実測接続数の中央値、直近{days, plural, other {#日}} + + + 期間: + + + これ + + + 中央値{median, number} · ピーク{peak, number} · {window, number}日のうち{days, number}日で{samples, plural, other {計数サンプル#件}} + + + {date}以降のすべての探査で到達可能 · {duration} + + + アーカイブ + + + 応答しなくなったゲーム。削除はしていない。毎週の探査は続いており、1回の探査が成功すれば、その日のうちに一覧へ戻る。 + + + アーカイブを検索 + + + アーカイブ済みのゲームを検索 + + + 名前・コードベース・説明 + + + 表示 + + + {count, plural, =0 {アーカイブ済みのゲームなし} other {アーカイブ済みのゲーム#件}} + + + アーカイブ済み + + + 最終到達 + + + 稼働確認期間 + + + ({age}前) + + + 該当なし。 + + + 実測した範囲では一度もなし + + + 到達可能時間の実測なし + + + 不明 + + + {years, number, ::.#}年 + + + {days, plural, other {#日}} + + + {from} – {to} · {span} + + + {count, plural, other {#か月}} + + + {count, plural, other {#年}} + + + 「{query}」に一致する{count, plural, other {#件のゲーム}} + + + {count, plural, other {#件のゲーム}} + + + 最終到達: + + + 稼働確認期間: + + + 実測した到達可能時間のうち{value} + + + 稼働期間: + + + コードベース: + + + ランダムなゲーム + + + {d, date, medium} — 平均{typical}、{probes, plural, other {#回の探査}}で{low}〜{high} + + + {d, date, medium} — {count, plural, =0 {接続数0} other {#人}}、{probes, plural, other {#回の探査}}のいずれでも同じ + + + {d, date, medium} — 探査したが接続数は読み取れず + + + {d, date, medium} — 実測なし + + + {days, plural, other {#日}}のうち{counted}日で、通常{typical}、ピークは{peak}。 + + + この範囲を通じて横ばい。 + + + 範囲の始めから終わりまでで約{change, number, percent}の増加。 + + + 範囲の始めから終わりまでで約{change, number, percent}の減少。 + + + この範囲では探査したが、そのどこからも接続数は読み取れず。 + + + この範囲に実測なし。 + + + この範囲に計数なし。 + + + {from, date, d MMM}〜{to, date, d MMM} + + + {d, date, d MMM} + + + {span}:通常{typical}、ピーク{peak}、{days, plural, other {#日}}を計数 + + + {span}:探査したが接続数は読み取れず + + + {span}:未計測 + + + {count, plural, other {#日は探査したが計数なし}} + + + {count, plural, other {#日は未計測}} + + + {line}、{clause} + + + 日付はUTC · {from, date, medium} – {to, date, medium} + + + 上端は{value} + + + {days, plural, other {#日}}のうち{counted}日を計数 + + + {d, date, MMM} + + + {d, date, MMM yyyy} + + + その日に読み取った計数の平均 + + + その日の最多の計数まで + + + その日の最少から最多までの計数 + + + 探査したが接続数は読み取れず + + + 未計測 — 棒そのものなし + + + 未計測 — 線が途切れる + + + 推移の範囲 + + + 推移の形 + + + {days, plural, other {#日}} + + + ← 前の期間 + + + 後の期間 → + + + 折れ線 + + + 棒 + + + 接続数の推移 + + + {from, date, medium} – {to, date, medium}、UTC + + + 前の期間 + + + 週は、その週のうち計数できた日をまとめたもの。1日もない週はそう記す + + + 到達可能 · 直近{days, plural, other {#日}} + + + 到達可能 + + + 最長の途絶 + + + 直近の原因 + + + 期間内になし + + + 記録なし + + + {days, plural, other {#日前}} + + + 今日 + + + 到達可能 + + + 劣化 — 応答はあったが完了せず + + + 到達不可 + + + 未計測 + + + 到達可能 + + + 劣化 + + + 到達不可 + + + 未計測 + + + {d, date, d MMM} — 終日到達可能 + + + {d, date, d MMM} — 劣化({cause}):応答はあったが完了せず + + + {d, date, d MMM} — 到達不可({cause}) + + + {d, date, d MMM} — 未計測。このゲームはまだ観測していなかった + + + {days, plural, other {直近#日はまだ未計測。}} + + + 直近{days, plural, other {#日}}のうち{percent}が到達可能。 + + + 実測した{days, plural, other {#日}}のうち{percent}が到達可能。 + + + {days, plural, other {直近#日の到達可能性は、まだ未計測。}} + + + 期間内に到達不可の日はなし。 + + + 実測した日に到達不可はなし。 + + + {count, plural, other {#日が到達不可。}} + + + {count, plural, other {#日が劣化 — 接続はできたが完了できず。}} + + + 最長の途絶は{duration}。 + + + 最長の途絶は{duration}({cause})。 + + + {count, plural, other {#日は、実測が始まる前。}} + + + {from, date, d MMM} – {to, date, d MMM} + + + {d, date, d MMM} + + + {range}:{count, plural, other {#日}}{word} + + + {range}:{count, plural, other {#日}}{word}({cause}) + + + 到達可能 + + + {days, plural, other {直近#日}} + + + 到達可能:{days, plural, other {直近#日}}のうち{percent} + + + 最長の途絶:{duration} + + + dnsが解決しなかった + + + 接続を拒否された + + + tlsが失敗した + + + タイムアウトした + + + ハンドシェイクが止まった + + + 原因の記録なし + + + このゲームの接続画面を再公開しないよう、所有者から要請があった。 + + + このゲームから接続画面は取得できていない。 + + + {count, plural, other {返ってきたのは#行だけ — 表示するには少なすぎる。}} + + + サーバーが送ったまま + + + {columns}×{rows} + + + {columns}×{rows}、倍幅 + + + 16色SGR + + + 色なし + + + {charset}として読み取り + + + 取得 + + + 凍結 — 最後に見た画面 + + + ASCIIアート:{game}の接続画面。その文字は下の「テキストで読む」にある。 + + + ASCIIアート:このゲームの接続画面。その文字は下の「テキストで読む」にある。 + + + {count, plural, other {接続画面:#行、テキストのみ}} + + + {count, plural, other {接続画面:#行、テキストのみ、{charset}として読み取り}} + + + ゲームは申告しているが、サーバーはハンドシェイクで一度も提供していない。 + + + サーバーは提供しているが、ゲーム自身の記載では非対応となっている。 + + + たいていは手書きのフィールドが古いだけで、嘘ではない。両者が食い違う点にクライアントが依存すべきではないので表示している。 + + + 見つからない + + + このアドレスにゲームはない。綴りを確認。 + + + 最終応答 + + + {count, plural, other {#件が更新期間を過ぎている。古いだけで、誤りではない。}} + + + このゲーム自身のリファラル一覧が挙げているもの: + + + このゲームを挙げているリファラル一覧: + + + {date}から + + + 掲載終了、最終確認 + + + 最終応答は{date}、{ago}前。 + + + 毎週の探査は継続中。応答があれば、その日のうちにこのページが更新される。 + + + 稼働確認期間{span} + + + 一覧、ランキング、日次の数値には含まれない:このアドレスが誰かの遊べるゲームだとは考えていない。以下はすべて、このアドレスが伝えてきた内容そのまま。 + + + 当サイトの理由:{why} + + + 運用している人たちの要請により、一覧、ランキング、日次の数値には含まれない。以下はすべてそのまま保存されており、このページとこれまで持っていたすべてのアドレスは応答し続け、当サイトはダイヤルしていない。 + + + 現在の接続数:{count} + + + 機能({total}件のうち{disagreeing}件が不一致) + + + ゲームの自己申告 + + + 接続画面 + + + 変更点 + + + 実測 + + + 自己申告 + + + ** 不一致 + + + 接続数 + + + 到達 + + + 検索 + + + アーカイブ済み + + + アダルト + + + 含む + + + 計数なし + + + 現在の計数なし、理由もここには記されていない + + + 現在の接続数:計数なし(理由もここには記されていない) + + + クローラー実行中 · 最終探査{age} + + + クローラー待機中 · 最終探査{age} + + + ここではまだ探査が一度も完了していない + + + このサイクルに探査予定なし + + + {considered, plural, other {#件が探査予定}} · {answered, plural, other {#件が応答}} · {failed, plural, other {#件が失敗}} + + + 登録簿に{targets, plural, other {#件のアドレス}}、{due, plural, other {うち#件が現在探査予定}} + + + {page} — {site} + + + MU*ホビーのディレクトリ — MUSH、MUD、MUCK、MOO — どの事実にも、その出所といつのものかが記されている。 + + + デモデータ — ここにある値は実測ではない。{description} + + + {site} — 実測であって、申告ではない + + + {site}の{title} + + + ゲーム + + + アーカイブ + + + ランキング + + + エコシステム + + + リファレンス + + + 概要 + + + 見つからない + + + ランダムなゲーム + + + 自分のゲーム + + + {game}を申請 + + + 到達したすべてのMU*を、実測した内容で絞り込む:コードベース、サーバーがハンドシェイクで提供したプロトコル、TLS、エンコーディング、言語、そして最後に到達した時期。 + + + 消息不明になったゲームを、そのまま保持している。ページも履歴もURLも残り、毎週の探査は続き、1回接続できれば一覧に戻る。 + + + 最も賑わう、最も到達可能、最も長く続く — 実測データのみから算出。投票も、星も、評価も、このサイトのどこにもない。 + + + 実測しているゲーム全体でのコードベースのシェアとプロトコルの採用状況。サーバーが提供するものを、自己申告の内容と並べて示す。示すのはシェアであって、総数ではない。 + + + MU*ホビーのコードベース、クライアント、プロトコルについて、人の手で書いたページ。クロールから得た件数と相互にリンクしている。 + + + このカタログの作り方:クローラーが何をするか、何を拒むか、そしてどう止めるか。 + + + このアドレスにゲームはない。ここでは何も削除しないので、かつてこのURLにあったゲームは今もある — 綴りを確認。 + + + カタログから1件、ランダムに選んだゲーム。同じものが二度出ることはない。 + + + 申請した掲載と、申請によって何を変えられるか。 + + + 運用者にしか置けない場所にトークンを公開して、このゲームを運用していることを証明する。 + + + アーカイブ済み — 最終到達{age}、探査は継続中 + + + アーカイブ済み、探査は継続中 + + + 接続数不明 — ゲームは応答するが、読み取れる数値は公開していない + + + {count, plural, other {#人}}、{how}、{age} + + + 新規発見 + + + 消息不明 + + + 復帰 + + + {count, plural, other {既知のゲーム#件}} + + + {count, plural, other {現在接続中#人(実測)}} + + + {count, plural, other {応答あり・読み取り不可#件}} + + + {count, plural, other {アーカイブ済み#件、探査は継続中}} + + + リファレンス + + + コードベースとは何か、クライアントは何をするか、プロトコルは何を意味するか。人の手で書き、クローラーと同じリポジトリに置いている — ウィキではなく、このページに編集できるものはない。ここにある{number}はどれも別物:カタログから取り、ページを読み込むたびに再計算している。 + + + 数値 + + + 人の手で書き、著者は一人、gitでバージョン管理している。ここにある文章は当サイトのもの。その横にある数値はすべてクローラーが実測し、リクエストのたびに再計算している。ウィキではなく、このページから編集する方法もない。 + + + はじめに + + + コードベース + + + クライアント + + + プロトコル + + + 入門 + + + コードベース + + + クライアント + + + プロトコル + + + 見つからない + + + ここにリファレンスのページはない。この節は人の手で書いているので、空白は誰も手をつけていない仕事であって、削除されたものではない — {index}。 + + + 何があるかを見る + + + これを動かしているゲーム + + + まだ一つも特定していない。これはこのクローラーが実測した内容についての事実であって、存在するものについてのものではない — 到達していないゲームや、コードベースを読み取れなかったゲームは、ここには数えていない。 + + + {count, plural, other {掲載#件}} + + + {count, plural, other {アーカイブ済み#件}} + + + 実測であって、申告ではない + + + このリクエストの時点でカタログから数えたもので、上のリンクが持つのと同じフィルターによる — この数値とその一覧は一つのクエリであり、食い違うことはない。 + + + ハンドシェイクで提供していたもの:{protocols} + + + 実測した採用状況 + + + まだ実測なし。 + + + {listed, plural, other {掲載#件のゲーム}}のうち — {percent} + + + ここに数えていないゲームは、そのプロトコルを持たないゲームということではない。数えるのは、そのサーバーがハンドシェイクでそのオプションを提供するのを観測したとき。残りは、当サイトに提供しなかったサーバーと、ハンドシェイクをまだ読んでいないサーバーであり、どちらなのかは言えない。 + + + ハンドシェイクで{protocol}を提供するのを観測したゲームを、動かしていると特定したコードベース別に示したもの。 + + + コードベース + + + 提供あり + + + 特定済み + + + 関連項目 + + + 機能 + + + {count, plural, other {{total}件のうち#件はプロジェクト自身のドキュメントで確認}} + + + 各プロジェクト自身のドキュメントから読み取ったものであり、当サイトの実測ではない — クライアントには観測できるハンドシェイクがない。「{unknown}」は、調べたが確認できなかったという意味。「いいえ」の意味では決してない。 + + + クライアントの機能。いずれも各プロジェクト自身のドキュメントから読み取ったもの。不明は確認できなかったという意味であって、そのクライアントに備わっていないという意味ではない。 + + + 文書化 + + + 典拠 + + + 見つけられなかった + + + はい + + + いいえ + + + 不明 + + + 動作環境:{platforms} + + + このコードベースを動かしていると特定したゲーム + + + まだない。これは当サイトが実測した内容についての言明であって、存在するものについてのものではない — 到達していないゲームや、コードベースを読み取れなかったゲームは、ここには数えていない。 + + + {listed, plural, other {掲載#件}}、{archived, plural, other {アーカイブ済み#件}} + + + ハンドシェイクで実測:{protocols} + + + これまで読んだどのハンドシェイクでも、何も提供されなかった。 + + + {listed, plural, other {掲載#件のゲーム}}のうち{offering}件で提供を観測({percent}) + + + 特定できたゲームの、コードベース別 + + + {identified}件のうち{offering}件が提供 + + + {count, plural, other {{total}行のうち#行が不明}}:どちらとも述べたプロジェクト自身のドキュメントを見つけられなかった。短くて正直な表のほうが、長くて当て推量の表よりよい。 + + + 自分のゲーム + + + アカウントにはデータベースが必要だが、このサイトはデモ用のダミーデータで動いている。 + + + サインイン + + + {name}としてサインイン中。 + + + サインアウト + + + まだ何も申請していない。{listing}で自分のゲームを見つけ、そのページで{claimControl}を押す。 + + + 一覧 + + + このゲームを申請する + + + 手放した。 + + + その記録は残る。新しいトークンを公開すれば、いつでもまた管理していることを証明できる。 + + + 保存した。 + + + そのゲーム + + + {game}のページに、所有者申告として表示されるようになった。 + + + {game}の接続画面の再公開をやめた。ページには穴を残さず、そのことを明記する。 + + + {game}の接続画面を、またそのページに表示している。 + + + 把握しているすべてのアドレスで、{game}へのダイヤルをやめた。そのページには、要請より前に実測したものがすべて残る。 + + + スケジュールで次の順番が来たときから、{game}へのダイヤルを再開する。 + + + {game}は一覧、ランキング、日次の数値から外れた。そのページと、これまで持っていたすべてのURLは応答し続ける。 + + + {game}は一覧に戻った。また実測されるのに要るのは、応答が返る探査1回だけ。 + + + {field}は変更していない。 + + + ここは1行で答える欄で、保存するのは最大{max}文字まで。 + + + その項目は実測。申請で足せるのはMSSPに収まらない内容であって、観測した内容は誰にも編集できない。当サイト自身にも。 + + + 申請済み + + + {date}に検証 + + + {date}に検証、トークンの最終確認{seen} + + + MSSPを確認する + + + {count, plural, other {{names}も所有者 — それぞれ自分のトークンを検証している。}} + + + 別のアカウント + + + 自分のサイトに接続数を載せる + + + バッジは接続数と、それを実測した時期を併せて示す。古さの分からない数値こそ、このサイトが置き換えるために存在するものだから。 + + + 数えられなかったときはゼロではなく{unknown}と表示し、ゲームが応答しなくなれば{archived}と表示する。 + + + 自分で描きたければ{json}もある。 + + + 履歴 + + + トークン発行 + + + トークン再発行 + + + 検証 — トークンを読み取った + + + トークンは引き続き公開中 + + + 今回はトークンを読み取れず + + + 申請を手放した + + + トークンが未使用のまま期限切れ + + + 別のアカウントが管理していることを証明し、ゲームを引き継いだ + + + 確認を要請 + + + この申請を手放す + + + 確認のため{word}と入力する。何も削除されず、新しいトークンを公開すればまた管理していることを証明できる。他に所有者がいれば、ゲームは申請済みのまま。 + + + {game}を手放す + + + トークン待ち + + + トークン発行{issued}、有効期限{expires} + + + パスキー + + + 名称なし + + + 追加{date} + + + 追加{date} · 端末1台のみ + + + このパスキーは1台の端末にしかない。失くしても、ゲームに新しいトークンを公開すれば入り直せるが、2つ目のパスキーがあるほうが早い。 + + + パスキーをもう1つ追加 + + + そのゲームはない + + + {game}を申請 + + + 申請にはデータベースが必要だが、このサイトはデモ用のダミーデータで動いている。 + + + 先にアカウントが要る。要るのはパスキーと名前だけ。 + + + サインイン、またはアカウントを作成 + + + 自分のゲーム + + + {count, plural, other {このゲームには、サーバーを管理していることを証明した所有者がすでに#人いる。}}こちらも証明できる — 確かめ方はどちらでも同じ — が、それが何を意味するのかは伝えてもらう必要がある。トークンからは判別できないから。 + + + こちらも運用している — 所有者に加えてほしい + + + 全員が自分の申請を保つ。1つのゲームを2人で運用している状態。 + + + 引き継いだ — こちらに移してほしい + + + {count, plural, other {トークンが検証された時点で、既存の申請は取り消され、ゲームはこちらのものになる。}}相手には自分の履歴でその理由が見える。何も削除されず、相手はこれから行うのと同じ方法で、また管理していることを証明できる。 + + + 検証できた。 + + + {date}、そのゲームのMSSPレポートからトークンを読み取った。 + + + {date}、接続画面からトークンを読み取った。 + + + トークンはそのままにしておく。同一性の手がかりも兼ねるので、ホストを移しても名前を変えても、このゲームは見分けがつく。外しても申請が解けるわけではない。 + + + 匿名の接続に見える場所であれば、どこにこのトークンを公開してもよい。次の探査がそれを拾い、それがそのサーバーに書き込めることの証明になる。 + + + これは移管。 + + + {count, plural, other {このトークンを読み取った時点で、このゲームに対する現所有者の申請は取り消され、こちらのものになる。}} + + + どちらか一方でよい + + + MSSPの変数 + + + {codebase}では{file}の1行。MSSPを持つコードベースには、どれも同じものがある。 + + + {aliases}も受け付ける。 + + + 接続画面の1行 + + + 画面内のどこでもよく、周りに色コードがあっても構わない。 + + + そのあと + + + 確認は通常のクロールスケジュールで行う。このトークンの有効期限は{date}。いつ戻ってきてもよく、書き留めておくものは何もない。 + + + 早めに見に行く + + + 自分のゲームを待ち行列の先頭に入れる。ダイヤルは当サイト自身のスケジュールで行うので、今すぐではなく、早まるだけ。 + + + 今要請したばかり。数分おいてからもう一度 — 本来より早く実際のサーバーにダイヤルすることになるので、回数を制限している。 + + + {game}について、運用者にしか伝えられないこと + + + ここにあるのは、MSSPに対応する欄がないもの。自分のゲームのページには、最後に確認した日付を添えて{declared}として、実測した内容と並べて表示する — 実測に代えて表示することは決してない。実測したものは、ここからは誰にも編集できない。運用者にも当サイトにも。 + + + {age}に自己申告。取り下げるにはこの欄を空にする — どちらにせよ、何と書かれていたかの記録は残る。 + + + 自己申告した内容を保存 + + + {game}が報告している内容と、代わりに表示してほしい内容 + + + MSSPはどのクローラーも読むものであり、ここに入れた内容の横に、当サイトはそれを表示し続ける — 運用者の回答が、ゲーム自身の回答を隠すことはない。 + + + 実測したものは、ここからは何も編集できない:接続数も、機能も、到達可能だった1時間も。 + + + 下の行が{file}で誤っているなら、そこで直せばすべての場所で直る。 + + + ゲームの報告は{value}、確認は{age} + + + ゲームはここで何も報告していない + + + 名前を変えると、{game}の掲載名と、そのページのアドレスが変わる。古いアドレスは永久に動き続け — このゲームがこれまで持っていたすべてのURLは現在のURLへ転送される — 欄を空にすれば、名前はMSSPの内容に戻る。 + + + 自分の接続画面 + + + 再公開はしていない。ページには穴を残さず、そのことを明記しており、クローラーは読み続けている — ゲームが移ったときに見分ける手がかりがそれだから。 + + + また表示する + + + 表示しているのは、サーバーがそれをすべての匿名接続に送っているから。表示してほしくなければ、そう言えばやめる。理由は訊かない。 + + + 接続画面の表示をやめてほしい + + + クロールされること + + + 停止した。{game}へは一切ダイヤルしておらず、ページには要請より前に実測したものがすべて残る — 空いた時間は原因を名指ししない。{ourFact}というのは当サイトについての事実であって、そのゲームについての実測ではないから。 + + + 停止を要請された + + + またクロールしてほしい + + + これはここからではなく、サーバー自身から来たもの — {routes}。またクロールされるには、その公開をやめる。次のサイクルでそれを受け取る。 + + + MSSPレポートが{variable}を公開している + + + {label}のTXTレコードが停止を求めている + + + 記録済みの要請 + + + {stopped}では停止し、{dialling}には引き続きダイヤルしている。たいていは離脱のあとに追加されたポート。 + + + すべてのアドレスでも停止してほしい + + + {game}へは一定のスケジュールでダイヤルし、匿名の接続に見えるものを読んでいる。やめてほしければ、そう言えばやめる — 1回のサイクル以内に、把握しているすべてのアドレスで。理由は訊かない。 + + + すでに実測したものは何も削除されない:ページは履歴とURLを保ち、これを撤回したあとの探査1回で再開する。当サイトを通さず、自分の設定で伝えることもできる — MSSPの{mssp}か、{dns}のTXTレコード — このゲームをここで誰かが申請しているかどうかにかかわらず、それに従う。 + + + クロールをやめてほしい + + + 掲載されること + + + {game}は一覧からも、ランキングからも、日次の数値からも外れている。ページと、これまで持っていたすべてのURLは応答し続け、要請より前に実測したものはすべてそこに残っている。何も削除していない。閲覧してたどり着ける場所ではなくなるだけ。 + + + 一覧に戻してほしい + + + 応答が返る探査1回でも同じことが起きる。離脱が有効な間はダイヤルしないので、そうなることはない — ただし撤回した日から1週間以内にそのアドレスの順番が来て、応答が返った探査が一覧に戻す。二度頼む必要はない。 + + + ダイヤルは停止しており、ページはそれ以前に実測した内容とともに一覧に残っている。残したくなければ、そう言えば外す — 一覧からも、ランキングからも、日次の数値からも。 + + + 何も削除されず、何も壊れない:ページは応答し、これまで持っていたすべてのURLは今もそこへ転送され、案内した相手には見える。閲覧してたどり着ける場所ではなくなるだけ。ここから元に戻せるし、離脱を撤回したあとに応答が返った探査でも元に戻る。 + + + 一覧からも外してほしい + + + 実測できたもの + + + 非表示にすると一覧から外れる — そのゲームが無人だという意味ではない。 + + + 接続数読み取り不可 + + + 到達不可 + + + この一覧では非表示 + + + この一覧では非表示 + + + 左の列で、* はこの一覧が絞り込みに使っている値、- は除外に使っている値。どちらもクエリでの選択であって、ゲームについての事実ではない。 + + diff --git a/src/MUI.Web/Resources/Messages.nl.resx b/src/MUI.Web/Resources/Messages.nl.resx new file mode 100644 index 0000000..56549b8 --- /dev/null +++ b/src/MUI.Web/Resources/Messages.nl.resx @@ -0,0 +1,2877 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + {count, plural, one {# spel} other {# spellen}} + + + {value}, {count, plural, one {# spel} other {# spellen}}, alleen + + + {value}, {count, plural, one {# spel} other {# spellen}}, uitgesloten + + + {value}, {count, plural, one {# spel} other {# spellen}} + + + ongeacht {facet}, {count, plural, one {# spel} other {# spellen}} + + + {count, plural, =0 {Hier staan geen spellen.} one {# spel, waarvan elk feit vermeldt hoe het verkregen is.} other {# spellen, waarvan elk feit vermeldt hoe het verkregen is.}} + + + {days, plural, one {# dag} other {# dagen}} gemeten · {probes, plural, one {# peiling} other {# peilingen}} + + + {days, plural, one {#d} other {#d}} · {count, plural, one {# telling} other {# tellingen}} + + + {disagreeing, plural, =0 {Geen van de {total} wijkt af.} one {# van de {total} wijkt af van wat het spel opgeeft.} other {# van de {total} wijken af van wat het spel opgeeft.}} + + + {count, plural, =0 {Geen spel past bij elk antwoord.} one {# spel past bij elk antwoord.} other {# spellen passen bij elk antwoord.}} + + + van {listed, plural, one {# vermeld spel} other {# vermelde spellen}} · {answers, plural, =0 {geen antwoorden gegeven} one {# antwoord gegeven} other {# antwoorden gegeven}} + + + {count, plural, one {Toon het ene spel} other {Toon deze # spellen}} + + + "{answer}" laten vallen — {count, plural, one {# spel} other {# spellen}} + + + antwoord wissen op: {question} + + + Een spel zoeken + + + passend bij alle antwoorden + + + {count, plural, one {spel} other {spellen}} + + + alle antwoorden wissen + + + opnieuw beginnen + + + {count, plural, one {# meer} other {# meer}} + + + gegeven antwoorden + + + de hele lijst + + + die zoekopdracht is geweigerd + + + een naam, als je er een hebt + + + naam, of een deel ervan + + + Zoeken op naam + + + Speelt er nu iemand? + + + Wat wil je spelen? + + + Wat voor spel? + + + In welke taal? + + + Heeft je client iets nodig? + + + Ook stilgevallen spellen meenemen? + + + maakt niet uit + + + elk genre + + + elke soort + + + elke taal + + + maakt niet uit + + + nee, alleen live spellen + + + ja, toon die ook + + + stilgevallen spellen + + + TLS — versleuteld, handshake door ons voltooid + + + MSSP — zelfbeschrijving van de server + + + MCCP — gecomprimeerde uitvoer + + + MXP — klikbare links + + + GMCP — gestructureerde clientgegevens + + + MSDP — gestructureerde clientgegevens + + + CHARSET — onderhandeling over codering + + + UTF-8 — niet-Latijnse tekst wordt weergegeven + + + TTYPE — client meldt zijn type + + + ATCP — gestructureerde clientgegevens + + + MSP — geluidstriggers + + + EOR — promptmarkering + + + {token} — gemeten in de handshake + + + gemeten + + + gemeten + + + gemeten + + + gemeten + + + gemeten + + + opgegeven + + + opgegeven + + + opgegeven + + + opgegeven + + + afgeleid + + + afgeleid + + + niet gemeten + + + ongeteld + + + onbereikbaar + + + niet geteld + + + geen telling + + + van hieruit + + + gearchiveerd + + + geclaimd + + + Niets om uit te kiezen + + + Geen spel past bij dat filter. Probeer {listing}, of {archive}. + + + de hele lijst + + + het archief meenemen + + + verbonden + + + niet geclaimd + + + geclaimd door de eigenaar + + + wordt nog gepeild + + + typisch + + + piek + + + als tekst lezen + + + platte tekst + + + naar de inhoud + + + ASCII-banner: het verbindingsscherm van {game}. + + + Catalogi + + + Deze site en je account + + + bladeren + + + uitleg + + + deze site + + + menu + + + spellen + + + zoeken + + + willekeurig + + + archief + + + naslag + + + ecosysteem + + + ranglijsten + + + over + + + aanmelden + + + een spel aanmelden + + + inloggen + + + je spellen + + + thema + + + automatisch + + + licht + + + donker + + + Demogegevens. + + + Er is geen database geconfigureerd, dus dit zijn testgegevens. Niets hiervan is gemeten. + + + alle spellen + + + archief + + + opgegeven door het spel + + + wat er veranderd is + + + Een gids voor de MU*-hobby + + + Elk feit vermeldt hoe het verkregen is en hoe oud het is: gemeten door onze crawler, of opgegeven door het spel en als zodanig gemarkeerd. + + + Spellen zoeken op naam, thema, codebase of host + + + zoeken op naam, thema, codebase of host + + + zoeken + + + bekende spellen + + + nu verbonden + + + antwoordt, ongeteld + + + gearchiveerd + + + nieuw ontdekt + + + stilgevallen — wordt nog gepeild + + + weer terug + + + Niets nieuws. + + + Er is niets stilgevallen. + + + Er is niets teruggekomen. We blijven kloppen. + + + live + + + Spellen + + + gesorteerd op {order} + + + willekeurig + + + verbonden · bereikt + + + van hieruit + + + Geen treffers. + + + Probeer minder woorden, of laat een filter vallen. + + + filters wissen + + + over {codebase} + + + nooit + + + geclaimd door de eigenaar + + + Onbekende codebase + + + we konden niet vaststellen op welke codebase dit spel draait + + + en {count, plural, one {# meer} other {# meer}}: {names} + + + Volgorde + + + Periode + + + nu + + + typisch + + + piek + + + naam + + + bereikt + + + 7 dagen + + + 30 dagen + + + 90 dagen + + + Spellen zoeken + + + spellen zoeken + + + filters + + + getoond + + + alles wissen + + + — hier niet meer op filteren + + + alle + + + meer filters ({count}) + + + {count, plural, one {# meer} other {# meer}} + + + ook tonen + + + Standaard uit. Geen van beide is een oordeel over het spel. + + + gearchiveerd + + + voor volwassenen + + + gearchiveerde spellen, {shown, select, true {getoond} other {verborgen}} + + + spellen die inhoud voor volwassenen opgeven, {shown, select, true {getoond} other {verborgen}} + + + Aantallen zijn spellen die we gemeten hebben, nooit schattingen. + + + wat de badges en de lege plekken betekenen + + + Een lege plek is een gat in onze meting, geen nee. Elk facet zegt het op zijn eigen manier: niet vastgesteld, niet opgegeven, niets onderhandeld. + + + Een gemeten nul is een telling. Een onbekende telling is geen nul en wordt ook nooit als nul gesorteerd. + + + Open facetten tonen hun {count} meest voorkomende waarden. De rest is bereikbaar via zoeken en via URL. + + + Niet aangevinkt betekent niet gemeten — niet dat het spel het mist. + + + activiteit + + + laatst gezien + + + aangeboden protocollen + + + versleuteld + + + codering + + + codebase + + + versie + + + afstamming + + + familie + + + genre + + + taal + + + nu verbonden + + + actief deze week + + + rustig — nooit boven 0 geteld + + + stil — een maand niet bereikt + + + gearchiveerd + + + in de laatste 24 uur + + + in de laatste 7 dagen + + + in de laatste 30 dagen + + + langer geleden + + + nooit bereikt + + + niets onderhandeld + + + niet vastgesteld + + + niet opgegeven + + + verbonden via TLS + + + niet {value} + + + iets onderhandeld + + + wel vastgesteld + + + wel opgegeven + + + we hebben dit zien gebeuren + + + het spel zegt het, en wij hebben het niet gecontroleerd + + + we hebben gegroepeerd wat het spel ons vertelde + + + naam + + + nu verbonden + + + laatst bereikt + + + typisch verbonden · 7 dagen + + + typisch verbonden · 30 dagen + + + typisch verbonden · 90 dagen + + + meeste tegelijk verbonden · 7 dagen + + + meeste tegelijk verbonden · 30 dagen + + + meeste tegelijk verbonden · 90 dagen + + + nu op de rij + + + typisch + + + piek + + + Onbekende telling + + + nog nooit bereikt — niet dat het lang geleden bereikt is + + + minder dan {minimum} tellingen in de periode, of helemaal geen — geen typische telling van nul + + + niets dat we in de periode konden tellen — niet dat er niemand verbonden was + + + mediaan {value} · {days, plural, one {#d} other {#d}} · {count, plural, one {# telling} other {# tellingen}} + + + meeste {value} tegelijk · {days, plural, one {#d} other {#d}} · {count, plural, one {# telling} other {# tellingen}} + + + Verbindingsscherm + + + Verbindingen per uur + + + Hoeveel, in de loop van de tijd + + + Bereikbaar + + + Wat er veranderd is + + + Mogelijkheden + + + Opgegeven door het spel + + + Verwijzingen + + + Niet geclaimd — alles hier is gemeten. + + + Geclaimd door de eigenaar — de gemeten feiten hieronder blijven van ons. + + + Dit spel claimen + + + antwoordt sinds {date} + + + als tekst lezen — {count, plural, one {# rij} other {# rijen}} + + + mogelijkheid + + + leeftijd + + + aangeboden + + + stil + + + afwezig + + + geweigerd + + + geclaimd + + + wijkt af + + + waar ze afwijken ({count}) + + + {day} {time} — {count, plural, =0 {0 spelers, gemeten} one {gemiddeld # speler} other {gemiddeld # spelers}} + + + {day} {time} — gepeild, geen telling kon worden gelezen + + + {day} {time} — geen meting in dit uur + + + We hebben de activiteit van dit spel nog niet gemeten. + + + Geen enkel uur van de week heeft een spelerstelling opgeleverd. + + + Elk uur gemeten en in geen enkel uur was er iemand verbonden. + + + {count, plural, one {# uur op {day} heeft nog geen meting.} other {# uren op {day} hebben nog geen meting.}} + + + {count, plural, one {# uur in de week heeft nog geen meting.} other {# uren in de week hebben nog geen meting.}} + + + {count, plural, one {# uur op {day} antwoordde maar leverde geen telling op.} other {# uren op {day} antwoordden maar leverden geen telling op.}} + + + {count, plural, one {# uur in de week antwoordde maar leverde geen telling op.} other {# uren in de week antwoordden maar leverden geen telling op.}} + + + Elke dag het drukst, {window}. + + + Elke dag het drukst in de {part}, {window}. + + + Het drukst op {days}, {window}. + + + Het drukst op {days} in de {part}, {window}. + + + Steevast rustig {who}, {window}. + + + Steevast rustig {who} in de {part}, {window}. + + + elke dag + + + elke dag die we konden meten + + + op werkdagen + + + op {days} + + + ochtend + + + middag + + + avond + + + nachtelijke uren + + + ochtenden + + + middagen + + + avonden + + + nachtelijke uren + + + {list}, {next} + + + {first} en {second} + + + nog te weinig metingen + + + Nog geen enkel uur van de week is gemeten. + + + {count, plural, one {# uur antwoordde en leverde geen telling op.} other {# uren antwoordden en leverden geen telling op.}} + + + Het raster verschijnt zodra elke dag van de week er een heeft. + + + {days, plural, one {Tot nu toe op # van de zeven dagen gemeten; het raster verschijnt zodra elke dag een uur bevat.} other {Tot nu toe op # van de zeven dagen gemeten; het raster verschijnt zodra elke dag een uur bevat.}} + + + {count, plural, one {# uur gemeten, met niemand verbonden.} other {# uren gemeten, allemaal met niemand verbonden.}} + + + {count, plural, one {# uur gemeten, het drukst met {peak} op {day} om {time} UTC.} other {# uren gemeten, het drukst met {peak} op {day} om {time} UTC.}} + + + {count, plural, one {Nog # uur antwoordde en leverde geen telling op.} other {Nog # uren antwoordden en leverden geen telling op.}} + + + {day} — {facts} + + + {first}, {second} + + + de hele dag op nul gemeten + + + piek {count} om {time} + + + niemand verbonden {window} + + + in geen enkel uur een telling + + + {count, plural, one {# uur niet gemeten} other {# uren niet gemeten}} + + + {count, plural, one {# uur gepeild maar niet telbaar} other {# uren gepeild maar niet telbaar}} + + + dag + + + rustigst + + + drukst + + + om + + + geen telling + + + Spelers verbonden per dag, in UTC. {window}. + + + tijden in UTC · {window} + + + {weeks, plural, one {voortschrijdend gemiddelde over # week} other {voortschrijdend gemiddelde over # weken}} + + + geteld, inclusief een gemeten nul + + + gepeild, geen telling kon worden gelezen + + + geen meting in dat uur + + + Wanneer er spelers verbonden zijn (UTC) + + + geteld + + + we kwamen binnen en lazen een getal, inclusief een gemeten nul + + + we kwamen binnen en er was geen getal te lezen + + + we hebben geen meting voor dat uur + + + taal + + + taal wijzigen + + + Over mu*index + + + Elk spel hier is gemeten door een machine die er verbinding mee maakte, en elke waarde vermeldt waar hij vandaan komt en wanneer. Deze pagina behandelt wat dat bewijst, wat we fout doen, wiens gidsen we lezen, en hoe de crawler te stoppen is. + + + Wat een feit hier is + + + Gemeten wint van opgegeven, en beide worden getoond. + + + Het MSSP-rapport van een spel is het spel dat zichzelf beschrijft. De telnet-handshake is wat we het hebben zien doen. Beide staan op zijn pagina, gelabeld met hoe en wanneer. Waar ze van elkaar afwijken, tonen we de afwijking. + + + Een spelerstelling vermeldt waar hij vandaan komt. + + + Ofwel een WHO of DOING die bij het verbindingsscherm gelezen is en door ons geteld, ofwel het eigen MSSP PLAYERS-veld van het spel, dat het zelf publiceerde. Nooit samengevoegd. + + + Een antwoord dat we niet kunnen lezen is onbekend, nooit nul. + + + Servers passen hun WHO-koppen naar believen aan, en voorbij een zeker punt kan onze parser er geen meer lezen. Dat is niet telbaar, een toestand op zichzelf. Een gemeten nul — we kwamen binnen, er was niemand — is een telling, en wordt ook als telling weergegeven. + + + Bereikbaar, nooit uptime. + + + We openen met tussenpozen een socket vanaf één host. Een spel waarheen we geen route hebben is onbereikbaar en volkomen springlevend. Niets hier beweert iets over de uptime van een spel, omdat niets hier die gemeten heeft. + + + Een uur is geteld, niet telbaar, of niet gemeten. + + + Het activiteitenraster heeft drie toestanden. De derde is leeg en noemt geen oorzaak: een uur dat we niet konden bereiken en een uur dat we nooit gepeild hebben zijn dezelfde afwezigheid, en geen van beide is downtime van die server. + + + Wat we weten dat we fout doen + + + Het archiefrespijt wordt gemeten vanaf de dag dat we je vonden. + + + Een spel dat stopt met antwoorden verlaat de standaardlijst na zijn respijtperiode: een kwart van de bereikbare tijd die we gepeild hebben, met een ondergrens van 60 dagen en een bovengrens van 365. Een spel dat sinds 1995 draait, begint op de ondergrens op de dag dat we het ontdekken. We importeren niets om de jaren vóór onze komst in te vullen. + + + We laten MSSP CREATED niet meetellen voor dat respijt. + + + Het is één met de hand ingetypte regel in een configuratiebestand, dus meetellen zou de archiefdrempel manipuleerbaar maken. Het wordt als opgave getoond en koopt niets. + + + Een spel claimen levert de bovengrens op. + + + Aantonen dat er servertoegang is, is het volle jaar respijt waard, hoe lang we ook al meekijken. + + + Alles hier is één host die met tussenpozen kijkt. + + + Een percentage bereikbare tijd is een deel van de periode die we waarnamen, nooit van een periode die we niet waarnamen. Geen enkele grafiek hier vult de rest in. + + + Er wordt nooit iets verwijderd. + + + Archiveren haalt een spel uit de standaardlijst, de ranglijsten en het cijfer van vandaag actief, en verder niets. Zijn pagina, URL, geschiedenis en adres blijven bestaan, het wordt nog gepeild, en één geslaagde peiling zet het terug. + + + Wat deze site niet zal doen + + + Geen stemmen, sterren, beoordelingen of aanbevelingen. + + + Ranglijsten worden alleen uit gemeten gegevens berekend. Een gids die gerangschikt is naar wie de meeste klikken kan mobiliseren, beschrijft de campagne en niet de hobby, en daaraan zijn de gevestigde gidsen ten onder gegaan. + + + Geen forums, recensies, wiki’s, reacties of spelersprofielen. + + + Oriënterend materiaal — wat een MUSH is, welke codebase past bij gezamenlijk rollenspel — wordt geschreven, ondertekend en van versies voorzien zoals de rest van de site. + + + Spelersnamen worden nooit bewaard. + + + Een WHO-antwoord wordt in het geheugen ontleed op een telling en de vorm van de kop. De namen worden niet vastgelegd; aggregaten gebruiken een gezouten hash met een roulerende salt. + + + Er wordt geen absoluut spelersaantal gepubliceerd. + + + Aandelen per codebase en per protocol gaan wel de deur uit: een verhouding over de gemeten verzameling overleeft de spellen die we niet kunnen tellen. ‘Hoeveel mensen spelen MU*’ overleeft dat niet, want dat getal zou het niet houden zodra het geciteerd werd. + + + De crawler, en hoe die te stoppen is + + + Een peiling is één verbinding die nooit inlogt. + + + Die opent een socket, onderhandelt over telnet-opties, leest het verbindingsscherm, vraagt om MSSP door optie 70 te onderhandelen, stuurt {commands} en verbreekt de verbinding. Geen personage, geen login, niets veranderd aan de andere kant. Een time-out begrenst de sessie, zodat een vastgelopen peiling geen verbindingsplek bezet kan houden. + + + CRAWL DELAY wint. + + + Een spel dat in zijn MSSP-rapport een gewenste minimale tussenpoos opgeeft, krijgt die, boven ons eigen schema in beide richtingen: 720 uur betekent maandelijks, niet wekelijks. Bij een stilgevallen spel wordt voor altijd op de langere tussenpoos aangeklopt, en zo zet het zichzelf weer in de lijst wanneer het terugkomt. + + + Een doorverwezen adres wordt geverifieerd, nooit vertrouwd. + + + Met MSSP kan een spel andere spellen noemen. Elke naam wordt opgelost voordat er ergens wordt aangeklopt, en geweigerd tenzij elk adres waarnaar hij oplost wereldwijd routeerbaar is. Een gemengd antwoord weigert het hele doel. Onze weigering wordt als de onze vastgelegd en verschijnt nooit als downtime in het dossier van een spel. + + + Verbindingsschermen worden getoond omdat ze naar iedereen gestuurd worden. + + + Een server tekent zijn verbindingsscherm, zonder authenticatie, naar elke anonieme verbinding. We tonen het als bewijs en labelen het. Eén verzoek en het gaat eraf. + + + Zeg stop, en we stoppen — op drie manieren. + + + Publiceer {variable} 1 in je MSSP-rapport, en de peiling die dat leest is de laatste. Of publiceer een TXT-record op {label}.jouw.host met de inhoud ‘{value}’, waarvoor geen MSSP-ondersteuning en geen account hier nodig is. Of schrijf een mens aan. Alle drie worden binnen één crawlcyclus gehonoreerd, vastgelegd met de datum en met wat we lazen, en ook op het aanmeldformulier afgedwongen. + + + Het MSSP-veld stopt die luisterpoort; het record stopt de host. + + + MSSP wordt gepubliceerd door de poort die antwoordde, dus spreekt het voor die poort — bij MU*-hosting draaien routinematig ongerelateerde spellen op één domein, en het ene mag het andere niet het zwijgen opleggen. Een TXT-record geldt voor elke poort tenzij het er een noemt, als ‘{value}=4201’. Alles wat daar staat en wat we niet als poortenlijst kunnen lezen betekent de hele host, dus ‘{value}=all’ werkt. + + + De DNS-route is de enige die je ongedaan kunt maken zonder het ons te vragen. + + + Een TXT-record is leesbaar zonder verbinding te maken met een server die dat verboden heeft, dus we lezen het opnieuw voordat we aankloppen. Verwijder het en we kloppen binnen een week weer aan. Een MSSP-veld kan niet opnieuw gelezen worden zonder precies te doen wat je ons gevraagd hebt te laten, dus afmeldingen via MSSP en schriftelijke verzoeken blijven staan tot je het tegendeel zegt. Die TXT-opzoeking is alles wat een afgemeld adres krijgt: ze raakt je nameserver, nooit je spel. + + + Stoppen is niet verwijderen, en het is geen downtime. + + + Een spel dat zich afmeldt, houdt zijn pagina, zijn adres en alles wat we gemeten hebben voordat het erom vroeg. Alleen nieuwe gegevens stoppen: het activiteitenraster krijgt er geen uren meer bij en noemt geen oorzaak, want ons besluit om te stoppen met kloppen is een feit over ons. Het wordt vastgelegd bij de crawl die niet plaatsvond, en in het register van wie erom vroeg. + + + Als stoppen niet genoeg is, kan de vermelding er ook af. + + + Zodra we gestopt zijn op elk adres waarop je spel antwoordt, biedt je dashboard nog één ding: het uit de lijst, de ranglijsten en het dagcijfer halen. De pagina en elk adres dat het ooit gehad heeft blijven antwoorden, en er wordt niets verwijderd — het houdt op een plek te zijn waar een lezer al bladerend aankomt. Er is een geverifieerde claim voor nodig, want het is een besluit over je spel en we leggen vast wie het genomen heeft. En een peiling maakt het ongedaan: trek je afmelding in, en de eerstvolgende keer dat er aangeklopt wordt en er antwoord komt, sta je weer in de lijst zonder dat je het ons nog eens hoeft te vragen. + + + De crawler noemt zichzelf {name} wanneer een server vraagt wat hij is. + + + De crawler is ingesteld om zichzelf {name} te noemen, maar kan dat nog niet zeggen. Zijn telnet-bibliotheek biedt een client geen manier om het terminaltype in te stellen, dus je logboeken zien de standaardwaarde van die bibliotheek, en NEW-ENVIRON wordt beantwoord vanuit de omgeving van de crawlerhost. Beide zijn gaten in de bibliotheek en aan ons om ze daar te dichten. Tot die tijd: herken een peiling aan de vorm ervan — één verbinding, geen login, een korte set alleen-lezen-commando’s, weg. + + + crawler + + + contact + + + Crawler: {name} + + + Contact: {url} + + + — tijdelijke aanduiding; deze installatie heeft geen contactadres ingesteld + + + Er is geen contactadres ingesteld, dus het adres hierboven is een tijdelijke aanduiding en beantwoordt niemand. + + + Waar de lijst met spellen vandaan komt + + + We nemen adressen over. Verder niets. + + + Een backfill haalt een host en een poort op. Geen spelerstellingen, geen bereikbaarheidsgeschiedenis, geen beschrijvingen, geen velden, en geen aantekening van welke site een adres kwam. + + + Bewust minder dan die sites kunnen geven. + + + Verschillende bewaren jaren aan gedateerde spelerstellingen. Dat importeren zou de heatmaps vullen van de spellen die iemand anders al in de gaten hield, en de centrale claim van deze site laten steunen op de peilingen van een ander. + + + De herkomst van een spel is niet één feit. + + + Elk spel dat het vermelden waard is, staat in meerdere van deze gidsen, dus ‘geïmporteerd uit’ zou noemen welke ophaalronde toevallig het eerst liep. Dat een spel bestaat is openbare informatie; waar we het gelezen hebben voegt niets toe en is het deel van andermans werk waarop wij de minste aanspraak hebben. + + + Andermans site lezen blijft andermans site lezen. + + + We vragen om een bulkexport of een gedocumenteerd endpoint voordat we scrapen, lezen eerst robots.txt, en beperken de snelheid van scrapes streng. Een bron waarvoor toestemming van de beheerder nodig is, wordt niet opgehaald tot een mens kan verklaren dat het gevraagd is. + + + gelezen — alleen adressen + + + niet gelezen — wacht op toestemming + + + Eén pagina, één verzoek. Gepubliceerd door een crawler die met elk spel verbinding maakt en afdrukt wat hij las. + + + De MSDP-lijst van dezelfde crawler. Bijna een deelverzameling van zijn MSSP-tegenhanger, gelezen om de paar adressen die hij wel bereikt en de andere niet. + + + Publiceert zijn hele catalogus op één pagina, dus het lezen ervan kost één verzoek. Onze grootste bron van adressen, en van geen enkele meting. + + + Eén indexpagina en één pagina per wereld, dus een scrape in plaats van een export. Op 30 juli 2026 hebben we 143 van hun pagina’s opgehaald, met vijftien seconden ertussen en met inachtneming van robots.txt, maar voordat iemand hun geschreven had. Dat had niet mogen gebeuren. De poort vraagt nu om een mens die wil verklaren dat de beheerder gevraagd is. + + + Geïmplementeerd, getest, nooit uitgevoerd. De sterkste bron hier op elke as behalve toestemming, en er wordt niets opgehaald tot iemand hun geschreven heeft. + + + Licentie + + + De code is MIT. + + + De site, de crawler en de parsers zijn opensource onder de MIT-licentie. + + + De licentie voor de gegevens is een open vraag. + + + Een besluit los van dat over de code, en nog niet genomen. Lees de voorwaarden hieronder als het huidige antwoord van deze installatie, niet als het vastgelegde standpunt van het project. Dat een concurrerende gids de hele catalogus overneemt geldt hier als succes, dus wat er ook wordt vastgelegd, het zal zo’n gids niet in de weg staan. + + + code + + + gegevens, zoals deze installatie ze aanbiedt + + + vermelden als + + + Code: {licence} + + + Gegevens: {licence} + + + Vermelden als: {credit} + + + (wat deze installatie aanbiedt. Het antwoord van het project zelf ligt nog open.) + + + Een spel aanmelden + + + Vertel ons waar een spel staat. Een host en een poort zijn het hele formulier; al het andere op deze site wordt door onze eigen crawler gemeten. + + + Host + + + Poort + + + mud.example.org, of plak mud.example.org:4201 en laat de poort leeg + + + Aanmelden + + + Aanmelden vereist een database, en deze site draait op de demofixture. Er is geen crawlregister om in te schrijven, dus het formulier ontbreekt in plaats van stilletjes niets te doen. + + + Niet hier + + + Wat er met een adres gebeurt + + + We lossen het adres op voordat we aankloppen, en weigeren alles wat buiten het publieke internet uitkomt. Dat is een besluit over onze eigen socket, nooit een feit over een spel. + + + Als degene die die host draait ons gevraagd heeft er niet te crawlen, nemen we het adres niet aan, door wie het ook wordt aangemeld. Een vreemde kan je spel niet terugzetten op deze site. + + + Als het antwoordt, lezen we wat de server over zichzelf zegt en blijven we dat lezen op zijn eigen schema, voor altijd. Een adres hoeft maar één keer gegeven te worden. + + + Er verschijnt niets op de site voordat iemand bewezen heeft het te draaien. Claimen vereist een passkey en één regel die op het spel zelf gepubliceerd wordt. + + + Een adres dat we al hebben valt samen met de bestaande vermelding. Het twee keer sturen levert geen tweede vermelding op en haalt geen peiling naar voren. + + + dat adres + + + In het register. + + + Bij {address} wordt in de volgende crawlcyclus aangeklopt, daarna op zijn eigen schema, voor altijd. Het verschijnt hier zodra iemand bewijst het te draaien — kom met hetzelfde adres terug naar dit formulier en het geeft je de link. + + + We hebben het, niet geclaimd. + + + {address} is er een die we al meten. Het blijft van de site af tot iemand bewijst het te draaien. Ben jij dat, dan is dit de weg naar binnen. + + + Die hebben we al. + + + {address} is een spel dat we al meten. Er is niets aangemaakt en niets gewijzigd. + + + Dat adres hebben we al. + + + {address} is bij ons al bekend. Er is niets aangemaakt en niets gewijzigd. + + + Staat al te wachten. + + + {address} staat in het crawlregister en heeft nog niet geantwoord. Het opnieuw sturen haalt het niet naar voren: een doel houdt zijn eigen schema aan, dus niemand kan ons opjagen bij andermans server. + + + Geen adres waar we kunnen aankloppen. + + + Een host heeft een punt of een dubbele punt nodig, en een poort is een getal tussen 1 en 65535. Vul beide velden in, of plak mud.example.org:4201 in het eerste. + + + Daar kunnen we niet aankloppen. + + + Drie dingen leveren dit antwoord op voor {address}: de naam wordt misschien niet opgelost, hij komt misschien buiten het publieke internet uit, of degene die die host draait heeft ons misschien gevraagd weg te blijven. We zeggen bewust niet welke, want dat voor een vreemde beantwoorden brengt een netwerk van buitenaf in kaart. Er is niets over het adres vastgelegd; het besluit was het onze en het is als het onze vastgelegd. + + + Genoeg voor nu. + + + Dit formulier is per afzender in snelheid beperkt, en je hebt de grens bereikt. Kom over een uur terug. Er is niets verloren gegaan — alles wat we aangenomen hebben staat al in het register. + + + dit spel claimen + + + Inloggen + + + Log in met een passkey om een spel te claimen dat je zelf draait. Er is geen wachtwoord om kwijt te raken en geen om te stelen. + + + Claimen vereist een database, en deze site draait op de demofixture. Er is niets om op in te loggen. + + + Inloggen gaat met een passkey. + + + Je apparaat of wachtwoordmanager houdt de privésleutel; wij houden alleen de publieke helft. Geen wachtwoord, geen e-mail. + + + Inloggen met een passkey + + + De enige pagina hier die JavaScript nodig heeft. Passkeys werken er niet zonder. + + + Nog geen account? + + + Je hebt er alleen een nodig om een spel te claimen dat je zelf draait. Kies een naam om onder bekend te staan — een label naast je claim, geen echte naam. + + + Naam + + + bijv. corvid-admin + + + Een account aanmaken met een passkey + + + Wat we opslaan + + + De naam die je gekozen hebt. + + + De publieke sleutel van elke passkey die je registreert, en hoe je apparaat die noemde. + + + Welke spellen je geclaimd hebt, en wanneer. + + + Geen e-mailadres, geen wachtwoord, geen IP-log gekoppeld aan je account. Raak je elke passkey kwijt, dan kun je een nieuw claimtoken op je spel publiceren en opnieuw beginnen: het spel is het bewijs, niet het account. + + + {day} {month} {year} + + + {date} {time} UTC + + + nu + + + {count, plural, one {#m} other {#m}} + + + {count, plural, one {#u} other {#u}} + + + {count, plural, one {#d} other {#d}} + + + {count, plural, one {#w} other {#w}} + + + {count, plural, one {#mnd} other {#mnd}} + + + {count, plural, one {#j} other {#j}} + + + zojuist + + + {count, plural, one {#m geleden} other {#m geleden}} + + + {count, plural, one {#u geleden} other {#u geleden}} + + + {count, plural, one {#d geleden} other {#d geleden}} + + + {count, plural, one {#w geleden} other {#w geleden}} + + + {count, plural, one {#mnd geleden} other {#mnd geleden}} + + + {count, plural, one {#j geleden} other {#j geleden}} + + + zojuist + + + {count, plural, one {#m geleden} other {#m geleden}} + + + {count, plural, one {#u geleden} other {#u geleden}} + + + {count, plural, one {#d geleden} other {#d geleden}} + + + {count, plural, one {#w geleden} other {#w geleden}} + + + {count, plural, one {#mnd geleden} other {#mnd geleden}} + + + {count, plural, one {#j geleden} other {#j geleden}} + + + {age}, {stamp} + + + , {stamp} + + + {value} — {how} via {source}, laatst bevestigd {date} + + + {value} — {how} via {source}, laatst bevestigd {date} (voorbij de verwachte verversing) + + + ({how}, {age}) + + + ({how}, {age}, verouderd) + + + opgegeven door de eigenaar + + + de redactie + + + de telnet-handshake + + + de eigenaar + + + WHO + + + I3 + + + MSSP + + + INFO + + + de I3-mudlist + + + het verbindingsscherm + + + Het ecosysteem + + + Aandelen, nooit totalen. We publiceren geen cijfer voor hoeveel mensen MU* spelen: een verhouding over de spellen die we gemeten hebben overleeft de spellen die we niet kunnen bereiken, en een koppentelling niet. + + + {count, plural, one {{value} spel vermeld} other {{value} spellen vermeld}} + + + {count, plural, one {{value} spel waarvan we de handshake voltooiden} other {{value} spellen waarvan we de handshake voltooiden}} + + + {count, plural, one {{value} spel waarvan we het MSSP-rapport hebben} other {{value} spellen waarvan we het MSSP-rapport hebben}} + + + Oudste handshake hier: {age} geleden bevestigd. + + + {count, number} van {total, number} ({fraction, number, ::percent .0}) + + + {count, number} van {total, number} — nog niets gemeten + + + {fraction, number, ::percent .0} + + + Codebases + + + Van de {listed, plural, one {# vermelde spel} other {# vermelde spellen}} hebben er {identified, number} ons verteld waarop ze draaien, en elk aandeel hieronder gaat over die {identified, number}. Een codebase die we niet konden lezen valt buiten de noemer, en wordt nooit als iets anders geteld. + + + Nog geen vermeld spel heeft ons zijn codebase verteld. + + + {share} draait op een codebase die geen enkel ander vermeld spel draait — één spel per stuk, wat een naam is en geen aandeel. Ze vallen binnen de noemer hierboven en zijn uit de balken gevouwen, niet weggelaten: + + + Afstammingen + + + Dezelfde spellen, gegroepeerd naar de traditie waar hun server van afstamt — onze lezing van de codebase, niet iets wat een spel gepubliceerd heeft. Geen enkel spel meldt ‘MUSH’: MSSP kent die waarde niet, en het grootste deel van de MUSH-wereld publiceert helemaal geen MSSP, dus alleen zo kan de vraag gesteld worden. + + + Nog geen vermeld spel draait op een codebase die we in een afstamming plaatsen. + + + {count, plural, one {# van die spellen draait op een codebase} other {# van die spellen draaien op codebases}} die we in geen enkele afstamming plaatsen — verschillende zeggen dat zelf ook, door {family} te publiceren. Ze vallen binnen de noemer hierboven en in niemands aandeel. + + + Protocollen + + + Lees elk gemeten cijfer hieronder als een ondergrens. We vragen MSSP met name aan, dus stilte daar is een antwoord. Om de rest hier wordt niet gevraagd, en een server kan een protocol ondersteunen zonder het ooit aan te bieden. + + + {instrument} is de enige rij hieronder die geen ondergrens is: we vragen elke server er met name om, dus de spellen die het niet aanboden zijn gevraagd en hebben geweigerd. Het is ook de enige zonder opgegeven cijfer, want elk spel waarvan we het rapport hebben ondersteunt het bij wijze van bewijs, en een telling van de spellen die het ook vermeldden zou daartegen een gewoonte meten. + + + We hebben {reports, number} rapporten en {offered, plural, one {# spel biedt} other {# spellen bieden}} MSSP vandaag aan: de andere {gap, number} zijn gestopt met publiceren nadat we het gelezen hadden, en een rapport wordt niet weggegooid omdat het niet meer opnieuw uitgegeven wordt. + + + Protocoladoptie. Gemeten is wat een server aanbood in een voltooide handshake; opgegeven is wat zijn MSSP beweert. Twee verzamelingen spellen, dus twee noemers. + + + protocol + + + gemeten — van {basis} + + + opgegeven — van {basis} + + + niet gemeten — nooit waargenomen + + + {share} · {declined, plural, one {# spel weigerde toen we het vroegen} other {# spellen weigerden toen we het vroegen}} + + + {share} · {unobserved, plural, one {bij # spel niet aangeboden en niet gevraagd} other {bij # spellen niet aangeboden en niet gevraagd}} + + + {share} · {declined, plural, one {# spel weigerde toen we het vroegen} other {# spellen weigerden toen we het vroegen}} · {unobserved, plural, one {bij # spel niet aangeboden en niet gevraagd} other {bij # spellen niet aangeboden en niet gevraagd}} + + + niet gevraagd — elk rapport hier is het antwoord + + + Adoptie, in de loop van de tijd + + + Elk punt is een aandeel over de spellen die we die dag gemeten hadden, dus deze lijn beweegt om twee redenen: doordat een spel verandert wat het aanbiedt, en doordat de verzameling spellen die we kunnen meten eromheen verandert. Alleen het eerste is adoptie. De telling van veranderingen hieronder is het deel dat puur spellen zijn die van gedachten veranderen. + + + Gemeten aandeel van elk protocol, oudste meting eerst + + + toen + + + nu + + + niet gemeten + + + Een momentopname, geen curve + + + Een momentopname van wat we nu kunnen meten. Een adoptiecurve zet spellen uit die van gedachten veranderen, en we leggen een verandering vast wanneer die gebeurt, dus de curve wordt tekenbaar zodra er genoeg vastgelegd zijn. Uitzetten wanneer we elk spel voor het eerst bereikten zou de crawl meten, niet de hobby. + + + {count, plural, one {# wijziging in mogelijkheden} other {# wijzigingen in mogelijkheden}} tot nu toe vastgelegd — het materiaal waaruit een curve getekend wordt. + + + Er is nog geen gemeten mogelijkheid veranderd, dus er valt niets uit te zetten. + + + Gemeten is van {measured}; opgegeven is van {declared}. Twee verzamelingen spellen, dus twee noemers. + + + {listed} · {handshakes} · {mssp}. + + + De oudste handshake in dit beeld is {age} geleden voor het laatst bevestigd. + + + Dezelfde spellen, gegroepeerd naar de traditie waar hun server van afstamt. Dit is {evidence} — {meaning} — en niet iets wat een spel gepubliceerd heeft: geen enkel spel meldt ‘MUSH’, want MSSP kent die waarde niet en het grootste deel van de MUSH-wereld publiceert helemaal geen MSSP. + + + gemeten: {value} + + + opgegeven: {value} + + + Ranglijsten + + + Alleen berekend uit gemeten gegevens. Nooit stemmen, sterren of beoordelingen. Niets hier rangschikt kwaliteit. Die hebben we niet gemeten. + + + Drukst, naar gemeten gelijktijdige spelers + + + Ranglijstperiode + + + {days, plural, one {# dag} other {# dagen}} + + + Mediaan van de spelerstellingen die we over de laatste {days, plural, one {# dag} other {# dagen}} gemeten hebben. + + + Nog geen spel heeft de {samples, number} tellingen over {days, plural, one {# dag} other {# dagen}} die een mediaan nodig heeft. + + + Van de {listed, number} spellen {eligible, plural, one {heeft # spel} other {hebben # spellen}} de {samples, number} tellingen over {days, plural, one {# dag} other {# dagen}} die daarvoor nodig zijn. + + + Een gemeten nul telt mee; een onleesbare telling niet. + + + Een week zegt wie er nu druk is; een kwartaal zegt wie druk is geweest. Dat zijn verschillende vragen, en een spel kan de ene aanvoeren en de andere niet. Dagen zijn hele dagen, UTC. + + + Nog geen vermeld spel heeft genoeg tellingen om te rangschikken — een uitspraak over hoe lang we al meten, niet over hoe druk het ergens is. + + + Spellen gerangschikt naar de mediaan van de spelerstellingen die we over de laatste {days, plural, one {# dag} other {# dagen}} gemeten hebben. Spellen met dezelfde mediaan delen een plaats; niets hier breekt de gelijkstand. + + + # + + + spel + + + mediaan + + + piek + + + tellingen + + + gemeten dagen + + + Langste ononderbroken bereikbaarheid + + + Elke peiling sinds de genoemde datum trof het spel bereikbaar aan. Bereikbaar, niet ‘up’: we meten een socket vanaf één host, en een spel waarheen we geen route hebben is volkomen springlevend. Zo’n reeks kan niet langer zijn dan we al meekijken, dus de datum is het feit en de duur volgt daaruit. + + + Geen vermeld spel zit op dit moment in een ononderbroken reeks bereikbaarheid. + + + Spellen die bij elke peiling sinds de genoemde datum bereikbaar waren. Spellen die sinds dezelfde datum bereikbaar zijn delen een plaats; niets hier breekt de gelijkstand. + + + bereikbaar sinds + + + dat is + + + Gearchiveerde spellen staan buiten beide tabellen en verder niets; één geslaagde peiling zet ze terug. + + + Drukst — mediaan gemeten spelers, laatste {days, plural, one {# dag} other {# dagen}} + + + periodes: + + + deze + + + mediaan {median, number} · piek {peak, number} · {samples, plural, one {# telling} other {# tellingen}} over {days, number} van {window, number} dagen + + + bereikbaar bij elke peiling sinds {date} · {duration} + + + Het archief + + + Spellen die gestopt zijn met antwoorden. Er is niets verwijderd. Ze worden nog wekelijks gepeild, en één geslaagde peiling zet een spel diezelfde dag terug in de lijst. + + + het archief doorzoeken + + + Gearchiveerde spellen zoeken + + + naam, codebase of beschrijving + + + tonen + + + {count, plural, =0 {Geen gearchiveerde spellen} one {# gearchiveerd spel} other {# gearchiveerde spellen}} + + + gearchiveerd + + + laatst bereikbaar + + + bekend live + + + ({age} geleden) + + + Geen treffers. + + + nooit, in niets wat we gemeten hebben + + + geen bereikbare tijd gemeten + + + onbekend + + + {years, number, ::.#} jaar + + + {days, plural, one {# dag} other {# dagen}} + + + {from} – {to} · {span} + + + {count, plural, one {# maand} other {# maanden}} + + + {count, plural, one {# jaar} other {# jaar}} + + + {count, plural, one {# spel dat overeenkomt met ‘{query}’} other {# spellen die overeenkomen met ‘{query}’}} + + + {count, plural, one {# spel} other {# spellen}} + + + Laatst bereikbaar: + + + Bekend live: + + + {value} van de gemeten bereikbare tijd + + + Looptijd: + + + Codebase: + + + willekeurig spel + + + {d, date, medium} — gemiddeld {typical}, {low}–{high} over {probes, plural, one {# peiling} other {# peilingen}} + + + {d, date, medium} — {count, plural, =0 {0 spelers} one {# speler} other {# spelers}}, bij elk van {probes, plural, one {# peiling} other {# peilingen}} + + + {d, date, medium} — gepeild, geen telling kon worden gelezen + + + {d, date, medium} — geen meting + + + Typisch {typical} verbonden, met een piek van {peak}, over {counted} van {days, plural, one {# dag} other {# dagen}}. + + + Gelijkmatig over het hele bereik. + + + Ongeveer {change, number, percent} omhoog van het begin van het bereik tot het eind. + + + Ongeveer {change, number, percent} omlaag van het begin van het bereik tot het eind. + + + In dit bereik gepeild, en uit niets ervan kon een spelerstelling worden gelezen. + + + Geen meting in dit bereik. + + + Niets geteld in dit bereik. + + + {from, date, d MMM}–{to, date, d MMM} + + + {d, date, d MMM} + + + {span}: typisch {typical}, piek {peak}, {days, plural, one {# dag} other {# dagen}} geteld + + + {span}: gepeild, geen telling kon worden gelezen + + + {span}: niet gemeten + + + {count, plural, one {# dag gepeild zonder telling} other {# dagen gepeild zonder telling}} + + + {count, plural, one {# dag niet gemeten} other {# dagen niet gemeten}} + + + {line}, {clause} + + + dagen in UTC · {from, date, medium} – {to, date, medium} + + + {value} bovenaan + + + {counted} van {days, plural, one {# dag} other {# dagen}} geteld + + + {d, date, MMM} + + + {d, date, MMM yyyy} + + + gemiddelde van de tellingen die we die dag lazen + + + tot aan de drukste telling van die dag + + + laagste tot hoogste telling van die dag + + + gepeild, geen telling kon worden gelezen + + + niet gemeten — helemaal geen balk + + + niet gemeten — een onderbreking in de lijn + + + Trendbereik + + + Trendvorm + + + {days, plural, one {# dag} other {# dagen}} + + + ← eerder + + + later → + + + lijn + + + staven + + + Hoeveel, in de loop van de tijd + + + {from, date, medium} – {to, date, medium}, UTC + + + eerder + + + een week wordt samengevat over de dagen erin die we geteld hebben; een week zonder zegt dat + + + bereikbaar · laatste {days, plural, one {# dag} other {# dagen}} + + + bereikbaar + + + langste onderbreking + + + laatste oorzaak + + + geen in de periode + + + niets vastgelegd + + + {days, plural, one {# dag geleden} other {# dagen geleden}} + + + vandaag + + + bereikbaar + + + haperend — antwoordde, kon niet afronden + + + onbereikbaar + + + niet gemeten + + + bereikbaar + + + haperend + + + onbereikbaar + + + niet gemeten + + + {d, date, d MMM} — de hele dag bereikbaar + + + {d, date, d MMM} — haperend ({cause}): antwoordde, kon niet afronden + + + {d, date, d MMM} — onbereikbaar ({cause}) + + + {d, date, d MMM} — niet gemeten; we volgden dit spel toen nog niet + + + {days, plural, one {Nog niet gemeten over de laatste # dag.} other {Nog niet gemeten over de laatste # dagen.}} + + + Bereikbaar {percent} van de laatste {days, plural, one {# dag} other {# dagen}}. + + + Bereikbaar {percent} van de {days, plural, one {# dag} other {# dagen}} die we gemeten hebben. + + + {days, plural, one {De bereikbaarheid over de laatste # dag is nog niet gemeten.} other {De bereikbaarheid over de laatste # dagen is nog niet gemeten.}} + + + Geen enkele dag in de periode was onbereikbaar. + + + Geen enkele dag die we gemeten hebben was onbereikbaar. + + + {count, plural, one {# dag onbereikbaar.} other {# dagen onbereikbaar.}} + + + {count, plural, one {# dag haperend — we kwamen binnen en konden niet afronden.} other {# dagen haperend — we kwamen binnen en konden niet afronden.}} + + + Langste onderbreking {duration}. + + + Langste onderbreking {duration} ({cause}). + + + {count, plural, one {# dag ligt vóór alles wat we gemeten hebben.} other {# dagen liggen vóór alles wat we gemeten hebben.}} + + + {from, date, d MMM} – {to, date, d MMM} + + + {d, date, d MMM} + + + {range}: {count, plural, one {# dag} other {# dagen}} {word} + + + {range}: {count, plural, one {# dag} other {# dagen}} {word} ({cause}) + + + Bereikbaar + + + {days, plural, one {laatste # dag} other {laatste # dagen}} + + + Bereikbaar: {percent} van de {days, plural, one {laatste # dag} other {laatste # dagen}} + + + Langste onderbreking: {duration} + + + dns loste niet op + + + verbinding geweigerd + + + tls mislukt + + + time-out + + + handshake liep vast + + + geen oorzaak vastgelegd + + + De eigenaar heeft ons gevraagd het verbindingsscherm van dit spel niet opnieuw te publiceren. + + + Van dit spel is geen verbindingsscherm vastgelegd. + + + {count, plural, one {Er kwam maar # rij terug — te weinig om te tonen.} other {Er kwamen maar # rijen terug — te weinig om te tonen.}} + + + zoals verzonden door de server + + + {columns}×{rows} + + + {columns}×{rows}, dubbele breedte + + + 16-kleuren SGR + + + geen kleur + + + gelezen als {charset} + + + vastgelegd + + + bevroren — het laatste scherm dat we zagen + + + ASCII-kunst: het verbindingsscherm van {game}. De tekst ervan staat onder ‘als tekst lezen’, hieronder. + + + ASCII-kunst: het verbindingsscherm van dit spel. De tekst ervan staat onder ‘als tekst lezen’, hieronder. + + + {count, plural, one {verbindingsscherm: # regel, alleen tekst} other {verbindingsscherm: # regels, alleen tekst}} + + + {count, plural, one {verbindingsscherm: # regel, alleen tekst, gelezen als {charset}} other {verbindingsscherm: # regels, alleen tekst, gelezen als {charset}}} + + + het spel geeft het op, en de server heeft het nooit aangeboden in een handshake. + + + de server biedt het aan, en het spel zegt in zijn eigen opgave van niet. + + + Meestal een verouderd, met de hand ingetypt veld, geen leugen. Getoond omdat een client niet moet vertrouwen op datgene waarover de twee van elkaar afwijken. + + + Niet gevonden + + + Geen spel op dit adres. Controleer de spelling. + + + laatst geantwoord + + + {count, plural, one {# is voorbij zijn verversingstermijn. Oud, niet fout.} other {# zijn voorbij hun verversingstermijn. Oud, niet fout.}} + + + De eigen verwijzingslijst van dit spel noemt: + + + Genoemd door de verwijzingslijst van: + + + sinds {date} + + + niet meer vermeld, laatst gezien + + + Laatst geantwoord op {date}, {ago} geleden. + + + Wordt nog wekelijks gepeild; deze pagina wordt bijgewerkt op de dag dat het antwoordt. + + + {span} bekend live + + + Niet in de lijst, de ranglijsten of het dagcijfer: we denken niet dat dit adres een spel is dat iemand kan spelen. Alles hieronder is wat het ons vertelde, ongewijzigd. + + + Onze reden: {why} + + + Niet in de lijst, de ranglijsten of het dagcijfer, op verzoek van de mensen die het draaien. Alles hieronder is bewaard zoals het was, deze pagina en elk adres dat het ooit gehad heeft blijven antwoorden, en we kloppen er niet aan. + + + Spelers nu: {count} + + + Mogelijkheden ({disagreeing} van {total} wijken af) + + + Opgegeven door het spel + + + Verbindingsscherm + + + Wat er veranderd is + + + gemeten + + + opgegeven + + + ** wijkt af + + + verbonden + + + bereikt + + + zoeken + + + gearchiveerd + + + voor volwassenen + + + opgenomen + + + geen telling + + + geen actuele telling, en niets hier zegt waarom + + + Spelers nu: geen telling (niets hier zegt waarom) + + + crawler actief · laatste peiling {age} + + + crawler wacht · laatste peiling {age} + + + hier is nog geen peiling afgerond + + + niets aan de beurt deze cyclus + + + {considered, plural, one {# aan de beurt} other {# aan de beurt}} · {answered, plural, one {# beantwoord} other {# beantwoord}} · {failed, plural, one {# mislukt} other {# mislukt}} + + + {targets, plural, one {# adres in het register} other {# adressen in het register}}, {due, plural, one {# nu aan de beurt} other {# nu aan de beurt}} + + + {page} — {site} + + + Een gids voor de MU*-hobby — MUSHes, MUDs, MUCKs, MOOs — waar elk feit vermeldt hoe het verkregen is en hoe oud het is. + + + Demogegevens — niets hiervan is gemeten. {description} + + + {site} — gemeten, niet beweerd + + + {title} op {site} + + + Spellen + + + Het archief + + + Ranglijsten + + + Het ecosysteem + + + Naslag + + + Over + + + Niet gevonden + + + Willekeurig spel + + + Je spellen + + + {game} claimen + + + Elke MU* die we bereikt hebben, gefacetteerd op onze metingen: codebase, protocollen uit de handshake, TLS, codering, taal, laatst gezien. + + + Stilgevallen spellen, bewaard. Elk houdt zijn pagina, geschiedenis en URL, wordt wekelijks gepeild en keert terug in de lijst bij één geslaagde verbinding. + + + Drukst, best bereikbaar, langst draaiend — alleen berekend uit metingen. Nergens op deze site stemmen, sterren of beoordelingen. + + + Aandeel per codebase en protocoladoptie over de spellen die we meten, met wat servers aanbieden naast wat ze opgeven. Aandelen, nooit totalen. + + + Met de hand geschreven pagina’s over de codebases, clients en protocollen van de MU*-hobby, met verwijzingen naar tellingen uit de crawl. + + + Hoe deze catalogus gebouwd wordt: wat de crawler doet, wat hij weigert te doen, en hoe hij te stoppen is. + + + Geen spel op dit adres. Hier wordt nooit iets verwijderd, dus een spel dat ooit op deze URL stond, staat er nog — controleer de spelling. + + + Eén spel uit de catalogus, willekeurig gekozen en nooit twee keer hetzelfde. + + + De vermeldingen die je geclaimd hebt, en wat je met een claim kunt wijzigen. + + + Bewijs dat je dit spel draait door een token te publiceren waar alleen wie het draait het kan neerzetten. + + + Gearchiveerd — laatst bereikbaar {age}, en wordt nog gepeild + + + Gearchiveerd, en wordt nog gepeild + + + Spelerstelling onbekend — het spel antwoordt en publiceert geen getal dat wij kunnen lezen + + + {count, plural, one {# speler} other {# spelers}}, {how} {age} + + + Nieuw ontdekt + + + Stilgevallen + + + Weer terug + + + {count, plural, one {# bekend spel} other {# bekende spellen}} + + + {count, plural, one {# nu verbonden (gemeten)} other {# nu verbonden (gemeten)}} + + + {count, plural, one {# antwoordt, ongeteld} other {# antwoorden, ongeteld}} + + + {count, plural, one {# gearchiveerd, wordt nog gepeild} other {# gearchiveerd, worden nog gepeild}} + + + Naslag + + + Wat de codebases zijn, wat de clients doen, en wat de protocollen betekenen. Met de hand geschreven en in de repository naast de crawler bewaard — geen wiki, en er valt op deze pagina niets te bewerken. Elk {number} hier is iets anders: het komt uit de catalogus en wordt opnieuw berekend telkens als de pagina geladen wordt. + + + getal + + + Met de hand geschreven, door één auteur, en van versies voorzien in git. De tekst hier is van ons; elk getal ernaast is door de crawler gemeten en wordt bij elk verzoek opnieuw berekend. Dit is geen wiki, en vanaf deze pagina is er niets te bewerken. + + + Begin hier + + + Codebases + + + Clients + + + Protocollen + + + oriëntatie + + + codebase + + + client + + + protocol + + + Niet gevonden + + + Hier staat geen naslagpagina. Dit onderdeel is met de hand geschreven, dus een gat is werk dat niemand gedaan heeft en niet iets wat verwijderd is — {index}. + + + kijk wat er wel is + + + Spellen die erop draaien + + + We hebben er nog geen vastgesteld. Dat is een feit over wat deze crawler gemeten heeft, niet over wat er bestaat — een spel dat we niet bereikt hebben, of waarvan we de codebase niet konden lezen, telt hier niet mee. + + + {count, plural, one {# vermeld} other {# vermeld}} + + + {count, plural, one {# gearchiveerd} other {# gearchiveerd}} + + + gemeten, nooit beweerd + + + Geteld uit de catalogus bij dit verzoek, over hetzelfde filter als de link hierboven — dus dit getal en die lijst zijn één zoekopdracht en kunnen niet uit elkaar lopen. + + + aangeboden in hun handshakes: {protocols} + + + Gemeten adoptie + + + Nog niets gemeten. + + + van {listed, plural, one {# vermeld spel} other {# vermelde spellen}} — {percent} + + + De spellen die hier niet meetellen zijn geen spellen zonder het protocol. Een spel telt mee wanneer we zijn server de optie in een handshake hebben zien aanbieden; de rest zijn servers die het ons niet aangeboden hebben en servers waarvan we de handshake niet gelezen hebben, en welke van beide het is kunnen we niet zeggen. + + + Spellen die we {protocol} in een handshake hebben zien aanbieden, naar de codebase waarvan we vaststelden dat ze erop draaien. + + + codebase + + + bood het aan + + + vastgesteld + + + Zie ook + + + Mogelijkheden + + + {count, plural, one {# van de {total} vastgesteld uit de eigen documentatie van het project} other {# van de {total} vastgesteld uit de eigen documentatie van het project}} + + + Afgelezen uit de eigen documentatie van elk project, niet door ons gemeten — een client heeft geen handshake die wij kunnen waarnemen. ‘{unknown}’ betekent dat we gekeken hebben en het niet vastgesteld hebben. Het betekent nooit nee. + + + Mogelijkheden van clients, elk afgelezen uit de eigen documentatie van het project. Onbekend betekent dat we het niet vastgesteld hebben, en nooit dat de client het mist. + + + gedocumenteerd + + + bron + + + we hebben er geen gevonden + + + ja + + + nee + + + onbekend + + + Draait op: {platforms} + + + Spellen waarvan we vastgesteld hebben dat ze op deze codebase draaien + + + Nog geen. Dat is een uitspraak over wat we gemeten hebben, niet over wat er bestaat — een spel dat we niet bereikt hebben, of waarvan we de codebase niet konden lezen, telt hier niet mee. + + + {listed, plural, one {# vermeld} other {# vermeld}}, {archived, plural, one {# gearchiveerd} other {# gearchiveerd}} + + + Gemeten in hun handshakes: {protocols} + + + In geen enkele handshake die we van hen gelezen hebben, is er iets aangeboden. + + + {offering} van {listed, plural, one {# vermeld spel} other {# vermelde spellen}} hebben we het zien aanbieden ({percent}) + + + Naar codebase, van de spellen die we hebben vastgesteld + + + {offering} van {identified} boden het aan + + + {count, plural, one {# van de {total} rijen is onbekend} other {# van de {total} rijen zijn onbekend}}: we hebben in de eigen documentatie van het project niets gevonden dat het een of het ander zegt. Een korte eerlijke tabel is beter dan een lange geraden. + + + Je spellen + + + Accounts vereisen een database, en deze site draait op de demofixture. + + + Inloggen + + + Ingelogd als {name}. + + + Uitloggen + + + Je hebt nog niets geclaimd. Zoek je spel op in {listing} en druk op zijn pagina op {claimControl}. + + + de lijst + + + dit spel claimen + + + Afstand gedaan. + + + Het blijft vastgelegd, en je kunt altijd opnieuw aantonen dat je de controle hebt door een nieuw token te publiceren. + + + Opgeslagen. + + + Dat spel + + + De pagina van {game} toont het nu als opgegeven door de eigenaar. + + + We publiceren het verbindingsscherm van {game} niet meer opnieuw. De pagina zegt dat met zoveel woorden in plaats van een gat te laten. + + + Het verbindingsscherm van {game} staat weer op zijn pagina. + + + We kloppen niet meer aan bij {game}, op geen enkel adres dat we ervan hebben. Zijn pagina houdt alles wat vóór je verzoek gemeten is. + + + We kloppen weer aan bij {game}, vanaf zijn volgende beurt in het schema. + + + {game} staat niet meer in de lijst, de ranglijsten en het dagcijfer. Zijn pagina en elk adres dat het ooit gehad heeft blijven antwoorden. + + + {game} staat weer in de lijst. Eén peiling die antwoord krijgt is genoeg om het weer te meten. + + + {field} is niet gewijzigd. + + + Dit zijn antwoorden van één regel; {max} tekens is het meeste dat we opslaan. + + + Dat veld is gemeten. Met een claim kun je toevoegen waar MSSP geen ruimte voor heeft; hij laat niemand wijzigen wat wij waargenomen hebben, ons ook niet. + + + Geclaimd + + + geverifieerd {date} + + + geverifieerd {date}, token laatst gezien {seen} + + + je MSSP controleren + + + {count, plural, one {Ook eigendom van {names} — die een eigen token geverifieerd heeft.} other {Ook eigendom van {names} — die elk een eigen token geverifieerd hebben.}} + + + een ander account + + + je spelerstelling op je eigen site zetten + + + De badge draagt de telling én wanneer we die gemeten hebben, want een getal zonder ouderdom is precies wat deze site wil vervangen. + + + Hij zegt {unknown} in plaats van nul wanneer we niet konden tellen, en {archived} als het spel stopt met antwoorden. + + + Er is ook {json}, als je hem liever zelf tekent. + + + geschiedenis + + + token uitgegeven + + + token opnieuw uitgegeven + + + geverifieerd — we hebben je token gelezen + + + token nog steeds gepubliceerd + + + token deze keer niet gelezen + + + afstand gedaan van de claim + + + token ongebruikt verlopen + + + een ander account heeft de controle aangetoond en het spel overgenomen + + + controle aangevraagd + + + afstand doen van deze claim + + + Typ {word} om te bevestigen. Er wordt niets verwijderd en je kunt opnieuw aantonen dat je de controle hebt door een nieuw token te publiceren; het spel blijft geclaimd als iemand anders het ook in eigendom heeft. + + + Afstand doen van {game} + + + Wachten op een token + + + token uitgegeven {issued}, geldig tot {expires} + + + Passkeys + + + naamloos + + + toegevoegd {date} + + + toegevoegd {date} · slechts op één apparaat + + + Deze passkey staat op één apparaat. Raak je hem kwijt, dan kom je er nog steeds in door een nieuw token op je spel te publiceren, maar met een tweede passkey gaat het sneller. + + + Nog een passkey toevoegen + + + Zo’n spel is er niet + + + {game} claimen + + + Claimen vereist een database, en deze site draait op de demofixture. + + + Je hebt eerst een account nodig. Daar zijn een passkey en een naam voor nodig. + + + Inloggen of een account aanmaken + + + Je spellen + + + {count, plural, one {Dit spel heeft al een eigenaar die de controle over de server heeft aangetoond.} other {Dit spel heeft al # eigenaren die de controle over de server hebben aangetoond.}} Jij kunt dat ook aantonen — de toets is in beide gevallen dezelfde — maar we moeten weten wat je ermee bedoelt, want aan het token kunnen we dat niet zien. + + + Ik draai het ook — voeg me toe als eigenaar + + + Iedereen houdt zijn claim. Dit zijn twee mensen die één spel draaien. + + + Ik heb het overgenomen — draag het aan mij over + + + {count, plural, one {Zodra je token geverifieerd is, wordt de bestaande claim ingetrokken en is het spel van jou.} other {Zodra je token geverifieerd is, worden de bestaande claims ingetrokken en is het spel van jou.}} Zij zien in hun eigen geschiedenis waarom. Er wordt niets verwijderd, en zij kunnen op dezelfde manier als jij nu opnieuw de controle aantonen. + + + Geverifieerd. + + + We hebben je token op {date} uit het MSSP-rapport van het spel gelezen. + + + We hebben je token op {date} van het verbindingsscherm gelezen. + + + Laat het token staan waar het staat. Het dient ook als identiteitssignaal, zodat dit spel herkenbaar blijft als het van host of van naam verandert. Het weghalen maakt je claim niet ongedaan. + + + Publiceer dit token overal waar het spel het aan een anonieme verbinding laat zien. De volgende peiling pikt het op, en daarmee is aangetoond dat je naar die server kunt schrijven. + + + Dit is een overdracht. + + + {count, plural, one {Zodra we dit token lezen, wordt de claim van de huidige eigenaar op dit spel ingetrokken en wordt het van jou.} other {Zodra we dit token lezen, worden de claims van de huidige eigenaren op dit spel ingetrokken en wordt het van jou.}} + + + Een van deze twee volstaat + + + Een MSSP-variabele + + + In {codebase} is dat een regel in {file}; elke codebase met MSSP heeft een equivalent. + + + {aliases} worden ook geaccepteerd. + + + Een regel op het verbindingsscherm + + + Overal op het scherm, en kleurcodes eromheen zijn geen probleem. + + + Daarna + + + We kijken op het gewone crawlschema. Dit token is geldig tot {date}. Kom gerust later terug; er hoeft niets opgeschreven te worden. + + + Eerder kijken + + + Zet je spel vooraan in de rij. We kloppen aan op ons eigen schema, dus dit is eerder en niet nu. + + + Net gevraagd. Probeer het over een paar minuten opnieuw — het is gerantsoeneerd omdat er eerder bij een echte server wordt aangeklopt dan wij anders gedaan hadden. + + + Wat alleen jij ons over {game} kunt vertellen + + + Dit zijn de dingen waar MSSP geen veld voor heeft. Ze verschijnen op de pagina van je spel als {declared}, met de datum waarop je ze voor het laatst bevestigd hebt, naast wat wij gemeten hebben — nooit in plaats daarvan. Niets wat gemeten is kan hiervandaan gewijzigd worden, niet door jou en niet door ons. + + + opgegeven {age}. Maak dit vak leeg om het in te trekken — wat erin stond blijft hoe dan ook vastgelegd. + + + Opslaan wat je opgegeven hebt + + + Wat {game} meldt, en wat jij liever getoond ziet + + + Je MSSP is wat elke crawler leest, en we blijven het tonen naast alles wat je hier neerzet — een antwoord van jou verbergt er geen van je spel. + + + Niets wat gemeten is kan hiervandaan gewijzigd worden: geen spelerstelling, geen mogelijkheid, geen uur bereikbaarheid. + + + Klopt een regel hieronder niet in je {file}, dan lost het daar oplossen het overal op. + + + je spel meldt {value}, bevestigd {age} + + + je spel meldt hier niets + + + De naam wijzigen verandert waaronder {game} vermeld staat en het adres van zijn pagina. Het oude adres blijft voor altijd werken — elk adres dat dit spel ooit gehad heeft verwijst door naar het huidige — en het vak leegmaken geeft de naam terug aan wat je MSSP zegt. + + + Je verbindingsscherm + + + We publiceren het niet opnieuw. De pagina zegt dat met zoveel woorden in plaats van een gat te laten, en de crawler blijft het lezen — zo herkennen we je spel als het verhuist. + + + Weer tonen + + + We tonen het omdat je server het naar elke anonieme verbinding stuurt. Wil je dat liever niet, zeg het dan en we stoppen ermee. We vragen niet waarom. + + + Toon ons verbindingsscherm niet meer + + + Gecrawld worden + + + We zijn gestopt. Er wordt bij {game} nergens meer aangeklopt, en de pagina houdt alles wat vóór je verzoek gemeten is — de lege uren noemen geen oorzaak, want {ourFact} is een feit over ons en geen meting van je spel. + + + dat je ons gevraagd hebt te stoppen + + + Crawl ons weer + + + Deze kwam van je eigen server en niet van hier — {routes}. Wil je weer gecrawld worden, stop dan met het publiceren ervan; dat horen we in de volgende cyclus. + + + je MSSP-rapport publiceert {variable} + + + een TXT-record op {label} vraagt ons te stoppen + + + een verzoek dat we vastgelegd hebben + + + We zijn gestopt op {stopped} en kloppen nog aan bij {dialling}. Dat is meestal een poort die na de afmelding is toegevoegd. + + + Stop ook op elk adres + + + We kloppen volgens een schema aan bij {game} en lezen wat elke anonieme verbinding te zien krijgt. Wil je dat liever niet, zeg het dan en we stoppen — binnen één cyclus, op elk adres dat we van je hebben, en we vragen niet waarom. + + + Wat al gemeten is wordt niet verwijderd: je pagina houdt zijn geschiedenis en zijn adres, en één peiling nadat je dit intrekt begint het weer. Je kunt het ook zonder ons zeggen, in je eigen configuratie — {mssp} in MSSP, of een TXT-record op {dns} — en die honoreren we, of iemand het spel hier nu ooit geclaimd heeft of niet. + + + Crawl ons niet meer + + + In de lijst staan + + + {game} staat niet meer in de lijst, niet in de ranglijsten en niet in het dagcijfer. Zijn pagina en elk adres dat het ooit gehad heeft blijven antwoorden, en alles wat vóór je verzoek gemeten is staat er nog op. Er is niets verwijderd; het is alleen geen plek meer waar een lezer al bladerend aankomt. + + + Zet ons terug in de lijst + + + Eén peiling die antwoord krijgt doet dit ook. Zolang je afmelding staat kloppen we niet aan, dus gebeurt dat niet — maar de dag dat je haar intrekt, komt het adres binnen een week aan de beurt en zet de peiling die antwoord krijgt je terug. Je hoeft het ons geen tweede keer te vragen. + + + We kloppen niet meer bij je aan, en je pagina staat nog in de lijst met wat we daarvóór gemeten hebben. Wil je dat liever niet, zeg het dan en hij gaat eruit — uit de lijst, de ranglijsten en het dagcijfer. + + + Er wordt niets verwijderd en er gaat niets stuk: de pagina antwoordt, elk adres dat hij ooit gehad heeft verwijst er nog naar door, en iedereen die je erheen stuurt ziet hem. Hij houdt op een plek te zijn waar een lezer al bladerend aankomt. Vanaf hier terug te draaien, en door elke peiling die antwoord krijgt nadat je je afmelding intrekt. + + + Haal ons ook uit de lijst + + + wat we konden meten + + + Deze verbergen haalt ze uit deze lijst; het betekent niet dat het spel leeg is. + + + konden niet tellen + + + konden niet bereiken + + + niet getoond in deze lijst + + + niet getoond in deze lijst + + + In de linkerkolom is * een waarde waarop deze lijst gefilterd is en - een waarde die is uitgesloten. Beide zijn keuzes in de zoekopdracht, geen feiten over een spel. + + diff --git a/src/MUI.Web/Resources/Messages.resx b/src/MUI.Web/Resources/Messages.resx new file mode 100644 index 0000000..0e39845 --- /dev/null +++ b/src/MUI.Web/Resources/Messages.resx @@ -0,0 +1,3823 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + {count, plural, one {# game} other {# games}} + counts, which is where the concatenations were + + + {value}, {count, plural, one {# game} other {# games}}, only + counts, which is where the concatenations were + + + {value}, {count, plural, one {# game} other {# games}}, excluded + counts, which is where the concatenations were + + + {value}, {count, plural, one {# game} other {# games}} + counts, which is where the concatenations were + + + any {facet}, {count, plural, one {# game} other {# games}} + counts, which is where the concatenations were + + + {count, plural, =0 {No games listed here.} one {# game, each fact carrying how it was obtained.} other {# games, each fact carrying how it was obtained.}} + counts, which is where the concatenations were + + + {days, plural, one {# day} other {# days}} measured · {probes, plural, one {# probe} other {# probes}} + counts, which is where the concatenations were + + + {days, plural, one {#d} other {#d}} · {count, plural, one {# count} other {# counts}} + counts, which is where the concatenations were + + + {disagreeing, plural, =0 {None of the {total} disagree.} one {# of {total} disagrees with what the game declares.} other {# of {total} disagree with what the game declares.}} + counts, which is where the concatenations were + + + {count, plural, =0 {No games match every answer.} one {# game matches every answer.} other {# games match every answer.}} + find a game, where the count is the whole point of the page + + + of {listed, plural, one {# listed game} other {# listed games}} · {answers, plural, =0 {no answers given} one {# answer given} other {# answers given}} + find a game, where the count is the whole point of the page + + + {count, plural, one {Show the one game} other {Show these # games}} + find a game, where the count is the whole point of the page + + + drop "{answer}" — {count, plural, one {# game} other {# games}} + find a game, where the count is the whole point of the page + + + clear answer to: {question} + find a game, where the count is the whole point of the page + + + Find a game + find a game, where the count is the whole point of the page + + + matching all answers + find a game, where the count is the whole point of the page + + + {count, plural, one {game} other {games}} + find a game, where the count is the whole point of the page + + + clear all answers + find a game, where the count is the whole point of the page + + + start again + find a game, where the count is the whole point of the page + + + {count, plural, one {# more} other {# more}} + find a game, where the count is the whole point of the page + + + answers given + find a game, where the count is the whole point of the page + + + the whole listing + find a game, where the count is the whole point of the page + + + that query was refused + find a game, where the count is the whole point of the page + + + a name, if you have one + find a game, where the count is the whole point of the page + + + name, or part of one + find a game, where the count is the whole point of the page + + + Search by name + find a game, where the count is the whole point of the page + + + Is anyone playing right now? + find a game, where the count is the whole point of the page + + + What do you want to play? + find a game, where the count is the whole point of the page + + + What kind of game? + find a game, where the count is the whole point of the page + + + In which language? + find a game, where the count is the whole point of the page + + + Anything your client needs? + find a game, where the count is the whole point of the page + + + Include games that have gone dark? + find a game, where the count is the whole point of the page + + + doesn't matter + find a game, where the count is the whole point of the page + + + any genre + find a game, where the count is the whole point of the page + + + any kind + find a game, where the count is the whole point of the page + + + any language + find a game, where the count is the whole point of the page + + + doesn't matter + find a game, where the count is the whole point of the page + + + no, only live games + find a game, where the count is the whole point of the page + + + yes, show me those too + find a game, where the count is the whole point of the page + + + games that have gone dark + find a game, where the count is the whole point of the page + + + TLS — encrypted, handshake completed by us + the client question's options + + + MSSP — server self-description + the client question's options + + + MCCP — compressed output + the client question's options + + + MXP — clickable links + the client question's options + + + GMCP — structured client data + the client question's options + + + MSDP — structured client data + the client question's options + + + CHARSET — encoding negotiation + the client question's options + + + UTF-8 — non-Latin text renders + the client question's options + + + TTYPE — client tells its type + the client question's options + + + ATCP — structured client data + the client question's options + + + MSP — sound triggers + the client question's options + + + EOR — prompt marking + the client question's options + + + {token} — measured in the handshake + the client question's options + + + measured + the locked provenance words, one id per context + + + measured + the locked provenance words, one id per context + + + measured + the locked provenance words, one id per context + + + measured + the locked provenance words, one id per context + + + measured + the locked provenance words, one id per context + + + declared + the locked provenance words, one id per context + + + declared + the locked provenance words, one id per context + + + declared + the locked provenance words, one id per context + + + declared + the locked provenance words, one id per context + + + derived + the locked provenance words, one id per context + + + derived + the locked provenance words, one id per context + + + not measured + the four kinds of absence + + + uncounted + the four kinds of absence + + + unreachable + the four kinds of absence + + + not counted + the four kinds of absence + + + no count + the listing's own absences, which are not the game page's + + + from here + the listing's own absences, which are not the game page's + + + archived + the listing's own absences, which are not the game page's + + + claimed + the listing's own absences, which are not the game page's + + + Nothing to pick from + the listing's own absences, which are not the game page's + + + No game matches that filter. Try {listing}, or {archive}. + the listing's own absences, which are not the game page's + + + the whole listing + the listing's own absences, which are not the game page's + + + include the archive + the listing's own absences, which are not the game page's + + + connected + the words the product rests on + + + unclaimed + the words the product rests on + + + claimed by its owner + the words the product rests on + + + still probed + the words the product rests on + + + typical + the words the product rests on + + + peak + the words the product rests on + + + read as text + the accessibility promises + + + plain text + the accessibility promises + + + skip to content + the accessibility promises + + + ASCII banner: the connect screen of {game}. + the accessibility promises + + + Catalogues + site chrome + + + This site and your account + site chrome + + + browse + site chrome + + + learn + site chrome + + + this site + site chrome + + + menu + site chrome + + + games + site chrome + + + find + site chrome + + + random + site chrome + + + archive + site chrome + + + reference + site chrome + + + ecosystem + site chrome + + + rankings + site chrome + + + about + site chrome + + + submit + site chrome + + + submit a game + site chrome + + + sign in + site chrome + + + your games + site chrome + + + theme + site chrome + + + auto + site chrome + + + light + site chrome + + + dark + site chrome + + + Demo data. + site chrome + + + No database is configured, so this is a fixture. Nothing here was measured. + site chrome + + + all games + site chrome + + + archive + site chrome + + + declared by the game + site chrome + + + what changed + site chrome + + + A directory of the MU* hobby + home + + + Every fact carries how it was obtained and how old it is: measured by our crawler, or declared by the game and marked as such. + home + + + Search games by name, theme, codebase or host + home + + + search by name, theme, codebase or host + home + + + search + home + + + games known + home + + + connected now + home + + + answering, uncounted + home + + + archived + home + + + newly discovered + home + + + went dark — still probed + home + + + came back + home + + + Nothing new. + home + + + Nothing went dark. + home + + + Nothing came back. We keep knocking. + home + + + live + home + + + Games + the listing + + + sorted by {order} + the listing + + + random + the listing + + + connected · reached + the listing + + + from here + the listing + + + Nothing matched. + the listing + + + Try fewer words, or drop a filter. + the listing + + + clear filters + the listing + + + about {codebase} + the listing + + + never + the listing + + + claimed by its owner + the listing + + + Unknown Codebase + the listing + + + we could not identify the codebase this game runs + the listing + + + and {count, plural, one {# more} other {# more}}: {names} + the listing + + + Order + the order switch + + + Window + the order switch + + + now + the order switch + + + typical + the order switch + + + peak + the order switch + + + name + the order switch + + + reached + the order switch + + + 7 days + the order switch + + + 30 days + the order switch + + + 90 days + the order switch + + + Search games + the filter panel + + + search games + the filter panel + + + filters + the filter panel + + + showing + the filter panel + + + clear all + the filter panel + + + — stop filtering by this + the filter panel + + + any + the filter panel + + + more filters ({count}) + the filter panel + + + {count, plural, one {# more} other {# more}} + the filter panel + + + also show + the filter panel + + + Off by default. Neither is a judgement about the game. + the filter panel + + + archived + the filter panel + + + adult + the filter panel + + + archived games, {shown, select, true {shown} other {hidden}} + the filter panel + + + games declaring adult content, {shown, select, true {shown} other {hidden}} + the filter panel + + + Counts are games we measured, never estimates. + the filter panel + + + what the badges and the blanks mean + the filter panel + + + A blank is a gap in our measurement, not a no. Each facet spells its own: not identified, not declared, nothing negotiated. + the filter panel + + + A measured zero is a count. An unknown count is not a zero and never sorts as one. + the filter panel + + + Open-ended facets list their {count} commonest values. The rest are reachable by search and by URL. + the filter panel + + + Unticked means not measured — not that the game lacks it. + the filter panel + + + activity + facet groups + + + last seen + facet groups + + + protocols offered + facet groups + + + encrypted + facet groups + + + encoding + facet groups + + + codebase + facet groups + + + version + facet groups + + + lineage + facet groups + + + family + facet groups + + + genre + facet groups + + + language + facet groups + + + connected now + facet values + + + active this week + facet values + + + quiet — no count above 0 + facet values + + + dark — not reached in a month + facet values + + + archived + facet values + + + in the last 24 hours + facet values + + + in the last 7 days + facet values + + + in the last 30 days + facet values + + + longer ago + facet values + + + never reached + facet values + + + nothing negotiated + facet values + + + not identified + facet values + + + not declared + facet values + + + connected over TLS + facet values + + + not {value} + facet values + + + something negotiated + facet values + + + identified at all + facet values + + + declared at all + facet values + + + we watched this happen + evidence, and what each word means + + + the game says so, and we did not check + evidence, and what each word means + + + we grouped what the game told us + evidence, and what each word means + + + name + sort orders + + + connected now + sort orders + + + last reached + sort orders + + + typically on · 7 days + sort orders + + + typically on · 30 days + sort orders + + + typically on · 90 days + sort orders + + + most on at once · 7 days + sort orders + + + most on at once · 30 days + sort orders + + + most on at once · 90 days + sort orders + + + on the row now + sort orders + + + typical + sort orders + + + peak + sort orders + + + Unknown count + sort orders + + + never once reached — not reached long ago + sort orders + + + fewer than {minimum} counts in the window, or none at all — not a typical count of zero + sort orders + + + nothing we could count in the window — not a game nobody was on + sort orders + + + median {value} · {days, plural, one {#d} other {#d}} · {count, plural, one {# count} other {# counts}} + sort orders + + + most {value} at once · {days, plural, one {#d} other {#d}} · {count, plural, one {# count} other {# counts}} + sort orders + + + Connect screen + the game page's own headings + + + Connections by hour + the game page's own headings + + + How many, over time + the game page's own headings + + + Reachable + the game page's own headings + + + What changed + the game page's own headings + + + Capabilities + the game page's own headings + + + Declared by the game + the game page's own headings + + + Referrals + the game page's own headings + + + Unclaimed — everything here was measured. + the game page's own headings + + + Claimed by its owner — measured facts below are still ours. + the game page's own headings + + + Claim this game + the game page's own headings + + + answering since {date} + the game page's own headings + + + read as text — {count, plural, one {# row} other {# rows}} + the game page's own headings + + + capability + the game page's own headings + + + age + the game page's own headings + + + offered + the game page's own headings + + + silent + the game page's own headings + + + absent + the game page's own headings + + + denied + the game page's own headings + + + claimed + the game page's own headings + + + disagrees + the game page's own headings + + + where they disagree ({count}) + the game page's own headings + + + {day} {time} — {count, plural, =0 {0 players, measured} one {# player on average} other {# players on average}} + the week of hours, said in words + + + {day} {time} — probed, no count could be read + the week of hours, said in words + + + {day} {time} — no measurement in this hour + the week of hours, said in words + + + We have not measured this game's activity yet. + the week of hours, said in words + + + No hour of the week has produced a player count. + the week of hours, said in words + + + Measured every hour and nobody has been on in any of them. + the week of hours, said in words + + + {count, plural, one {# hour on {day} has no measurement yet.} other {# hours on {day} have no measurement yet.}} + the week of hours, said in words + + + {count, plural, one {# hour across the week has no measurement yet.} other {# hours across the week have no measurement yet.}} + the week of hours, said in words + + + {count, plural, one {# hour on {day} answered but produced no count.} other {# hours on {day} answered but produced no count.}} + the week of hours, said in words + + + {count, plural, one {# hour across the week answered but produced no count.} other {# hours across the week answered but produced no count.}} + the week of hours, said in words + + + Busiest every day, {window}. + the week of hours, said in words + + + Busiest every day, {part}, {window}. + the week of hours, said in words + + + Busiest {days} {window}. + the week of hours, said in words + + + Busiest {days} {part}, {window}. + the week of hours, said in words + + + Reliably quiet {who}, {window}. + the week of hours, said in words + + + Reliably quiet {who} in the {part}, {window}. + the week of hours, said in words + + + every day + the week of hours, said in words + + + every day we could measure + the week of hours, said in words + + + on weekdays + the week of hours, said in words + + + on {days} + the week of hours, said in words + + + morning + the week of hours, said in words + + + afternoon + the week of hours, said in words + + + evening + the week of hours, said in words + + + small hours + the week of hours, said in words + + + mornings + the week of hours, said in words + + + afternoons + the week of hours, said in words + + + evenings + the week of hours, said in words + + + small hours + the week of hours, said in words + + + {list}, {next} + the week of hours, said in words + + + {first} and {second} + the week of hours, said in words + + + not enough measurements yet + the week of hours, said in words + + + No hour of the week has a measurement yet. + the week of hours, said in words + + + {count, plural, one {# hour answered and produced no count.} other {# hours answered and produced no count.}} + the week of hours, said in words + + + The grid appears once every day of the week has one. + the week of hours, said in words + + + {days, plural, one {Measured on # of the seven days so far; the grid appears once every day has an hour in it.} other {Measured on # of the seven days so far; the grid appears once every day has an hour in it.}} + the week of hours, said in words + + + {count, plural, one {# hour measured, all of it at nobody on.} other {# hours measured, all of them at nobody on.}} + the week of hours, said in words + + + {count, plural, one {# hour measured, the busiest {peak} on {day} at {time} UTC.} other {# hours measured, the busiest {peak} on {day} at {time} UTC.}} + the week of hours, said in words + + + {count, plural, one {# more hour answered and produced no count.} other {# more hours answered and produced no count.}} + the week of hours, said in words + + + {day} — {facts} + the week of hours, said in words + + + {first}, {second} + the week of hours, said in words + + + measured at zero all day + the week of hours, said in words + + + peak {count} at {time} + the week of hours, said in words + + + nobody on {window} + the week of hours, said in words + + + no count in any hour + the week of hours, said in words + + + {count, plural, one {# hour not measured} other {# hours not measured}} + the week of hours, said in words + + + {count, plural, one {# hour probed but uncountable} other {# hours probed but uncountable}} + the week of hours, said in words + + + day + the week of hours, said in words + + + quietest + the week of hours, said in words + + + busiest + the week of hours, said in words + + + at + the week of hours, said in words + + + no count + the week of hours, said in words + + + Players on by day, in UTC. {window}. + the week of hours, said in words + + + times in UTC · {window} + the week of hours, said in words + + + {weeks, plural, one {#-week rolling average} other {#-week rolling average}} + the week of hours, said in words + + + counted, including a measured zero + the week of hours, said in words + + + probed, no count could be read + the week of hours, said in words + + + no measurement in that hour + the week of hours, said in words + + + When people are on (UTC) + the week of hours, said in words + + + counted + the week of hours, said in words + + + we got in and read a number, including a measured zero + the week of hours, said in words + + + we got in and no number could be read + the week of hours, said in words + + + we have no measurement for that hour + the week of hours, said in words + + + language + the switcher's own chrome, which has to read in the locale being left + + + change language + the switcher's own chrome, which has to read in the locale being left + + + About mu*index + about: the page, and what a fact here is + + + Every game here was measured by a machine that connected to it, and every value says where it came from and when. This page covers what that proves, what we get wrong, whose directories we read, and how to make the crawler stop. + about: the page, and what a fact here is + + + What a fact here is + about: the page, and what a fact here is + + + Measured beats declared, and both are shown. + about: the page, and what a fact here is + + + A game's MSSP report is the game describing itself. The telnet handshake is what we watched it do. Both appear on its page, labelled with how and when. Where they disagree, we show the disagreement. + about: the page, and what a fact here is + + + A player count says where it came from. + about: the page, and what a fact here is + + + Either a WHO or DOING read at the connect screen, which we counted, or the game's own MSSP PLAYERS field, which it published. Never merged. + about: the page, and what a fact here is + + + An answer we cannot read is unknown, never zero. + about: the page, and what a fact here is + + + Servers customise their WHO headers freely, and past a point our parser cannot read one. That is uncountable, its own state. A measured zero — we got in, nobody was there — is a count, and prints as one. + about: the page, and what a fact here is + + + Reachable, never uptime. + about: the page, and what a fact here is + + + We open a socket from one host at intervals. A game we cannot route to is unreachable and perfectly alive. Nothing here claims a game's uptime, because nothing here measured it. + about: the page, and what a fact here is + + + An hour is counted, uncountable, or not measured. + about: the page, and what a fact here is + + + The activity grid has three states. The third is empty and names no cause: an hour we could not reach and an hour we never probed are the same absence, and neither is that server's downtime. + about: the page, and what a fact here is + + + What we know we get wrong + about: what we know we get wrong + + + Archive grace is measured from the day we found you. + about: what we know we get wrong + + + A game that stops answering leaves the default listing after its grace period: a quarter of the reachable time we probed, floored at 60 days and capped at 365. A game running since 1995 starts at the floor on the day we discover it. We import nothing to fill in the years before we arrived. + about: what we know we get wrong + + + We do not credit MSSP CREATED toward that grace. + about: what we know we get wrong + + + It is one hand-typed line in a config file, so crediting it would make the archive threshold gameable. It is shown as a declaration and buys nothing. + about: what we know we get wrong + + + Claiming a game earns the ceiling. + about: what we know we get wrong + + + Proving server access is worth the full year of grace, however long we have been watching. + about: what we know we get wrong + + + Everything here is one host, looking at intervals. + about: what we know we get wrong + + + A percentage of reachable time is a fraction of the window we observed, never of one we did not. No graphic here fills in the rest. + about: what we know we get wrong + + + Nothing is ever deleted. + about: what we know we get wrong + + + Archiving takes a game out of the default listing, the rankings and the active-today figure, and nothing else. Its page, URL, history and address survive, it keeps being probed, and one successful probe puts it back. + about: what we know we get wrong + + + What this site will not do + about: what this site will not do + + + No votes, stars, ratings or recommendations. + about: what this site will not do + + + Rankings are computed from measured data only. A directory ranked by who can mobilise the most clicks describes the campaigning, not the hobby, and that is what killed the incumbents. + about: what this site will not do + + + No forums, reviews, wikis, comments or player profiles. + about: what this site will not do + + + Orientation material — what a MUSH is, which codebase suits collaborative roleplay — is written, signed and versioned like the rest of the site. + about: what this site will not do + + + Player names are never persisted. + about: what this site will not do + + + A WHO reply is parsed in memory for a count and the shape of the header. The names are not written down; aggregates use a salted hash with a rotating salt. + about: what this site will not do + + + No absolute population figure is published. + about: what this site will not do + + + Per-codebase and per-protocol shares ship: a ratio over the measured set survives the games we cannot count. "How many people play MU*" does not, because that number would not survive being quoted. + about: what this site will not do + + + The crawler, and how to make it stop + about: the crawler, and how to make it stop + + + A probe is one connection that never logs in. + about: the crawler, and how to make it stop + + + It opens a socket, negotiates telnet options, reads the connect screen, asks for MSSP by negotiating option 70, sends {commands}, and disconnects. No character, no login, nothing changed on the far side. A timeout bounds the session so a wedged probe cannot sit on a connection slot. + about: the crawler, and how to make it stop + + + CRAWL DELAY wins. + about: the crawler, and how to make it stop + + + A game that states a preferred minimum gap in its MSSP report gets it, over our own schedule in both directions: 720 hours means monthly, not weekly. A dark game is still tried for ever at the longer interval, which is how it re-lists itself when it comes back. + about: the crawler, and how to make it stop + + + A referred address is verified, never trusted. + about: the crawler, and how to make it stop + + + MSSP lets a game name other games. Every name is resolved before anything is dialled, and refused unless every address it resolves to is globally routable. A mixed answer refuses the whole target. Our refusal is filed as ours and never appears in a game's record as downtime. + about: the crawler, and how to make it stop + + + Connect screens are shown because they are sent to everybody. + about: the crawler, and how to make it stop + + + A server paints its connect screen, unauthenticated, to every anonymous connection. We display it as evidence and label it. Ask and it comes down. + about: the crawler, and how to make it stop + + + Say stop, and we stop — three ways. + about: the crawler, and how to make it stop + + + Publish {variable} 1 in your MSSP report, and the probe that reads it is the last one. Or publish a TXT record at {label}.your.host reading "{value}", which needs no MSSP support and no account here. Or write to a person. All three are honoured within one crawl cycle, recorded with the date and what we read, and enforced on the submission form too. + about: the crawler, and how to make it stop + + + The MSSP field stops that listener; the record stops the host. + about: the crawler, and how to make it stop + + + MSSP is published by the port that answered, so it speaks for that port — MU* hosting routinely runs unrelated games on one domain, and one must not silence its neighbour. A TXT record covers every port unless it names one, as "{value}=4201". Anything there we cannot read as a port list means the whole host, so "{value}=all" works. + about: the crawler, and how to make it stop + + + The DNS route is the one you can undo without asking us. + about: the crawler, and how to make it stop + + + A TXT record is readable without connecting to a server that told us not to, so we re-read it before every dial. Delete it and we dial again within a week. An MSSP field cannot be re-read without doing the thing you asked us to stop, so MSSP opt-outs and written requests stand until you say otherwise. That TXT lookup is all an opted-out address gets: it touches your nameserver, never your game. + about: the crawler, and how to make it stop + + + Stopping is not deleting, and it is not downtime. + about: the crawler, and how to make it stop + + + A game that opts out keeps its page, its address and everything we measured before it asked. Only new data stops: the activity grid stops gaining hours and names no cause, because our decision to stop knocking is a fact about us. It is recorded on the crawl that did not happen, and in the register of who asked. + about: the crawler, and how to make it stop + + + If stopping is not enough, the listing can go too. + about: the crawler, and how to make it stop + + + Once we have stopped on every address your game answers on, your dashboard offers one more thing: take it out of the listing, the rankings and the daily figure. The page and every address it has ever had still answer, and nothing is deleted — it stops being somewhere a reader arrives by browsing. It needs a verified claim, because it is a decision about your game and we record who made it. And a probe undoes it: take your opt-out back, and the next dial that gets an answer puts you back in the listing without asking us twice. + about: the crawler, and how to make it stop + + + The crawler names itself {name} when a server asks what it is. + about: who is knocking + + + The crawler is configured to call itself {name} but cannot yet say so. Its telnet library gives a client no way to set the terminal type, so your logs see that library's default, and NEW-ENVIRON is answered from the crawler host's environment. Both are gaps in the library and ours to fix there. Until then, recognise a probe by its shape: one connection, no login, a short read-only command set, gone. + about: who is knocking + + + crawler + about: who is knocking + + + contact + about: who is knocking + + + Crawler: {name} + about: who is knocking + + + Contact: {url} + about: who is knocking + + + — placeholder; this deployment set no contact address + about: who is knocking + + + No contact address is configured, so the one above is a placeholder and answers nobody. + about: who is knocking + + + Where the list of games came from + about: where the list of games came from + + + We take addresses. Nothing else. + about: where the list of games came from + + + A backfill takes a host and a port. No player counts, no reachability history, no descriptions, no fields, and no note of which site an address came from. + about: where the list of games came from + + + Deliberately less than those sites can give. + about: where the list of games came from + + + Several hold years of dated player counts. Importing that would fill the heatmaps of the games somebody else was already watching, and rest this site's central claim on another party's prober. + about: where the list of games came from + + + A game's origin is not one fact. + about: where the list of games came from + + + Any game worth listing appears in several of these directories, so "imported from" would name whichever fetch ran first. That a game exists is public information; where we read it adds nothing and is the part of somebody else's work with the least claim to be ours. + about: where the list of games came from + + + Reading somebody's site is still reading somebody's site. + about: where the list of games came from + + + We ask for a bulk export or a documented endpoint before scraping, read robots.txt first, and rate-limit scrapes hard. A source that needs its maintainer's say-so is not fetched until a person can state they were asked. + about: where the list of games came from + + + read — addresses only + about: where the list of games came from + + + not read — awaiting permission + about: where the list of games came from + + + One page, one request. Published by a crawler that connects to each game and prints what it read. + about: where the list of games came from + + + The same crawler's MSDP listing. Nearly a subset of its MSSP sibling, read for the few addresses it reaches that the other does not. + about: where the list of games came from + + + Publishes its whole catalogue on one page, so reading it costs a single request. Our largest source of addresses, and of no measurements. + about: where the list of games came from + + + One index page and one page per world, so a scrape rather than an export. On 30 July 2026 we fetched 143 of their pages, fifteen seconds apart and honouring robots.txt, but before anyone had written to them. That should not have happened. The gate now takes a person willing to state the maintainer was asked. + about: where the list of games came from + + + Implemented, tested, never run. The strongest source here on every axis except permission, and nothing will be fetched until somebody has written to them. + about: where the list of games came from + + + Licence + about: licence + + + The code is MIT. + about: licence + + + The site, the crawler and the parsers are open source under the MIT licence. + about: licence + + + The licence for the data is an open question. + about: licence + + + A separate decision from the code's, and not yet taken. Treat the terms below as this deployment's current answer, not the project's settled position. A rival directory taking the whole catalogue is a success condition here, so whatever is settled will not stand in the way of one. + about: licence + + + code + about: licence + + + data, as this deployment serves it + about: licence + + + credit as + about: licence + + + Code: {licence} + about: licence + + + Data: {licence} + about: licence + + + Credit as: {credit} + about: licence + + + (what this deployment serves. The project's own answer is still open.) + about: licence + + + Submit a game + submit a game + + + Tell us where a game is. A host and a port is the whole form; everything else on this site is measured by our own crawler. + submit a game + + + Host + submit a game + + + Port + submit a game + + + mud.example.org, or paste mud.example.org:4201 and leave the port empty + submit a game + + + Submit + submit a game + + + Submitting needs a database, and this site is running on the demo fixture. There is no crawl registry to write into, so the form is absent rather than quietly doing nothing. + submit a game + + + Not here + submit a game + + + What happens to an address + submit a game + + + We resolve the address before dialling it, and refuse anything that resolves off the public internet. That is a decision about our own socket, never a fact about a game. + submit a game + + + If whoever runs that host has asked us not to crawl it, we will not take the address, whoever submits it. A stranger cannot put your game back on this site. + submit a game + + + If it answers, we read what the server says for itself and keep reading it on its own schedule, for ever. An address only has to be given once. + submit a game + + + Nothing appears on the site until somebody proves they run it. Claiming takes a passkey and one line published on the game itself. + submit a game + + + An address we already have collapses onto the existing entry. Sending it twice makes no second listing and brings no probe forward. + submit a game + + + that address + submit a game + + + In the registry. + submit a game + + + {address} will be dialled on the next crawl cycle, then on its own schedule for ever. It appears here once somebody proves they run it — come back to this form with the same address and it will hand you the link. + submit a game + + + We have it, unclaimed. + submit a game + + + {address} is one we already measure. It stays off the site until somebody proves they run it. If that is you, this is the way in. + submit a game + + + We already have that one. + submit a game + + + {address} is a game we already measure. Nothing was created and nothing was changed. + submit a game + + + We already have that address. + submit a game + + + {address} is already known to us. Nothing was created and nothing was changed. + submit a game + + + Already waiting. + submit a game + + + {address} is in the crawl registry and has not answered yet. Sending it again does not bring it forward: a target keeps its own schedule, so nobody can hurry us at somebody else's server. + submit a game + + + Not an address we can dial. + submit a game + + + A host needs a dot or a colon in it, and a port is a number between 1 and 65535. Fill in both boxes, or paste mud.example.org:4201 into the first. + submit a game + + + We cannot dial that. + submit a game + + + Three things produce this answer for {address}: the name may not resolve, it may resolve off the public internet, or whoever runs that host may have asked us to stay away. We deliberately do not say which, because answering that for a stranger maps a network from outside it. Nothing was recorded about the address; the decision was ours and it is filed as ours. + submit a game + + + Enough for now. + submit a game + + + This form is rate-limited by sender, and you have hit the bound. Come back in an hour. Nothing was lost — anything we took is already in the registry. + submit a game + + + claim this game + submit a game + + + Sign in + signing in, which is a passkey and nothing else + + + Sign in with a passkey to claim a game you run. There is no password to lose and none to steal. + signing in, which is a passkey and nothing else + + + Claiming needs a database, and this site is running on the demo fixture. There is nothing to sign in to. + signing in, which is a passkey and nothing else + + + Sign-in is a passkey. + signing in, which is a passkey and nothing else + + + Your device or password manager holds the private key; we hold only the public half. No password, no email. + signing in, which is a passkey and nothing else + + + Sign in with a passkey + signing in, which is a passkey and nothing else + + + The one page here that needs JavaScript. Passkeys cannot work without it. + signing in, which is a passkey and nothing else + + + No account yet? + signing in, which is a passkey and nothing else + + + You need one only to claim a game you run. Pick a name to be known by — a label beside your claim, not a real name. + signing in, which is a passkey and nothing else + + + Name + signing in, which is a passkey and nothing else + + + e.g. corvid-admin + signing in, which is a passkey and nothing else + + + Create an account with a passkey + signing in, which is a passkey and nothing else + + + What we store + signing in, which is a passkey and nothing else + + + The name you chose. + signing in, which is a passkey and nothing else + + + The public key of each passkey you register, and what your device called it. + signing in, which is a passkey and nothing else + + + Which games you have claimed, and when. + signing in, which is a passkey and nothing else + + + No email address, no password, no IP log tied to your account. Lose every passkey and you can publish a fresh claim token on your game and start again: the game is the proof, not the account. + signing in, which is a passkey and nothing else + + + {day} {month} {year} + dates, ages and provenance — the two shapes on nearly every page + + + {date} {time} UTC + dates, ages and provenance — the two shapes on nearly every page + + + now + the age ladder, in three registers + + + {count, plural, one {#m} other {#m}} + the age ladder, in three registers + + + {count, plural, one {#h} other {#h}} + the age ladder, in three registers + + + {count, plural, one {#d} other {#d}} + the age ladder, in three registers + + + {count, plural, one {#w} other {#w}} + the age ladder, in three registers + + + {count, plural, one {#mo} other {#mo}} + the age ladder, in three registers + + + {count, plural, one {#y} other {#y}} + the age ladder, in three registers + + + just now + the age ladder, in three registers + + + {count, plural, one {#m ago} other {#m ago}} + the age ladder, in three registers + + + {count, plural, one {#h ago} other {#h ago}} + the age ladder, in three registers + + + {count, plural, one {#d ago} other {#d ago}} + the age ladder, in three registers + + + {count, plural, one {#w ago} other {#w ago}} + the age ladder, in three registers + + + {count, plural, one {#mo ago} other {#mo ago}} + the age ladder, in three registers + + + {count, plural, one {#y ago} other {#y ago}} + the age ladder, in three registers + + + just now + the age ladder, in three registers + + + {count, plural, one {#m ago} other {#m ago}} + the age ladder, in three registers + + + {count, plural, one {#h ago} other {#h ago}} + the age ladder, in three registers + + + {count, plural, one {#d ago} other {#d ago}} + the age ladder, in three registers + + + {count, plural, one {#w ago} other {#w ago}} + the age ladder, in three registers + + + {count, plural, one {#mo ago} other {#mo ago}} + the age ladder, in three registers + + + {count, plural, one {#y ago} other {#y ago}} + the age ladder, in three registers + + + {age}, {stamp} + the age ladder, in three registers + + + , {stamp} + the age ladder, in three registers + + + {value} — {how} via {source}, last confirmed {date} + the provenance chip's tooltip + + + {value} — {how} via {source}, last confirmed {date} (past its expected refresh) + the provenance chip's tooltip + + + ({how}, {age}) + the provenance chip's tooltip + + + ({how}, {age}, stale) + the provenance chip's tooltip + + + owner-declared + the provenance chip's tooltip + + + staff + how a value reached us, one id per source + + + the telnet handshake + how a value reached us, one id per source + + + the owner + how a value reached us, one id per source + + + WHO + how a value reached us, one id per source + + + I3 + how a value reached us, one id per source + + + MSSP + how a value reached us, one id per source + + + INFO + how a value reached us, one id per source + + + the I3 mudlist + how a value reached us, one id per source + + + the connect screen + how a value reached us, one id per source + + + The ecosystem + the ecosystem dashboard: shares, never totals, and never a share without its set + + + Shares, never totals. We do not publish a figure for how many people play MU*: a ratio over the games we measured survives the ones we cannot reach, and a headcount does not. + the ecosystem dashboard: shares, never totals, and never a share without its set + + + {count, plural, one {{value} game listed} other {{value} games listed}} + the ecosystem dashboard: shares, never totals, and never a share without its set + + + {count, plural, one {{value} game whose handshake we completed} other {{value} games whose handshake we completed}} + the ecosystem dashboard: shares, never totals, and never a share without its set + + + {count, plural, one {{value} game whose MSSP report we hold} other {{value} games whose MSSP report we hold}} + the ecosystem dashboard: shares, never totals, and never a share without its set + + + Oldest handshake here: confirmed {age} ago. + the ecosystem dashboard: shares, never totals, and never a share without its set + + + {count, number} of {total, number} ({fraction, number, ::percent .0}) + the ecosystem dashboard: shares, never totals, and never a share without its set + + + {count, number} of {total, number} — nothing measured yet + the ecosystem dashboard: shares, never totals, and never a share without its set + + + {fraction, number, ::percent .0} + the ecosystem dashboard: shares, never totals, and never a share without its set + + + Codebases + the ecosystem dashboard: shares, never totals, and never a share without its set + + + Of the {listed, plural, one {# game} other {# games}} listed, {identified, number} told us what they run, and every share below is over those {identified, number}. A codebase we could not read is left out of the denominator, never counted as something else. + the ecosystem dashboard: shares, never totals, and never a share without its set + + + No listed game has told us its codebase yet. + the ecosystem dashboard: shares, never totals, and never a share without its set + + + {share} run a codebase no other listed game runs — one game each, which is a name rather than a share. They are inside the denominator above and folded out of the bars, not dropped: + the ecosystem dashboard: shares, never totals, and never a share without its set + + + Lineages + the ecosystem dashboard: shares, never totals, and never a share without its set + + + The same games, grouped by the tradition their server descends from — our reading of the codebase, not anything a game published. No game reports "MUSH": MSSP has no such value, and most of the MUSH world publishes no MSSP at all, so this is the only way the question can be asked. + the ecosystem dashboard: shares, never totals, and never a share without its set + + + No listed game runs a codebase we place in a lineage yet. + the ecosystem dashboard: shares, never totals, and never a share without its set + + + {count, plural, one {# of those games runs a codebase} other {# of those games run codebases}} we do not place in any lineage — several say as much themselves, publishing {family}. They are inside the denominator above and in nobody's share. + the ecosystem dashboard: shares, never totals, and never a share without its set + + + Protocols + the ecosystem dashboard: shares, never totals, and never a share without its set + + + Read every measured figure below as a floor. We ask for MSSP by name, so silence there is an answer. Nothing else here is requested, and a server may support a protocol without ever offering it. + the ecosystem dashboard: shares, never totals, and never a share without its set + + + {instrument} is the one row below that is not a floor: we ask every server for it by name, so the games that did not offer it were asked and declined. It is also the only one with no declared figure, because every game whose report we hold supports it by demonstration and a count of the ones that also listed it would measure a habit against that. + the ecosystem dashboard: shares, never totals, and never a share without its set + + + We hold {reports, number} reports and {offered, plural, one {# game} other {# games}} offer MSSP today: the other {gap, number} stopped publishing one after we read it, and a report is not thrown away because it stopped being reissued. + the ecosystem dashboard: shares, never totals, and never a share without its set + + + Protocol adoption. Measured is what a server offered in a completed handshake; declared is what its MSSP claims. Two sets of games, so two denominators. + the ecosystem dashboard: shares, never totals, and never a share without its set + + + protocol + the ecosystem dashboard: shares, never totals, and never a share without its set + + + measured — of {basis} + the ecosystem dashboard: shares, never totals, and never a share without its set + + + declared — of {basis} + the ecosystem dashboard: shares, never totals, and never a share without its set + + + not measured — never observed + the ecosystem dashboard: shares, never totals, and never a share without its set + + + {share} · {declined, plural, one {# game} other {# games}} declined when asked + the ecosystem dashboard: shares, never totals, and never a share without its set + + + {share} · {unobserved, plural, one {# game} other {# games}} neither offered nor asked + the ecosystem dashboard: shares, never totals, and never a share without its set + + + {share} · {declined, plural, one {# game} other {# games}} declined when asked · {unobserved, plural, one {# game} other {# games}} neither offered nor asked + the ecosystem dashboard: shares, never totals, and never a share without its set + + + not asked — every report here is the answer + the ecosystem dashboard: shares, never totals, and never a share without its set + + + Adoption over time + the ecosystem dashboard: shares, never totals, and never a share without its set + + + Each point is a share over the games we had measured that day, so this line moves for two reasons: a game changing what it offers, and the set of games we can measure changing around it. Only the first is adoption. The transition count below is the part that is purely games changing their minds. + the ecosystem dashboard: shares, never totals, and never a share without its set + + + Measured share of each protocol, oldest reading first + the ecosystem dashboard: shares, never totals, and never a share without its set + + + then + the ecosystem dashboard: shares, never totals, and never a share without its set + + + now + the ecosystem dashboard: shares, never totals, and never a share without its set + + + not measured + the ecosystem dashboard: shares, never totals, and never a share without its set + + + A snapshot, not a curve + the ecosystem dashboard: shares, never totals, and never a share without its set + + + A snapshot of what we can measure now. An adoption curve plots games changing their minds, and we record a change when it happens, so the curve becomes drawable once enough have been recorded. Plotting when we first reached each game would measure the crawl, not the hobby. + the ecosystem dashboard: shares, never totals, and never a share without its set + + + {count, plural, one {# capability change} other {# capability changes}} recorded so far — the material a curve is drawn from. + the ecosystem dashboard: shares, never totals, and never a share without its set + + + No measured capability has changed yet, so there is nothing to plot. + the ecosystem dashboard: shares, never totals, and never a share without its set + + + Measured is of {measured}; declared is of {declared}. Two sets of games, so two denominators. + the ecosystem dashboard: shares, never totals, and never a share without its set + + + {listed} · {handshakes} · {mssp}. + the ecosystem dashboard: shares, never totals, and never a share without its set + + + The oldest handshake in this picture was last confirmed {age} ago. + the ecosystem dashboard: shares, never totals, and never a share without its set + + + The same games, grouped by the tradition their server descends from. This is {evidence} — {meaning} — and not anything a game published: no game reports "MUSH", because MSSP has no such value and most of the MUSH world publishes no MSSP at all. + the ecosystem dashboard: shares, never totals, and never a share without its set + + + measured: {value} + the ecosystem dashboard: shares, never totals, and never a share without its set + + + declared: {value} + the ecosystem dashboard: shares, never totals, and never a share without its set + + + Rankings + the rankings: computed from measured data only, and it says so first + + + Computed from measured data only. No votes, stars or ratings, ever. Nothing here ranks quality. We have not measured it. + the rankings: computed from measured data only, and it says so first + + + Busiest, by measured concurrent players + the rankings: computed from measured data only, and it says so first + + + Ranking window + the rankings: computed from measured data only, and it says so first + + + {days, plural, one {# day} other {# days}} + the rankings: computed from measured data only, and it says so first + + + Median of the player counts we measured over the last {days, plural, one {# day} other {# days}}. + the rankings: computed from measured data only, and it says so first + + + No game yet has the {samples, number} samples across {days, plural, one {# day} other {# days}} that a median needs. + the rankings: computed from measured data only, and it says so first + + + {eligible, plural, one {# game} other {# games}} of {listed, number} have the {samples, number} samples across {days, plural, one {# day} other {# days}} it needs. + the rankings: computed from measured data only, and it says so first + + + A measured zero counts; an unreadable count does not. + the rankings: computed from measured data only, and it says so first + + + A week says who is busy now; a quarter says who has been busy. They are different questions and a game can lead one and not the other. Days are whole days, UTC. + the rankings: computed from measured data only, and it says so first + + + No listed game has enough counted samples to rank yet — a statement about how long we have been measuring, not about how busy anybody is. + the rankings: computed from measured data only, and it says so first + + + Games ranked by the median of the player counts we measured over the last {days, plural, one {# day} other {# days}}. Games on the same median share a place; nothing here breaks the tie. + the rankings: computed from measured data only, and it says so first + + + # + the rankings: computed from measured data only, and it says so first + + + game + the rankings: computed from measured data only, and it says so first + + + median + the rankings: computed from measured data only, and it says so first + + + peak + the rankings: computed from measured data only, and it says so first + + + counted samples + the rankings: computed from measured data only, and it says so first + + + days measured + the rankings: computed from measured data only, and it says so first + + + Longest unbroken reachable spell + the rankings: computed from measured data only, and it says so first + + + Every probe since the date given found the game reachable. Reachable, not up: we measure a socket from one host, and a game we cannot route to is perfectly alive. A spell cannot be longer than we have been watching, so the date is the fact and the duration follows. + the rankings: computed from measured data only, and it says so first + + + No listed game is in an unbroken reachable spell right now. + the rankings: computed from measured data only, and it says so first + + + Games whose every probe since the date given found them reachable. Games reachable since the same date share a place; nothing here breaks the tie. + the rankings: computed from measured data only, and it says so first + + + reachable since + the rankings: computed from measured data only, and it says so first + + + that is + the rankings: computed from measured data only, and it says so first + + + Archived games are out of both tables and nothing else; one successful probe puts them back. + the rankings: computed from measured data only, and it says so first + + + Busiest — median measured players, last {days, plural, one {# day} other {# days}} + the rankings: computed from measured data only, and it says so first + + + windows: + the rankings: computed from measured data only, and it says so first + + + this one + the rankings: computed from measured data only, and it says so first + + + median {median, number} · peak {peak, number} · {samples, plural, one {# counted sample} other {# counted samples}} over {days, number} of {window, number} days + the rankings: computed from measured data only, and it says so first + + + reachable on every probe since {date} · {duration} + the rankings: computed from measured data only, and it says so first + + + The archive + the archive: removed from the default listing, and from nothing else + + + Games that have stopped answering. Nothing was deleted. Still probed weekly, and one successful probe puts a game back in the listing the same day. + the archive: removed from the default listing, and from nothing else + + + search the archive + the archive: removed from the default listing, and from nothing else + + + Search archived games + the archive: removed from the default listing, and from nothing else + + + name, codebase or description + the archive: removed from the default listing, and from nothing else + + + show + the archive: removed from the default listing, and from nothing else + + + {count, plural, =0 {No archived games} one {# archived game} other {# archived games}} + the archive: removed from the default listing, and from nothing else + + + archived + the archive: removed from the default listing, and from nothing else + + + last reachable + the archive: removed from the default listing, and from nothing else + + + known live + the archive: removed from the default listing, and from nothing else + + + ({age} ago) + the archive: removed from the default listing, and from nothing else + + + Nothing matched. + the archive: removed from the default listing, and from nothing else + + + never, in anything we measured + the archive: removed from the default listing, and from nothing else + + + no reachable time measured + the archive: removed from the default listing, and from nothing else + + + unknown + the archive: removed from the default listing, and from nothing else + + + {years, number, ::.#} years + the archive: removed from the default listing, and from nothing else + + + {days, plural, one {# day} other {# days}} + the archive: removed from the default listing, and from nothing else + + + {from} – {to} · {span} + the archive: removed from the default listing, and from nothing else + + + {count, plural, one {# month} other {# months}} + the archive: removed from the default listing, and from nothing else + + + {count, plural, one {# year} other {# years}} + the archive: removed from the default listing, and from nothing else + + + {count, plural, one {# game} other {# games}} matching "{query}" + the archive: removed from the default listing, and from nothing else + + + {count, plural, one {# game} other {# games}} + the archive: removed from the default listing, and from nothing else + + + Last reachable: + the archive: removed from the default listing, and from nothing else + + + Known live: + the archive: removed from the default listing, and from nothing else + + + {value} of measured reachable time + the archive: removed from the default listing, and from nothing else + + + Run: + the archive: removed from the default listing, and from nothing else + + + Codebase: + the archive: removed from the default listing, and from nothing else + + + random game + /find's footer, which the rebuild left in English + + + {d, date, medium} — {typical} on average, {low}–{high} across {probes, plural, one {# probe} other {# probes}} + the trend chart's per-day title, one per column + + + {d, date, medium} — {count, plural, =0 {0 players} one {# player} other {# players}}, every one of {probes, plural, one {# probe} other {# probes}} + the trend chart's per-day title, one per column + + + {d, date, medium} — probed, no count could be read + the trend chart's per-day title, one per column + + + {d, date, medium} — no measurement + the trend chart's per-day title, one per column + + + Typically {typical} on, peaking at {peak}, over {counted} of {days, plural, one {# day} other {# days}}. + the sentence above the chart + + + Steady across the range. + the sentence above the chart + + + Up about {change, number, percent} from the start of the range to the end. + the sentence above the chart + + + Down about {change, number, percent} from the start of the range to the end. + the sentence above the chart + + + Probed in this range, and no player count could be read from any of it. + the sentence above the chart + + + No measurement in this range. + the sentence above the chart + + + Nothing counted in this range. + the sentence above the chart + + + {from, date, d MMM}–{to, date, d MMM} + the week lines, which are the text alternative and the plain surface both + + + {d, date, d MMM} + the week lines, which are the text alternative and the plain surface both + + + {span}: typically {typical}, peak {peak}, {days, plural, one {# day} other {# days}} counted + the week lines, which are the text alternative and the plain surface both + + + {span}: probed, no count could be read + the week lines, which are the text alternative and the plain surface both + + + {span}: not measured + the week lines, which are the text alternative and the plain surface both + + + {count, plural, one {# day probed without a count} other {# days probed without a count}} + the week lines, which are the text alternative and the plain surface both + + + {count, plural, one {# day not measured} other {# days not measured}} + the week lines, which are the text alternative and the plain surface both + + + {line}, {clause} + the week lines, which are the text alternative and the plain surface both + + + days in UTC · {from, date, medium} – {to, date, medium} + the chart's own chrome + + + {value} at the top + the chart's own chrome + + + {counted} of {days, plural, one {# day} other {# days}} counted + the chart's own chrome + + + {d, date, MMM} + the chart's own chrome + + + {d, date, MMM yyyy} + the chart's own chrome + + + mean of the counts we read that day + the chart's own chrome + + + up to the busiest count that day + the chart's own chrome + + + lowest to highest count that day + the chart's own chrome + + + probed, no count could be read + the chart's own chrome + + + not measured — no bar at all + the chart's own chrome + + + not measured — a break in the line + the chart's own chrome + + + Trend range + seeking and switching, which are links because the range is in the address + + + Trend shape + seeking and switching, which are links because the range is in the address + + + {days, plural, one {# day} other {# days}} + seeking and switching, which are links because the range is in the address + + + ← earlier + seeking and switching, which are links because the range is in the address + + + later → + seeking and switching, which are links because the range is in the address + + + line + seeking and switching, which are links because the range is in the address + + + bars + seeking and switching, which are links because the range is in the address + + + How many, over time + seeking and switching, which are links because the range is in the address + + + {from, date, medium} – {to, date, medium}, UTC + seeking and switching, which are links because the range is in the address + + + earlier + seeking and switching, which are links because the range is in the address + + + a week is summarised over the days in it we counted; a week with none says so + seeking and switching, which are links because the range is in the address + + + reachable · last {days, plural, one {# day} other {# days}} + the reachability strip + + + reachable + the reachability strip + + + longest outage + the reachability strip + + + last cause + the reachability strip + + + none in the window + the reachability strip + + + nothing recorded + the reachability strip + + + {days, plural, one {# day ago} other {# days ago}} + the reachability strip + + + today + the reachability strip + + + reachable + the reachability strip + + + degraded — answered, could not finish + the reachability strip + + + unreachable + the reachability strip + + + not measured + the reachability strip + + + reachable + the reachability strip + + + degraded + the reachability strip + + + unreachable + the reachability strip + + + not measured + the reachability strip + + + {d, date, d MMM} — reachable all day + the reachability strip + + + {d, date, d MMM} — degraded ({cause}): answered, could not finish + the reachability strip + + + {d, date, d MMM} — unreachable ({cause}) + the reachability strip + + + {d, date, d MMM} — not measured; we were not watching this game yet + the reachability strip + + + {days, plural, one {Not yet measured over the last # day.} other {Not yet measured over the last # days.}} + the reachability strip + + + Reachable {percent} of the last {days, plural, one {# day} other {# days}}. + the reachability strip + + + Reachable {percent} of the {days, plural, one {# day} other {# days}} we have measured. + the reachability strip + + + {days, plural, one {Reachability over the last # day is not yet measured.} other {Reachability over the last # days is not yet measured.}} + the reachability strip + + + No day in the window was unreachable. + the reachability strip + + + No day we measured was unreachable. + the reachability strip + + + {count, plural, one {# day unreachable.} other {# days unreachable.}} + the reachability strip + + + {count, plural, one {# day degraded — we got in and could not finish.} other {# days degraded — we got in and could not finish.}} + the reachability strip + + + Longest outage {duration}. + the reachability strip + + + Longest outage {duration} ({cause}). + the reachability strip + + + {count, plural, one {# day predates anything we measured.} other {# days predate anything we measured.}} + the reachability strip + + + {from, date, d MMM} – {to, date, d MMM} + the reachability strip + + + {d, date, d MMM} + the reachability strip + + + {range}: {count, plural, one {# day} other {# days}} {word} + the reachability strip + + + {range}: {count, plural, one {# day} other {# days}} {word} ({cause}) + the reachability strip + + + Reachable + the reachability strip + + + {days, plural, one {last # day} other {last # days}} + the reachability strip + + + Reachable: {percent} of the {days, plural, one {last # day} other {last # days}} + the reachability strip + + + Longest outage: {duration} + the reachability strip + + + dns did not resolve + why a dial did not complete, in a person's words + + + connection refused + why a dial did not complete, in a person's words + + + tls failed + why a dial did not complete, in a person's words + + + timed out + why a dial did not complete, in a person's words + + + handshake stalled + why a dial did not complete, in a person's words + + + no cause recorded + why a dial did not complete, in a person's words + + + The owner asked us not to republish this game's connect screen. + the ANSI capture's frame + + + No connect screen has been captured from this game. + the ANSI capture's frame + + + {count, plural, one {Only # row came back — too little to show.} other {Only # rows came back — too little to show.}} + the ANSI capture's frame + + + as sent by the server + the ANSI capture's frame + + + {columns}×{rows} + the ANSI capture's frame + + + {columns}×{rows}, double-width + the ANSI capture's frame + + + 16-colour SGR + the ANSI capture's frame + + + no colour + the ANSI capture's frame + + + read as {charset} + the ANSI capture's frame + + + captured + the ANSI capture's frame + + + frozen — the last screen we saw + the ANSI capture's frame + + + ASCII art: the connect screen of {game}. Its text is under "read as text", below. + the ANSI capture's frame + + + ASCII art: this game's connect screen. Its text is under "read as text", below. + the ANSI capture's frame + + + {count, plural, one {connect screen: # line, text only} other {connect screen: # lines, text only}} + the ANSI capture's frame + + + {count, plural, one {connect screen: # line, text only, read as {charset}} other {connect screen: # lines, text only, read as {charset}}} + the ANSI capture's frame + + + the game declares it, and the server has never offered it in a handshake. + the capability matrix's disagreements, said in prose + + + the server offers it, and the game's own record says it does not. + the capability matrix's disagreements, said in prose + + + Usually a stale hand-typed field, not a lie. Shown because a client should not rely on what the two disagree about. + the capability matrix's disagreements, said in prose + + + Not found + the rest of the game page + + + No game at this address. Check the spelling. + the rest of the game page + + + last answered + the rest of the game page + + + {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 + + + This game's own referral list names: + the rest of the game page + + + Named by the referral list of: + the rest of the game page + + + since {date} + the rest of the game page + + + no longer listed, last seen + the rest of the game page + + + Last answered {date}, {ago} ago. + the rest of the game page + + + Still probed weekly; this page updates the day it answers. + the rest of the game page + + + {span} known live + the rest of the game page + + + Not in the listing, the rankings or the daily figure: we do not think this address is a game somebody can play. Everything below is what it told us, unchanged. + the rest of the game page + + + Our reason: {why} + the rest of the game page + + + Not in the listing, the rankings or the daily figure, at the request of the people who run it. Everything below is preserved as it was, this page and every address it has ever had go on answering, and we are not dialling it. + the rest of the game page + + + Players now: {count} + the plain surface's own headings for this page + + + Capabilities ({disagreeing, plural, one {# of {total} disagrees} other {# of {total} disagree}}) + the plain surface's own headings for this page + + + Declared by the game + the plain surface's own headings for this page + + + Connect screen + the plain surface's own headings for this page + + + What changed + the plain surface's own headings for this page + + + measured + the plain surface's own headings for this page + + + declared + the plain surface's own headings for this page + + + ** disagree + the plain surface's own headings for this page + + + connected + appended block: the listing row's own labels and the last three chips + + + reached + appended block: the listing row's own labels and the last three chips + + + search + appended block: the listing row's own labels and the last three chips + + + archived + appended block: the listing row's own labels and the last three chips + + + adult + appended block: the listing row's own labels and the last three chips + + + included + appended block: the listing row's own labels and the last three chips + + + no count + added by the accessibility review, second pass + + + no current count, and nothing here says why + added by the accessibility review, second pass + + + Players now: no count (nothing here says why) + added by the accessibility review, second pass + + + crawler live · last probe {age} + the crawler strip, whose sentence was English around a localized age + + + crawler quiet · last probe {age} + the crawler strip, whose sentence was English around a localized age + + + no probe has finished here yet + the crawler strip, whose sentence was English around a localized age + + + nothing due this cycle + the crawler strip, whose sentence was English around a localized age + + + {considered, plural, one {# due} other {# due}} · {answered, plural, one {# answered} other {# answered}} · {failed, plural, one {# failed} other {# failed}} + the crawler strip, whose sentence was English around a localized age + + + {targets, plural, one {# address in the registry} other {# addresses in the registry}}, {due, plural, one {# due now} other {# due now}} + the crawler strip, whose sentence was English around a localized age + + + {page} — {site} + what this site says about itself where it is not this site + + + A directory of the MU* hobby — MUSHes, MUDs, MUCKs, MOOs — where every fact carries how it was obtained and how old it is. + what this site says about itself where it is not this site + + + Demo data — nothing here was measured. {description} + what this site says about itself where it is not this site + + + {site} — measured, not asserted + what this site says about itself where it is not this site + + + {title} on {site} + what this site says about itself where it is not this site + + + Games + what this site says about itself where it is not this site + + + The archive + what this site says about itself where it is not this site + + + Rankings + what this site says about itself where it is not this site + + + The ecosystem + what this site says about itself where it is not this site + + + Reference + what this site says about itself where it is not this site + + + About + what this site says about itself where it is not this site + + + Not found + what this site says about itself where it is not this site + + + Random game + what this site says about itself where it is not this site + + + Your games + what this site says about itself where it is not this site + + + Claim {game} + what this site says about itself where it is not this site + + + Every MU* we have reached, faceted on what we measured: codebase, the protocols a server offered in the handshake, TLS, charset, language, and when we last got in. + what this site says about itself where it is not this site + + + The games that went dark, kept. Each keeps its page, history and URL, is still probed weekly, and returns to the listing on one successful connection. + what this site says about itself where it is not this site + + + Busiest, most reachable, longest running — computed from measurements only. No votes, stars or ratings anywhere on this site. + what this site says about itself where it is not this site + + + Codebase share and protocol adoption across the games we measure, with what servers offer set beside what they declare. Shares, never totals. + what this site says about itself where it is not this site + + + Hand-written pages on the codebases, clients and protocols of the MU* hobby, cross-linked to counts taken from the crawl. + what this site says about itself where it is not this site + + + How this catalogue is built: what the crawler does, what it refuses to do, and how to make it stop. + what this site says about itself where it is not this site + + + No game at this address. Nothing here is ever deleted, so a game that once lived at this URL still does — check the spelling. + what this site says about itself where it is not this site + + + One game from the catalogue, chosen at random and never the same one twice. + what this site says about itself where it is not this site + + + The listings you have claimed, and what a claim lets you change. + what this site says about itself where it is not this site + + + Prove you run this game by publishing a token where only its operator could put it. + what this site says about itself where it is not this site + + + Archived — last reachable {age}, and still probed + what this site says about itself where it is not this site + + + Archived, and still probed + what this site says about itself where it is not this site + + + Player count unknown — the game answers, and publishes no number we can read + what this site says about itself where it is not this site + + + {count, plural, one {# player} other {# players}}, {how} {age} + what this site says about itself where it is not this site + + + Newly discovered + the plain mirror's feed headings and home counts + + + Went dark + the plain mirror's feed headings and home counts + + + Came back + the plain mirror's feed headings and home counts + + + {count, plural, one {# game known} other {# games known}} + the plain mirror's feed headings and home counts + + + {count, plural, one {# connected now (measured)} other {# connected now (measured)}} + the plain mirror's feed headings and home counts + + + {count, plural, one {# answering, uncounted} other {# answering, uncounted}} + the plain mirror's feed headings and home counts + + + {count, plural, one {# archived, still probed} other {# archived, still probed}} + the plain mirror's feed headings and home counts + + + Reference + THE REFERENCE SECTION'S CHROME + + + What the codebases are, what the clients do, and what the protocols mean. Written by hand and kept in the repository beside the crawler — not a wiki, and there is nothing on this page to edit. Every {number} here is a different thing: it comes from the catalogue and is recomputed each time you load the page. + THE REFERENCE SECTION'S CHROME + + + number + THE REFERENCE SECTION'S CHROME + + + Hand-written, single-author, and versioned in git. The prose here is ours; every number beside it was measured by the crawler and is recomputed on each request. This is not a wiki, and there is no way to edit it from this page. + THE REFERENCE SECTION'S CHROME + + + Start here + THE REFERENCE SECTION'S CHROME + + + Codebases + THE REFERENCE SECTION'S CHROME + + + Clients + THE REFERENCE SECTION'S CHROME + + + Protocols + THE REFERENCE SECTION'S CHROME + + + orientation + THE REFERENCE SECTION'S CHROME + + + codebase + THE REFERENCE SECTION'S CHROME + + + client + THE REFERENCE SECTION'S CHROME + + + protocol + THE REFERENCE SECTION'S CHROME + + + Not found + THE REFERENCE SECTION'S CHROME + + + No reference page here. This section is hand-written, so a gap is work nobody has done rather than something that was removed — {index}. + THE REFERENCE SECTION'S CHROME + + + see what there is + THE REFERENCE SECTION'S CHROME + + + Games running it + a codebase page's measured half + + + We have not identified any yet. That is a fact about what this crawler has measured, not about what exists — a game we have not reached, or whose codebase we could not read, is not counted here. + a codebase page's measured half + + + {count, plural, one {# listed} other {# listed}} + a codebase page's measured half + + + {count, plural, one {# archived} other {# archived}} + a codebase page's measured half + + + measured, never asserted + a codebase page's measured half + + + Counted from the catalogue on this request, over the same filter the link above carries — so this number and that listing are one query and cannot drift apart. + a codebase page's measured half + + + offered in their handshakes: {protocols} + a codebase page's measured half + + + Measured adoption + a protocol page's measured half + + + Nothing measured yet. + a protocol page's measured half + + + of {listed, plural, one {# listed game} other {# listed games}} — {percent} + a protocol page's measured half + + + The games not counted here are not games without the protocol. A game is counted when we observed its server offering the option in a handshake; the rest are servers that did not offer it to us and servers whose handshake we have not read, and we cannot tell you which. + a protocol page's measured half + + + Games observed offering {protocol} in a handshake, by the codebase we identified them as running. + a protocol page's measured half + + + codebase + a protocol page's measured half + + + offered it + a protocol page's measured half + + + identified + a protocol page's measured half + + + See also + a protocol page's measured half + + + Capabilities + the client capability matrix, which is documentation rather than measurement + + + {count, plural, one {# of {total} established from the project's own documentation} other {# of {total} established from the project's own documentation}} + the client capability matrix, which is documentation rather than measurement + + + Read off each project's own documentation, not measured by us — a client has no handshake for us to observe. "{unknown}" means we looked and did not establish it. It never means no. + the client capability matrix, which is documentation rather than measurement + + + Client capabilities, each read off the project's own documentation. Unknown means we did not establish it, and never that the client lacks it. + the client capability matrix, which is documentation rather than measurement + + + documented + the client capability matrix, which is documentation rather than measurement + + + source + the client capability matrix, which is documentation rather than measurement + + + we did not find one + the client capability matrix, which is documentation rather than measurement + + + yes + the client capability matrix, which is documentation rather than measurement + + + no + the client capability matrix, which is documentation rather than measurement + + + unknown + the client capability matrix, which is documentation rather than measurement + + + Runs on: {platforms} + the plain mirror's own wording + + + Games we have identified as running this codebase + the plain mirror's own wording + + + None yet. That is a statement about what we have measured, not about what exists — a game we have not reached, or whose codebase we could not read, is not counted here. + the plain mirror's own wording + + + {listed, plural, one {# listed} other {# listed}}, {archived, plural, one {# archived} other {# archived}} + the plain mirror's own wording + + + Measured in their handshakes: {protocols} + the plain mirror's own wording + + + Nothing was offered in any handshake we have read from them. + the plain mirror's own wording + + + {offering} of {listed, plural, one {# listed game} other {# listed games}} were observed offering it ({percent}) + the plain mirror's own wording + + + By codebase, of the games we identified + the plain mirror's own wording + + + {offering} of {identified} offered it + the plain mirror's own wording + + + {count, plural, one {# of {total} rows is unknown} other {# of {total} rows are unknown}}: we did not find the project's own documentation saying either way. A short honest table beats a long guessed one. + the plain mirror's own wording + + + Your games + the dashboard's frame + + + Accounts need a database behind them, and this site is running on the demo fixture. + the dashboard's frame + + + Sign in + the dashboard's frame + + + Signed in as {name}. + the dashboard's frame + + + Sign out + the dashboard's frame + + + You have not claimed anything yet. Find your game in {listing} and press {claimControl} on its page. + the dashboard's frame + + + the listing + the dashboard's frame + + + claim this game + the dashboard's frame + + + Given up. + the one banner a POST comes back with + + + The record of it is kept, and you can prove control again any time by publishing a fresh token. + the one banner a POST comes back with + + + Saved. + the one banner a POST comes back with + + + That game + the one banner a POST comes back with + + + {game}'s page now shows it as owner-declared. + the one banner a POST comes back with + + + We have stopped republishing {game}'s connect screen. The page says so plainly rather than leaving a hole. + the one banner a POST comes back with + + + {game}'s connect screen is on its page again. + the one banner a POST comes back with + + + We have stopped dialling {game}, on every address we have for it. Its page keeps everything measured before you asked. + the one banner a POST comes back with + + + We are dialling {game} again, from its next turn in the schedule. + the one banner a POST comes back with + + + {game} is out of the listing, the rankings and the daily figure. Its page and every URL it has ever had go on answering. + the one banner a POST comes back with + + + {game} is back in the listing. One probe that answers is all it needs to be measured again. + the one banner a POST comes back with + + + {field} was not changed. + the one banner a POST comes back with + + + These are one-line answers; {max} characters is the most we store. + the one banner a POST comes back with + + + That field is measured. A claim lets you add what MSSP has no room for; it never lets anybody edit what we observed, and that includes us. + the one banner a POST comes back with + + + Claimed + a claimed game's block + + + verified {date} + a claimed game's block + + + verified {date}, token last seen {seen} + a claimed game's block + + + check your MSSP + a claimed game's block + + + {count, plural, one {Also owned by {names} — who verified a token of their own.} other {Also owned by {names} — each having verified a token of their own.}} + a claimed game's block + + + another account + a claimed game's block + + + put your player count on your own site + a claimed game's block + + + The badge carries the count and when we measured it, because a number with no age is the thing this site exists to replace. + a claimed game's block + + + It says {unknown} rather than nought when we could not count, and {archived} if the game stops answering. + a claimed game's block + + + There is {json} too, if you would rather draw your own. + a claimed game's block + + + history + the audit log + + + token issued + the audit log + + + token issued again + the audit log + + + verified — we read your token + the audit log + + + token still published + the audit log + + + token not read this time + the audit log + + + claim given up + the audit log + + + token expired unused + the audit log + + + another account proved control and took the game over + the audit log + + + check requested + the audit log + + + give up this claim + the audit log + + + Type {word} to confirm. Nothing is deleted and you can prove control again by publishing a fresh token; the game stays claimed if anybody else owns it. + the audit log + + + Give up {game} + the audit log + + + Waiting on a token + waiting on a token, and the passkeys + + + token issued {issued}, good until {expires} + waiting on a token, and the passkeys + + + Passkeys + waiting on a token, and the passkeys + + + unnamed + waiting on a token, and the passkeys + + + added {date} + waiting on a token, and the passkeys + + + added {date} · on one device only + waiting on a token, and the passkeys + + + This passkey lives on one device. If you lose it you can still get back in by publishing a fresh token on your game, but a second passkey is quicker. + waiting on a token, and the passkeys + + + Add another passkey + waiting on a token, and the passkeys + + + No such game + claiming a game you run + + + Claim {game} + claiming a game you run + + + Claiming needs a database behind it, and this site is running on the demo fixture. + claiming a game you run + + + You need an account first. It takes a passkey and a name. + claiming a game you run + + + Sign in or create an account + claiming a game you run + + + Your games + claiming a game you run + + + {count, plural, one {This game already has an owner who proved control of the server.} other {This game already has # owners who proved control of the server.}} You can prove it too — the test is the same either way — but we need to know what you mean by it, because we cannot tell from the token. + claiming a game you run + + + I run it too — add me as an owner + claiming a game you run + + + Everyone keeps their claim. This is two people running one game. + claiming a game you run + + + I have taken it over — transfer it to me + claiming a game you run + + + {count, plural, one {When your token verifies, the existing claim is revoked and the game is yours.} other {When your token verifies, the existing claims are revoked and the game is yours.}} They will see why in their own history. Nothing is deleted, and they can prove control again the same way you are about to. + claiming a game you run + + + Verified. + claiming a game you run + + + We read your token from the game's MSSP report on {date}. + claiming a game you run + + + We read your token from the connect screen on {date}. + claiming a game you run + + + Leave the token where it is. It doubles as an identity signal, so this game stays recognisable if it moves host or changes name. Removing it will not un-claim you. + claiming a game you run + + + Publish this token anywhere the game shows it to an anonymous connection. The next probe picks it up, which proves you can write to that server. + claiming a game you run + + + This is a transfer. + claiming a game you run + + + {count, plural, one {When we read this token, the current owner's claim on this game is revoked and it becomes yours.} other {When we read this token, the current owners' claims on this game are revoked and it becomes yours.}} + claiming a game you run + + + Either of these will do + claiming a game you run + + + An MSSP variable + claiming a game you run + + + In {codebase} that is a line in {file}; every codebase with MSSP has an equivalent. + claiming a game you run + + + {aliases} are accepted too. + claiming a game you run + + + A line on the connect screen + claiming a game you run + + + Anywhere in the screen, and colour codes around it are fine. + claiming a game you run + + + Then + claiming a game you run + + + We check on the ordinary crawl schedule. This token is good until {date}. Come back any time; nothing needs writing down. + claiming a game you run + + + Look sooner + claiming a game you run + + + Brings your game to the front of the queue. We dial on our own schedule, so this is sooner rather than now. + claiming a game you run + + + Just asked. Try again in a few minutes — it is rationed because it dials a real server sooner than we would have. + claiming a game you run + + + What only you can tell us about {game} + what a claim actually grants (the owner panel, §8.5 and §11) + + + These are the things MSSP has no field for. They appear on your game's page as {declared}, with the date you last confirmed them, beside what we measured — never instead of it. Nothing measured can be edited from here, by you or by us. + what a claim actually grants (the owner panel, §8.5 and §11) + + + declared {age}. Empty this box to withdraw it — the record of what it said is kept either way. + what a claim actually grants (the owner panel, §8.5 and §11) + + + Save what you declared + what a claim actually grants (the owner panel, §8.5 and §11) + + + What {game} reports, and what you would rather we showed + what a claim actually grants (the owner panel, §8.5 and §11) + + + Your MSSP is what every crawler reads, and we go on showing it beside anything you put here — an answer of yours does not hide one of your game's. + what a claim actually grants (the owner panel, §8.5 and §11) + + + Nothing measured can be edited from here: not a player count, not a capability, not an hour of reachability. + what a claim actually grants (the owner panel, §8.5 and §11) + + + If a line below is wrong in your {file}, fixing it there fixes it everywhere. + what a claim actually grants (the owner panel, §8.5 and §11) + + + your game reports {value}, confirmed {age} + what a claim actually grants (the owner panel, §8.5 and §11) + + + your game reports nothing here + what a claim actually grants (the owner panel, §8.5 and §11) + + + Changing the name changes what {game} is listed as and the address of its page. The old address goes on working for ever — every URL this game has ever had redirects to its current one — and clearing the box hands the name back to whatever your MSSP says. + what a claim actually grants (the owner panel, §8.5 and §11) + + + Your connect screen + what a claim actually grants (the owner panel, §8.5 and §11) + + + We are not republishing it. The page says so plainly rather than leaving a hole, and the crawler goes on reading it — it is how we recognise your game if it moves. + what a claim actually grants (the owner panel, §8.5 and §11) + + + Show it again + what a claim actually grants (the owner panel, §8.5 and §11) + + + We show it because your server sends it to every anonymous connection. If you would rather we did not, say so and we stop. We will not ask why. + what a claim actually grants (the owner panel, §8.5 and §11) + + + Stop showing our connect screen + what a claim actually grants (the owner panel, §8.5 and §11) + + + Being crawled + what a claim actually grants (the owner panel, §8.5 and §11) + + + We have stopped. Nothing on {game} is dialled, and the page keeps everything measured before you asked — the empty hours name no cause, because {ourFact} is our fact and not a measurement of your game. + what a claim actually grants (the owner panel, §8.5 and §11) + + + you asked us to stop + what a claim actually grants (the owner panel, §8.5 and §11) + + + Start crawling us again + what a claim actually grants (the owner panel, §8.5 and §11) + + + This one came from your own server rather than from here — {routes}. To be crawled again, stop publishing it; we will hear that on the next cycle. + what a claim actually grants (the owner panel, §8.5 and §11) + + + your MSSP report publishes {variable} + what a claim actually grants (the owner panel, §8.5 and §11) + + + a {label} TXT record asks us to stop + what a claim actually grants (the owner panel, §8.5 and §11) + + + a request we recorded + what a claim actually grants (the owner panel, §8.5 and §11) + + + We have stopped on {stopped} and are still dialling {dialling}. That is usually a port added after the opt-out. + what a claim actually grants (the owner panel, §8.5 and §11) + + + Stop on every address too + what a claim actually grants (the owner panel, §8.5 and §11) + + + We dial {game} on a schedule and read what any anonymous connection is shown. If you would rather we did not, say so and we stop — within one cycle, on every address we have for you, and we will not ask why. + what a claim actually grants (the owner panel, §8.5 and §11) + + + Nothing already measured is deleted: your page keeps its history and its URL, and one probe after you take this back starts it again. You can also say it without us, in your own config — {mssp} in MSSP, or a {dns} TXT record — and we honour those whether or not anybody has ever claimed the game here. + what a claim actually grants (the owner panel, §8.5 and §11) + + + Stop crawling us + what a claim actually grants (the owner panel, §8.5 and §11) + + + Being listed + what a claim actually grants (the owner panel, §8.5 and §11) + + + {game} is out of the listing, out of the rankings and out of the daily figure. Its page and every URL it has ever had go on answering, and everything measured before you asked is still on it. Nothing was deleted; it is simply not somewhere a reader arrives by browsing. + what a claim actually grants (the owner panel, §8.5 and §11) + + + Put us back in the listing + what a claim actually grants (the owner panel, §8.5 and §11) + + + One probe that answers does this too. While your opt-out stands we do not dial, so nothing will — but the day you take it back, the address comes up within a week and the probe that gets an answer puts you back. You do not have to ask us twice. + what a claim actually grants (the owner panel, §8.5 and §11) + + + We have stopped dialling you, and your page is still in the listing with what we measured before that. If you would rather it were not, say so and it comes out — of the listing, the rankings and the daily figure. + what a claim actually grants (the owner panel, §8.5 and §11) + + + Nothing is deleted and nothing breaks: the page answers, every URL it has ever had still redirects to it, and anyone you send there sees it. It stops being somewhere a reader can arrive by browsing. Reversible from here, and by any probe that answers after you take your opt-out back. + what a claim actually grants (the owner panel, §8.5 and §11) + + + Take us out of the listing too + what a claim actually grants (the owner panel, §8.5 and §11) + + + what we could measure + what we could measure (the two measurement switches, spec §9) + + + Hiding these takes them out of your listing; it does not mean the game is empty. + what we could measure (the two measurement switches, spec §9) + + + could not count + what we could measure (the two measurement switches, spec §9) + + + could not reach + what we could measure (the two measurement switches, spec §9) + + + hidden from this listing + what we could measure (the two measurement switches, spec §9) + + + hidden from this listing + what we could measure (the two measurement switches, spec §9) + + + In the left column, * is a value this listing is filtered to and - is one it is filtered against. Both are choices in the query, not facts about a game. + what we could measure (the two measurement switches, spec §9) + + diff --git a/src/MUI.Web/Resources/Messages.zh-Hans.resx b/src/MUI.Web/Resources/Messages.zh-Hans.resx new file mode 100644 index 0000000..a303b42 --- /dev/null +++ b/src/MUI.Web/Resources/Messages.zh-Hans.resx @@ -0,0 +1,2877 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + {count, plural, other {#个游戏}} + + + {value},{count, plural, other {#个游戏}},仅此项 + + + {value},{count, plural, other {#个游戏}},已排除 + + + {value},{count, plural, other {#个游戏}} + + + 任意{facet},{count, plural, other {#个游戏}} + + + {count, plural, =0 {此处未列出任何游戏。} other {#个游戏,每项事实都标明它的来源。}} + + + {days, plural, other {#天}}实测 · {probes, plural, other {#次探测}} + + + {days, plural, other {#天}} · {count, plural, other {#次计数}} + + + {disagreeing, plural, =0 {全部{total}项均一致。} other {在{total}项中,有#项与游戏自述不符。}} + + + {count, plural, =0 {没有游戏符合全部回答。} other {有#个游戏符合全部回答。}} + + + 在{listed, plural, other {#个已列出的游戏}}中 · {answers, plural, =0 {未作回答} other {已回答#项}} + + + {count, plural, other {显示这#个游戏}} + + + 去掉“{answer}”——{count, plural, other {#个游戏}} + + + 清除该问题的回答:{question} + + + 查找游戏 + + + 符合全部回答 + + + {count, plural, other {游戏}} + + + 清除全部回答 + + + 重新开始 + + + {count, plural, other {另有#项}} + + + 已回答 + + + 全部列表 + + + 该查询已被拒绝 + + + 名称(若有) + + + 名称或部分名称 + + + 按名称搜索 + + + 现在有人在玩吗? + + + 你想玩什么? + + + 哪种游戏? + + + 使用哪种语言? + + + 你的客户端有什么需求吗? + + + 是否包含已失联的游戏? + + + 不限 + + + 任意题材 + + + 任意类型 + + + 任意语言 + + + 不限 + + + 否,仅未失联的游戏 + + + 是,也一并显示 + + + 已失联的游戏 + + + TLS——已加密,握手由我们完成 + + + MSSP——服务器自述 + + + MCCP——输出压缩 + + + MXP——可点击链接 + + + GMCP——结构化客户端数据 + + + MSDP——结构化客户端数据 + + + CHARSET——编码协商 + + + UTF-8——非拉丁文本可正常显示 + + + TTYPE——客户端自述类型 + + + ATCP——结构化客户端数据 + + + MSP——声音触发 + + + EOR——提示符标记 + + + {token}——握手中实测 + + + 实测 + + + 实测 + + + 实测 + + + 实测 + + + 实测 + + + 自述 + + + 自述 + + + 自述 + + + 自述 + + + 推算 + + + 推算 + + + 未实测 + + + 未能计数 + + + 无法连通 + + + 未计数 + + + 无计数 + + + 自本站 + + + 已归档 + + + 已认领 + + + 没有可挑的游戏 + + + 没有游戏符合该筛选。试试{listing},或者{archive}。 + + + 全部列表 + + + 把归档也算上 + + + 连接数 + + + 无人认领 + + + 已由所有者认领 + + + 仍在探测 + + + 典型值 + + + 峰值 + + + 以文本形式阅读 + + + 纯文本 + + + 跳至主要内容 + + + ASCII 横幅:{game} 的连接画面。 + + + 目录 + + + 本站与账户 + + + 浏览 + + + 了解 + + + 本站 + + + 菜单 + + + 游戏 + + + 查找 + + + 随机 + + + 归档 + + + 参考 + + + 生态 + + + 排行 + + + 关于 + + + 提交 + + + 提交游戏 + + + 登录 + + + 我的游戏 + + + 主题 + + + 自动 + + + 浅色 + + + 深色 + + + 演示数据。 + + + 未配置数据库,因此这是一份样例。此处没有任何内容经过实测。 + + + 全部游戏 + + + 归档 + + + 游戏自述 + + + 变更记录 + + + MU* 爱好者的游戏目录 + + + 每一项事实都标明它的来源与时间:由我们的爬虫实测,或由游戏自述并明确标出。 + + + 按名称、主题、代码库或主机搜索游戏 + + + 按名称、主题、代码库或主机搜索 + + + 搜索 + + + 已知游戏 + + + 当前连接数 + + + 有应答,未能计数 + + + 已归档 + + + 新发现 + + + 已失联——仍在探测 + + + 已恢复 + + + 没有新发现。 + + + 没有游戏失联。 + + + 没有游戏恢复。我们仍会继续叩门。 + + + 实时 + + + 游戏 + + + 按{order}排序 + + + 随机 + + + 连接数 · 连通 + + + 自本站 + + + 没有匹配结果。 + + + 试试减少关键词,或去掉一个筛选条件。 + + + 清除筛选 + + + 关于 {codebase} + + + 从未 + + + 已由所有者认领 + + + 未知代码库 + + + 我们无法识别该游戏运行的代码库 + + + 另有{count, plural, other {#项}}:{names} + + + 排序 + + + 时间窗 + + + 当前 + + + 典型值 + + + 峰值 + + + 名称 + + + 连通 + + + 7天 + + + 30天 + + + 90天 + + + 搜索游戏 + + + 搜索游戏 + + + 筛选 + + + 当前显示 + + + 全部清除 + + + ——取消此项筛选 + + + 任意 + + + 更多筛选({count}) + + + {count, plural, other {另有#项}} + + + 同时显示 + + + 默认关闭。两者都不是对游戏的评判。 + + + 已归档 + + + 成人 + + + 已归档游戏,{shown, select, true {显示} other {隐藏}} + + + 自述含成人内容的游戏,{shown, select, true {显示} other {隐藏}} + + + 计数均为我们实测到的游戏,绝非估算。 + + + 徽标与留空的含义 + + + 留空是我们测量中的缺口,而不是否定。每个分面各有说法:未识别、未自述、无协商结果。 + + + 实测到的零是一个计数。未知的计数不是零,排序时也绝不按零处理。 + + + 开放式分面只列出最常见的 {count} 个值。其余的可通过搜索和 URL 访问。 + + + 未勾选表示未实测,而不是游戏不具备该项。 + + + 活跃度 + + + 最近可见 + + + 提供的协议 + + + 加密 + + + 编码 + + + 代码库 + + + 版本 + + + 谱系 + + + 家族 + + + 题材 + + + 语言 + + + 当前有连接 + + + 本周活跃 + + + 冷清——没有高于 0 的计数 + + + 失联——一个月未连通 + + + 已归档 + + + 24 小时内 + + + 7 天内 + + + 30 天内 + + + 更早 + + + 从未连通 + + + 无协商结果 + + + 未识别 + + + 未自述 + + + 已通过 TLS 连接 + + + 非{value} + + + 有协商结果 + + + 已识别 + + + 有自述 + + + 我们亲眼观测到 + + + 游戏这样说,我们未加核实 + + + 我们对游戏所述内容做了归类 + + + 名称 + + + 当前连接数 + + + 最近连通 + + + 典型连接数 · 7天 + + + 典型连接数 · 30天 + + + 典型连接数 · 90天 + + + 同时最多连接 · 7天 + + + 同时最多连接 · 30天 + + + 同时最多连接 · 90天 + + + 当前行内 + + + 典型值 + + + 峰值 + + + 未知计数 + + + 从未连通过——并非只是很久未连通 + + + 窗口内计数少于 {minimum} 次,或完全没有——并非典型连接数为零 + + + 窗口内没有任何我们能计到的数——并非无人连接的游戏 + + + 中位 {value} · {days, plural, other {#天}} · {count, plural, other {#次计数}} + + + 同时最多 {value} · {days, plural, other {#天}} · {count, plural, other {#次计数}} + + + 连接画面 + + + 按小时的连接数 + + + 人数随时间变化 + + + 可连通 + + + 变更记录 + + + 能力 + + + 游戏自述 + + + 转介 + + + 无人认领——此处的一切均为实测。 + + + 已由所有者认领——下方的实测事实仍出自我们。 + + + 认领此游戏 + + + 自 {date} 起有应答 + + + 以文本形式阅读——{count, plural, other {#行}} + + + 能力 + + + 距今 + + + 已提供 + + + 静默 + + + 缺失 + + + 已拒绝 + + + 自述有 + + + 不一致 + + + 不一致之处({count}) + + + {day} {time}——{count, plural, =0 {0人连接,实测} other {平均#人连接}} + + + {day} {time}——已探测,未能读取计数 + + + {day} {time}——该小时未实测 + + + 我们尚未实测该游戏的活跃度。 + + + 一周中没有任何一个小时得出过连接人数。 + + + 每个小时都已实测,其中没有任何一个小时有人连接。 + + + {count, plural, other {{day}有#小时尚未实测。}} + + + {count, plural, other {一周中有#小时尚未实测。}} + + + {count, plural, other {{day}有#小时有应答,但未能计数。}} + + + {count, plural, other {一周中有#小时有应答,但未能计数。}} + + + 每天最热闹,{window}。 + + + 每天{part}最热闹,{window}。 + + + {days}最热闹,{window}。 + + + {days}{part}最热闹,{window}。 + + + {who}一贯冷清,{window}。 + + + {who}的{part}一贯冷清,{window}。 + + + 每天 + + + 我们实测到的每一天 + + + 工作日 + + + {days} + + + 上午 + + + 下午 + + + 晚上 + + + 凌晨 + + + 上午 + + + 下午 + + + 晚上 + + + 凌晨 + + + {list}、{next} + + + {first}和{second} + + + 实测次数尚不足 + + + 一周中尚无任何一个小时有过实测。 + + + {count, plural, other {#小时有应答,但未能计数。}} + + + 待一周七天每天都有实测后,网格才会显示。 + + + {days, plural, other {七天中已实测#天;待每天都有实测小时后,网格才会显示。}} + + + {count, plural, other {已实测#小时,其中始终无人连接。}} + + + {count, plural, other {已实测#小时,最热闹的是{day} {time} UTC,{peak}人连接。}} + + + {count, plural, other {另有#小时有应答,但未能计数。}} + + + {day}——{facts} + + + {first},{second} + + + 全天实测为零 + + + {time}峰值{count}人 + + + {window}无人连接 + + + 各小时均无计数 + + + {count, plural, other {#小时未实测}} + + + {count, plural, other {#小时已探测但未能计数}} + + + 星期 + + + 最冷清 + + + 最热闹 + + + 时间 + + + 无计数 + + + 按天显示的连接人数,时间为 UTC。{window}。 + + + 时间为 UTC · {window} + + + {weeks, plural, other {#周滚动平均}} + + + 已计数,含实测为零 + + + 已探测,未能读取计数 + + + 该小时未实测 + + + 有人连接的时段(UTC) + + + 已计数 + + + 我们连上了并读到了数字,包括实测为零 + + + 我们连上了,但读不到数字 + + + 我们没有该小时的实测数据 + + + 语言 + + + 切换语言 + + + 关于 mu*index + + + 这里的每一个游戏,都由一台连入其中的机器实测得出,每一项数值都标明它的来源与时间。本页说明这能证明什么、我们哪里会出错、我们读了谁的目录,以及如何让爬虫停下。 + + + 本站所说的事实是什么 + + + 实测优先于自述,两者都会呈现。 + + + 游戏的 MSSP 报告是它对自己的描述,telnet 握手则是我们亲眼看到它做了什么。两者都会出现在它的页面上,并标注来源与时间。两者不一致时,我们把这份不一致本身呈现出来。 + + + 每一项人数计数都标明它从何而来。 + + + 要么是我们在连接画面上读到并数出的 WHO 或 DOING,要么是游戏自己发布的 MSSP PLAYERS 字段。二者从不合并。 + + + 读不出来的回应是未知,绝不是零。 + + + 服务器可以随意定制自己的 WHO 表头,超过一定程度,我们的解析器就读不出来了。那是无法计数,自成一种状态。而实测到的零——我们连上了,里面没有人——是一个计数,也按计数显示。 + + + 只说可连通,从不说在线率。 + + + 我们从一台主机上按间隔打开一个套接字。路由不到的游戏是无法连通,而它完全可能好好活着。本站不宣称任何游戏的在线率,因为本站没有测过它。 + + + 一个小时要么已计数,要么无法计数,要么未实测。 + + + 活跃度网格有三种状态。第三种是空白,并且不指明原因:我们连不上的那个小时,和我们从未探测过的那个小时,是同一种缺失,两者都不是那台服务器的停机时间。 + + + 我们知道自己哪里会出错 + + + 归档宽限期从我们发现你的那天算起。 + + + 游戏停止应答后,会在宽限期结束时离开默认列表:宽限期是我们探测到的可连通时长的四分之一,下限 60 天,上限 365 天。一个从 1995 年就在运行的游戏,在我们发现它的那天也只从下限起算。我们不会导入任何数据,去填补我们到来之前的那些年。 + + + MSSP CREATED 不计入这段宽限期。 + + + 它只是配置文件里手打的一行,若予采信,归档门槛就会变得可以操纵。它以自述的身份显示,换不来任何东西。 + + + 认领游戏可以拿到上限。 + + + 只要证明拥有服务器访问权,无论我们观察了多久,都值整整一年的宽限期。 + + + 本站的一切,都出自一台主机的间隔观测。 + + + 可连通时间的百分比,是我们观测过的那段窗口内的比例,绝不是我们没观测过的窗口的比例。本站的任何图形都不会替其余部分补上数据。 + + + 任何内容都不会被删除。 + + + 归档只是把游戏移出默认列表、排行和今日活跃数字,仅此而已。它的页面、URL、历史和地址都保留,我们仍会继续探测,一次成功的探测就能让它回来。 + + + 本站不会做的事 + + + 没有投票、星级、评分或推荐。 + + + 排行仅由实测数据算出。一个按谁能动员最多点击来排名的目录,描述的是拉票活动,而不是这个爱好本身——前辈们正是这样倒下的。 + + + 没有论坛、评测、维基、评论或玩家资料页。 + + + 入门资料——什么是 MUSH、哪种代码库适合协作扮演——和本站其余内容一样,由人撰写、署名,并纳入版本管理。 + + + 玩家名称从不留存。 + + + WHO 的回复只在内存中解析,取出一个计数和表头的形状。名称不会被记录下来;汇总使用加盐哈希,盐值会轮换。 + + + 不发布任何绝对人口数字。 + + + 我们发布按代码库和按协议的占比:在实测集合上算出的比例,即使还有我们数不出来的游戏也依然成立。“有多少人在玩 MU*”则不然,因为那个数字一经引用就站不住脚。 + + + 爬虫,以及如何让它停下 + + + 一次探测就是一个从不登录的连接。 + + + 它打开一个套接字,协商 telnet 选项,读取连接画面,通过协商选项 70 索取 MSSP,发送 {commands},然后断开。不建角色,不登录,对端没有任何改动。会话有超时上限,卡住的探测不会一直占着一个连接位。 + + + CRAWL DELAY 说了算。 + + + 游戏若在 MSSP 报告中给出希望的最小间隔,我们就照办,并在两个方向上都优先于我们自己的排期:720 小时就是每月一次,而不是每周一次。失联的游戏仍会按这个更长的间隔被一直尝试下去,它恢复之后,正是这样重新回到列表的。 + + + 转介来的地址只做核验,从不轻信。 + + + MSSP 允许一个游戏点名其他游戏。任何拨号之前,每个名称都先做解析;除非它解析出的每一个地址都是全球可路由的,否则一律拒绝。解析结果混杂时,整个目标都被拒绝。这种拒绝记在我们自己名下,绝不会作为停机出现在某个游戏的记录里。 + + + 展示连接画面,是因为它本来就发给所有人。 + + + 服务器会向每一个匿名连接、无需认证地绘出自己的连接画面。我们把它作为证据展示,并加以标注。你若开口,我们就撤下。 + + + 说停我们就停——有三种方式。 + + + 在你的 MSSP 报告中发布 {variable} 1,读到它的那次探测就是最后一次。或者在 {label}.your.host 上发布一条内容为“{value}”的 TXT 记录,这既不需要支持 MSSP,也不需要在本站注册账户。再或者,写信给一个人。这三种方式都会在一个爬取周期之内生效,并连同日期和我们读到的内容一起记录,提交表单上同样照此执行。 + + + MSSP 字段停的是那个监听端口,TXT 记录停的是整台主机。 + + + MSSP 由应答的那个端口发布,因此它只代表那个端口——MU* 托管商常常在同一个域名下运行互不相关的游戏,谁也不该替邻居闭嘴。TXT 记录默认覆盖所有端口,除非它指名某一个,写作“{value}=4201”。凡是我们读不出端口清单的写法,都按整台主机处理,所以“{value}=all”也是有效的。 + + + DNS 这条路,你不用问我们就能撤销。 + + + 读一条 TXT 记录,不必连上那台叫我们别来的服务器,所以我们在每次拨号前都会重新读它。你把它删掉,一周之内我们就会再次拨号。而 MSSP 字段不做你叫我们别做的那件事就无法重读,所以通过 MSSP 提出的退出和书面请求会一直有效,直到你另行告知。已退出的地址只会得到这一次 TXT 查询:它触及的是你的域名服务器,绝不会触及你的游戏。 + + + 停下不是删除,也不是停机。 + + + 选择退出的游戏保留它的页面、它的地址,以及它开口之前我们实测到的一切。停下的只有新数据:活跃度网格不再增加小时,并且不指明原因,因为不再叩门是我们的决定,那是关于我们的事实。它记录在那次没有发生的爬取上,也记录在提出请求者的名册里。 + + + 如果只是停下还不够,列表条目也可以撤下。 + + + 当我们在你游戏应答的每一个地址上都已停手之后,你的控制台会多出一项:把它移出列表、排行和每日数字。页面和它曾用过的每一个地址仍然照常应答,任何内容都不会被删除——它只是不再是读者靠浏览就能走到的地方。这需要一次经过验证的认领,因为这是关于你游戏的决定,我们会记录是谁做的。而一次探测就能撤销它:收回你的退出声明,下一次拨号只要得到应答,你就会重新回到列表,不必再来问我们第二次。 + + + 服务器询问来者何人时,爬虫自报为 {name}。 + + + 爬虫已配置为自称 {name},但目前还说不出口。它使用的 telnet 库没有给客户端设置终端类型的途径,所以你的日志里看到的是那个库的默认值,而 NEW-ENVIRON 是用爬虫主机的环境作答的。这两处都是那个库的缺口,该由我们去那里修补。在此之前,请靠形态辨认一次探测:一个连接,不登录,一组简短的只读命令,然后就走。 + + + 爬虫 + + + 联系方式 + + + 爬虫:{name} + + + 联系方式:{url} + + + ——占位值;本部署未设置联系地址 + + + 未配置联系地址,因此上面那个只是占位值,不会有人回应。 + + + 游戏名单从何而来 + + + 我们只取地址,别的都不取。 + + + 一次回填只取一个主机和一个端口。不取人数计数,不取连通历史,不取简介,不取字段,也不记录某个地址来自哪个站点。 + + + 刻意少于那些站点能给的。 + + + 其中有几家存着多年的带日期人数计数。把它们导进来,会用别人早已在观测的游戏去填满热力图,也会把本站最核心的主张,架在别人的探测器上。 + + + 一个游戏的出处不是单一事实。 + + + 凡是值得列出的游戏,都会同时出现在其中好几个目录里,所以“导入自”只会写上先跑完的那次抓取。某个游戏存在,这是公开信息;我们从哪里读到的,既不增添任何东西,也是别人劳动成果里最没理由算作我们的那一部分。 + + + 读别人的站点,终究是在读别人的站点。 + + + 抓取之前,我们先索要批量导出或有文档的接口,先读 robots.txt,并对抓取严格限速。凡是需要维护者点头的来源,在有人能声明已经征询过之前,一律不抓。 + + + 已读取——仅地址 + + + 未读取——等待许可 + + + 一个页面,一次请求。由一个逐个连入游戏、并打印所读内容的爬虫发布。 + + + 同一个爬虫的 MSDP 列表。几乎是其 MSSP 版本的子集,读取它只为那少数几个另一份没有覆盖到的地址。 + + + 在一个页面上发布整份目录,因此读一次只花一个请求。它是我们最大的地址来源,而不是任何实测数据的来源。 + + + 一个索引页,外加每个世界一个页面,所以只能抓取,而不能导出。2026 年 7 月 30 日,我们以十五秒的间隔、遵守 robots.txt 抓取了他们的 143 个页面,但当时还没有人写信联系过他们。这本不该发生。现在这道闸门需要有人愿意声明已经征询过维护者才会打开。 + + + 已实现,已测试,从未运行。除许可这一项之外,它在各方面都是这里最强的来源;在有人写信联系他们之前,我们不会抓取任何内容。 + + + 许可协议 + + + 代码采用 MIT 许可协议。 + + + 本站、爬虫和各个解析器,均在 MIT 许可协议下开源。 + + + 数据的许可协议尚无定论。 + + + 这是与代码分开的一项决定,目前尚未作出。请把下面的条款当作本部署当前的答复,而不是本项目已定的立场。竞争的目录把整份名录拿走,在这里算作一项成功,所以无论最终如何定夺,都不会挡它的路。 + + + 代码 + + + 数据,按本部署提供的形式 + + + 署名为 + + + 代码:{licence} + + + 数据:{licence} + + + 署名为:{credit} + + + (这是本部署提供的形式。本项目自己的答复仍未定。) + + + 提交游戏 + + + 告诉我们某个游戏在哪里。一个主机加一个端口就是整张表单;本站其余的一切,都由我们自己的爬虫实测得出。 + + + 主机 + + + 端口 + + + mud.example.org,或者粘贴 mud.example.org:4201 并把端口留空 + + + 提交 + + + 提交需要数据库,而本站正运行在演示数据上。没有可写入的爬取登记表,所以这里干脆不放表单,而不是放一个悄悄什么也不做的表单。 + + + 此处没有 + + + 一个地址会经历什么 + + + 拨号之前我们先解析地址,凡是解析到公网之外的一律拒绝。那是关于我们自己套接字的决定,绝不是关于某个游戏的事实。 + + + 如果运行那台主机的人已经要求我们不要爬取,那么无论谁提交,我们都不会接受这个地址。陌生人无法把你的游戏重新放上本站。 + + + 如果它有应答,我们就读取服务器对自己的说法,并按它自己的节奏一直读下去。一个地址只需要给我们一次。 + + + 在有人证明自己是运营者之前,站上不会出现任何内容。认领需要一个通行密钥,以及在游戏本身上发布的一行文字。 + + + 我们已经有的地址会并入现有条目。重复提交不会产生第二个条目,也不会让探测提前。 + + + 那个地址 + + + 已进入登记表。 + + + {address} 会在下一个爬取周期被拨号,此后一直按它自己的节奏进行。等有人证明自己是运营者,它就会出现在这里——带着同一个地址回到这张表单,它会把链接交给你。 + + + 我们有它,尚无人认领。 + + + {address} 是我们已经在实测的地址。在有人证明自己是运营者之前,它不会出现在站上。如果那个人就是你,这里就是入口。 + + + 这个我们已经有了。 + + + {address} 是我们已经在实测的游戏。没有创建任何内容,也没有改动任何内容。 + + + 这个地址我们已经有了。 + + + {address} 我们已经知道了。没有创建任何内容,也没有改动任何内容。 + + + 已经在等了。 + + + {address} 已在爬取登记表中,尚未应答。再次提交不会让它提前:每个目标都按自己的节奏来,所以谁也不能借我们去催别人的服务器。 + + + 这不是我们能拨号的地址。 + + + 主机名里需要有一个点或一个冒号,端口是 1 到 65535 之间的数字。请把两个框都填上,或者把 mud.example.org:4201 粘贴到第一个框里。 + + + 我们无法拨号到那里。 + + + 对 {address} 给出这个答复有三种可能:名称可能解析不了,可能解析到公网之外,也可能运行那台主机的人已经要求我们别去。我们刻意不说是哪一种,因为向陌生人回答这个问题,等于从外部把一张网络画出来。关于该地址没有记录任何内容;这个决定是我们做的,也记在我们自己名下。 + + + 暂时够了。 + + + 这张表单按提交者限速,你已经到上限了。请一小时后再来。没有丢失任何东西——我们收下的内容都已经在登记表里。 + + + 认领此游戏 + + + 登录 + + + 用通行密钥登录,即可认领你运营的游戏。没有密码可丢,也没有密码可偷。 + + + 认领需要数据库,而本站正运行在演示数据上。这里没有可登录的东西。 + + + 登录就是一个通行密钥。 + + + 私钥保存在你的设备或密码管理器里;我们只保存公钥那一半。没有密码,没有电子邮箱。 + + + 用通行密钥登录 + + + 本站唯一需要 JavaScript 的页面。没有它,通行密钥无法工作。 + + + 还没有账户? + + + 只有在认领你运营的游戏时才需要账户。取一个对外显示的名字——它是认领旁边的一个标签,不是真名。 + + + 名称 + + + 例如 corvid-admin + + + 用通行密钥创建账户 + + + 我们存什么 + + + 你选择的名称。 + + + 你注册的每个通行密钥的公钥,以及你的设备给它取的名字。 + + + 你认领了哪些游戏,以及认领的时间。 + + + 不存电子邮箱,不存密码,也不把 IP 日志与你的账户关联。即使丢失了全部通行密钥,你也可以在自己的游戏上发布一个新的认领令牌,重新来过:凭据是游戏,不是账户。 + + + {year}年{month}{day}日 + + + {date} {time} UTC + + + 刚刚 + + + {count, plural, other {#分钟}} + + + {count, plural, other {#小时}} + + + {count, plural, other {#天}} + + + {count, plural, other {#周}} + + + {count, plural, other {#个月}} + + + {count, plural, other {#年}} + + + 刚刚 + + + {count, plural, other {#分钟前}} + + + {count, plural, other {#小时前}} + + + {count, plural, other {#天前}} + + + {count, plural, other {#周前}} + + + {count, plural, other {#个月前}} + + + {count, plural, other {#年前}} + + + 刚刚 + + + {count, plural, other {#分钟前}} + + + {count, plural, other {#小时前}} + + + {count, plural, other {#天前}} + + + {count, plural, other {#周前}} + + + {count, plural, other {#个月前}} + + + {count, plural, other {#年前}} + + + {age},{stamp} + + + ,{stamp} + + + {value}——经{source}{how},最近确认于 {date} + + + {value}——经{source}{how},最近确认于 {date}(已过预期刷新期) + + + ({how},{age}) + + + ({how},{age},已过刷新期) + + + 所有者自述 + + + 本站工作人员 + + + telnet 握手 + + + 游戏所有者 + + + WHO + + + I3 + + + MSSP + + + INFO + + + I3 的游戏列表 + + + 连接画面 + + + 生态 + + + 只讲占比,绝不讲总量。我们不发布有多少人在玩 MU* 的数字:在我们实测过的游戏上算出的比例,即使还有连不上的游戏也依然成立,人头总数则不然。 + + + {count, plural, other {已列出的游戏 {value} 个}} + + + {count, plural, other {已完成握手的游戏 {value} 个}} + + + {count, plural, other {已持有 MSSP 报告的游戏 {value} 个}} + + + 此处最早的握手:{age}前确认。 + + + {total, number} 个中的 {count, number}({fraction, number, ::percent .0}) + + + {total, number} 个中的 {count, number}——尚无实测 + + + {fraction, number, ::percent .0} + + + 代码库 + + + 在已列出的{listed, plural, other {#个游戏}}中,有 {identified, number} 个告诉了我们它们运行的是什么,下面每一项占比都是相对这 {identified, number} 个算出来的。读不出代码库的游戏被排除在分母之外,绝不会被算作别的东西。 + + + 还没有已列出的游戏告诉过我们它的代码库。 + + + {share} 运行的代码库,没有任何其他已列出的游戏在用——每种各一个游戏,那是一个名字,而不是一份占比。它们计入上面的分母,只是从条形图中折叠起来,并没有被丢弃: + + + 谱系 + + + 还是这些游戏,按其服务器所承袭的传统分组——这是我们对代码库的判读,而不是任何游戏发布过的东西。没有游戏会报告“MUSH”:MSSP 里没有这个取值,而 MUSH 世界的大多数根本不发布 MSSP,所以只有这样,这个问题才问得出来。 + + + 还没有已列出的游戏运行我们能归入某个谱系的代码库。 + + + {count, plural, other {其中#个游戏运行的代码库}}我们没有归入任何谱系——有几个自己也这么说,它们发布的是 {family}。它们计入上面的分母,但不属于任何一份占比。 + + + 协议 + + + 下面每一个实测数字都应读作下限。MSSP 是我们指名索取的,所以在这一项上,沉默本身就是一种回答。其余各项我们都不索取,服务器可能支持某个协议,却从不主动提供。 + + + {instrument} 是下面唯一不算下限的一行:我们向每台服务器指名索取它,所以没有提供它的游戏,是被问过之后拒绝的。它也是唯一没有自述数字的一项,因为凡是我们持有其报告的游戏,都已用行动证明支持它,再去数其中有多少同时把它列了出来,量到的只是一种习惯。 + + + 我们持有 {reports, number} 份报告,而今天提供 MSSP 的有{offered, plural, other {#个游戏}}:另外 {gap, number} 个在我们读过之后就不再发布了,一份报告不会因为不再重发而被丢弃。 + + + 协议采用情况。实测是服务器在一次完成的握手中提供的内容,自述是它的 MSSP 所声称的内容。两组不同的游戏,因此有两个分母。 + + + 协议 + + + 实测——占 {basis} + + + 自述——占 {basis} + + + 未实测——从未观测到 + + + {share} · {declined, plural, other {#个游戏}}被问及时拒绝 + + + {share} · {unobserved, plural, other {#个游戏}}既未提供也未被问及 + + + {share} · {declined, plural, other {#个游戏}}被问及时拒绝 · {unobserved, plural, other {#个游戏}}既未提供也未被问及 + + + 未索取——这里的每一份报告本身就是回答 + + + 采用情况随时间的变化 + + + 每个点都是当天我们已实测的那些游戏上的占比,所以这条线会因两件事而移动:某个游戏改变了它提供的内容,以及我们能实测的游戏集合在它周围发生了变化。只有前者才是采用。下面的变更次数,才是纯粹由游戏改变主意构成的那一部分。 + + + 各协议的实测占比,最早的读数在前 + + + 当时 + + + 现在 + + + 未实测 + + + 这是快照,不是曲线 + + + 这是我们现在能实测到的情况的一张快照。采用曲线画的是游戏改变主意,而我们在变化发生时才记录它,所以等记录得足够多,曲线才画得出来。若改用我们首次连通各个游戏的时间来作图,量到的将是这次爬取,而不是这个爱好。 + + + {count, plural, other {迄今已记录#次能力变更}}——曲线正是用这些材料画出来的。 + + + 还没有任何实测能力发生过变化,因此无从作图。 + + + 实测的分母是 {measured},自述的分母是 {declared}。两组不同的游戏,因此有两个分母。 + + + {listed} · {handshakes} · {mssp}。 + + + 这张图里最早的握手,最近一次确认是在 {age}前。 + + + 还是这些游戏,按其服务器所承袭的传统分组。这是{evidence}——{meaning}——而不是任何游戏发布过的东西:没有游戏会报告“MUSH”,因为 MSSP 里没有这个取值,而 MUSH 世界的大多数根本不发布 MSSP。 + + + 实测:{value} + + + 自述:{value} + + + 排行 + + + 仅由实测数据算出。永远没有投票、星级或评分。这里没有任何东西是对质量的排名。我们没有测过质量。 + + + 最热闹——按实测的同时连接人数 + + + 排行时间窗 + + + {days, plural, other {#天}} + + + 最近{days, plural, other {#天}}内我们实测到的人数的中位数。 + + + 还没有游戏在{days, plural, other {#天}}内取得中位数所需的 {samples, number} 个样本。 + + + {listed, number} 个已列出的游戏中,有{eligible, plural, other {#个}}在{days, plural, other {#天}}内取得了所需的 {samples, number} 个样本。 + + + 实测到的零算数;读不出来的计数不算。 + + + 一周说的是现在谁热闹,一个季度说的是一直以来谁热闹。这是两个不同的问题,一个游戏可能在其中一项领先,而在另一项不然。天数按整天计,时间为 UTC。 + + + 还没有已列出的游戏拥有足以参与排行的已计数样本——这说的是我们测了多久,而不是谁有多热闹。 + + + 按最近{days, plural, other {#天}}内我们实测到的人数的中位数为游戏排名。中位数相同的游戏并列同一名次,这里不做任何附加排序来分先后。 + + + # + + + 游戏 + + + 中位数 + + + 峰值 + + + 已计数样本 + + + 实测天数 + + + 连续可连通时间最长 + + + 自所给日期起的每一次探测,都发现该游戏可连通。是可连通,不是在线:我们从一台主机上测一个套接字,路由不到的游戏完全可能好好活着。一段连续时长不可能超过我们观察的时长,所以日期才是事实,时长由它推出。 + + + 目前没有已列出的游戏处在连续可连通的时段中。 + + + 自所给日期起每一次探测都可连通的游戏。自同一日期起可连通的游戏并列同一名次,这里不做任何附加排序来分先后。 + + + 可连通起始 + + + 即 + + + 已归档的游戏只是不在这两张表里,仅此而已;一次成功的探测就能让它们回来。 + + + 最热闹——最近{days, plural, other {#天}}实测人数中位数 + + + 时间窗: + + + 当前这个 + + + 中位数 {median, number} · 峰值 {peak, number} · {window, number} 天中的 {days, number} 天里共有{samples, plural, other {#个已计数样本}} + + + 自 {date} 起每次探测均可连通 · {duration} + + + 归档 + + + 已经停止应答的游戏。没有删除任何内容。我们仍每周探测,一次成功的探测当天就能把游戏放回列表。 + + + 搜索归档 + + + 搜索已归档的游戏 + + + 名称、代码库或简介 + + + 显示 + + + {count, plural, =0 {没有已归档的游戏} other {#个已归档的游戏}} + + + 已归档 + + + 最近连通 + + + 已知存活 + + + ({age}前) + + + 没有匹配结果。 + + + 在我们实测过的范围内从未连通 + + + 未实测到可连通时长 + + + 未知 + + + {years, number, ::.#} 年 + + + {days, plural, other {#天}} + + + {from} – {to} · {span} + + + {count, plural, other {#个月}} + + + {count, plural, other {#年}} + + + {count, plural, other {有#个游戏}}匹配“{query}” + + + {count, plural, other {#个游戏}} + + + 最近连通: + + + 已知存活: + + + 实测可连通时长 {value} + + + 运行期: + + + 代码库: + + + 随机游戏 + + + {d, date, medium}——平均 {typical},{probes, plural, other {#次探测}}中介于 {low}–{high} + + + {d, date, medium}——{count, plural, =0 {0 人} other {#人}},{probes, plural, other {#次探测}}次次如此 + + + {d, date, medium}——已探测,未能读取计数 + + + {d, date, medium}——无实测 + + + 在{days, plural, other {#天}}中的 {counted} 天里,典型有 {typical} 人在线,峰值 {peak}。 + + + 整个区间内保持平稳。 + + + 从区间开始到结束,约上升 {change, number, percent}。 + + + 从区间开始到结束,约下降 {change, number, percent}。 + + + 本区间内有过探测,其中任何一次都未能读出在线人数。 + + + 本区间内没有实测。 + + + 本区间内没有计到任何数。 + + + {from, date, d MMM}–{to, date, d MMM} + + + {d, date, d MMM} + + + {span}:典型 {typical},峰值 {peak},已计数{days, plural, other {#天}} + + + {span}:已探测,未能读取计数 + + + {span}:未实测 + + + {count, plural, other {#天已探测但没有计数}} + + + {count, plural, other {#天未实测}} + + + {line},{clause} + + + 按 UTC 计日 · {from, date, medium} – {to, date, medium} + + + 顶端为 {value} + + + {days, plural, other {#天}}中已计数 {counted} 天 + + + {d, date, MMM} + + + {d, date, MMM yyyy} + + + 当天我们读到的各次计数的平均值 + + + 直到当天最热闹的那次计数 + + + 当天最低到最高的计数 + + + 已探测,未能读取计数 + + + 未实测——完全没有条形 + + + 未实测——线条断开一段 + + + 趋势区间 + + + 趋势图形 + + + {days, plural, other {#天}} + + + ← 更早 + + + 更近 → + + + 折线 + + + 条形 + + + 人数随时间变化 + + + {from, date, medium} – {to, date, medium},UTC + + + 更早 + + + 一周的概况按其中我们计到数的那些天汇总;一天也没有的那一周会明确说明 + + + 可连通 · 最近{days, plural, other {#天}} + + + 可连通 + + + 最长中断 + + + 最近原因 + + + 窗口内没有 + + + 未记录 + + + {days, plural, other {#天前}} + + + 今天 + + + 可连通 + + + 降级——有应答,未能完成 + + + 无法连通 + + + 未实测 + + + 可连通 + + + 降级 + + + 无法连通 + + + 未实测 + + + {d, date, d MMM}——全天可连通 + + + {d, date, d MMM}——降级({cause}):有应答,未能完成 + + + {d, date, d MMM}——无法连通({cause}) + + + {d, date, d MMM}——未实测;当时我们还没有开始观测这个游戏 + + + {days, plural, other {最近#天尚未实测。}} + + + 最近{days, plural, other {#天}}中,有 {percent} 的时间可连通。 + + + 在我们已实测的{days, plural, other {#天}}中,有 {percent} 的时间可连通。 + + + {days, plural, other {最近#天的可连通情况尚未实测。}} + + + 窗口内没有哪一天是无法连通的。 + + + 我们实测过的日子里,没有哪一天是无法连通的。 + + + {count, plural, other {#天无法连通。}} + + + {count, plural, other {#天降级——我们连上了,但未能完成。}} + + + 最长中断 {duration}。 + + + 最长中断 {duration}({cause})。 + + + {count, plural, other {有#天早于我们的任何实测。}} + + + {from, date, d MMM} – {to, date, d MMM} + + + {d, date, d MMM} + + + {range}:{word}{count, plural, other {#天}} + + + {range}:{word}{count, plural, other {#天}}({cause}) + + + 可连通 + + + {days, plural, other {最近#天}} + + + 可连通:{days, plural, other {最近#天}}中的 {percent} + + + 最长中断:{duration} + + + dns 未能解析 + + + 连接被拒绝 + + + tls 失败 + + + 超时 + + + 握手停滞 + + + 未记录原因 + + + 所有者要求我们不要再发布这个游戏的连接画面。 + + + 尚未从这个游戏捕获到连接画面。 + + + {count, plural, other {只回来了#行——太少,无法显示。}} + + + 按服务器发送的原样 + + + {columns}×{rows} + + + {columns}×{rows},双倍宽 + + + 16 色 SGR + + + 无颜色 + + + 按 {charset} 解读 + + + 已捕获 + + + 已冻结——我们最后看到的画面 + + + ASCII 图画:{game} 的连接画面。它的文本在下方的“以文本形式阅读”中。 + + + ASCII 图画:这个游戏的连接画面。它的文本在下方的“以文本形式阅读”中。 + + + {count, plural, other {连接画面:#行,仅文本}} + + + {count, plural, other {连接画面:#行,仅文本,按 {charset} 解读}} + + + 游戏自述具备该项,而服务器在握手中从未提供过它。 + + + 服务器提供该项,而游戏自己的记录说它没有。 + + + 通常只是一个久未更新的手打字段,而不是谎言。之所以显示出来,是因为客户端不该依赖两者不一致的部分。 + + + 未找到 + + + 这个地址上没有游戏。请检查拼写。 + + + 最近应答 + + + {count, plural, other {有#项已超过刷新窗口。是旧,不是错。}} + + + 这个游戏自己的转介列表点名了: + + + 被以下游戏的转介列表点名: + + + 自 {date} 起 + + + 已不再列出,最后一次见于 + + + 最近一次应答是 {date},距今 {ago}。 + + + 仍每周探测;它一应答,本页当天就会更新。 + + + 已知存活 {span} + + + 不在列表、排行或每日数字中:我们认为这个地址不是一个可供人游玩的游戏。下面的一切都是它告诉我们的内容,原样未改。 + + + 我们的理由:{why} + + + 应运营者的要求,不在列表、排行或每日数字中。下面的一切按原样保留,本页和它曾用过的每一个地址都继续应答,而我们不再向它拨号。 + + + 当前人数:{count} + + + 能力({total} 项中有 {disagreeing} 项不一致) + + + 游戏自述 + + + 连接画面 + + + 变更记录 + + + 实测 + + + 自述 + + + ** 不一致 + + + 连接数 + + + 连通 + + + 搜索 + + + 已归档 + + + 成人 + + + 已包含 + + + 无计数 + + + 当前无计数,此处也不指明原因 + + + 当前人数:无计数(此处不指明原因) + + + 爬虫运行中 · 最近探测 {age} + + + 爬虫空闲 · 最近探测 {age} + + + 此处尚未完成过任何探测 + + + 本周期无到期目标 + + + {considered, plural, other {#个到期}} · {answered, plural, other {#个应答}} · {failed, plural, other {#个失败}} + + + 登记表中有{targets, plural, other {#个地址}},{due, plural, other {其中#个已到期}} + + + {page} — {site} + + + MU* 爱好的游戏目录——MUSH、MUD、MUCK、MOO——每一项事实都标明它的来源与时间。 + + + 演示数据——此处没有任何内容经过实测。{description} + + + {site}——实测,而非自述 + + + {site} 上的 {title} + + + 游戏 + + + 归档 + + + 排行 + + + 生态 + + + 参考 + + + 关于 + + + 未找到 + + + 随机游戏 + + + 我的游戏 + + + 认领 {game} + + + 我们连通过的每一个 MU*,按实测结果分面:代码库、服务器在握手中提供的协议、TLS、编码、语言,以及我们最近一次连入的时间。 + + + 已经失联的游戏,都保留下来。每个都保有自己的页面、历史和 URL,仍每周探测,一次成功连接即可回到列表。 + + + 最热闹、最可连通、运行最久——仅由实测数据算出。本站任何地方都没有投票、星级或评分。 + + + 我们实测的游戏中,代码库的占比与协议的采用情况,服务器提供的内容与它自述的内容并列呈现。只讲占比,绝不讲总量。 + + + 关于 MU* 爱好中的代码库、客户端与协议的手写页面,并与爬取所得的计数相互链接。 + + + 这份目录是如何建成的:爬虫做什么、拒绝做什么,以及如何让它停下。 + + + 这个地址上没有游戏。本站任何内容都不会被删除,所以曾经在这个 URL 上的游戏如今依然在——请检查拼写。 + + + 从目录中随机挑出的一个游戏,绝不会连着两次相同。 + + + 你已认领的条目,以及一次认领能让你改动什么。 + + + 在只有运营者才能放置的地方发布一个令牌,以此证明这个游戏由你运营。 + + + 已归档——最近连通 {age},仍在探测 + + + 已归档,仍在探测 + + + 人数未知——游戏有应答,但没有发布我们能读出的数字 + + + {count, plural, other {#人连接}},{age}{how} + + + 新发现 + + + 已失联 + + + 已恢复 + + + {count, plural, other {已知#个游戏}} + + + {count, plural, other {当前#人连接(实测)}} + + + {count, plural, other {#个游戏有应答,未能计数}} + + + {count, plural, other {#个已归档,仍在探测}} + + + 参考 + + + 代码库是什么,客户端做什么,协议又意味着什么。由人手写,与爬虫一同存放在代码仓库里——这不是维基,本页上也没有任何可编辑的东西。这里的每一个{number}都是另一回事:它取自目录,每次加载页面时都重新算出。 + + + 数字 + + + 手写,单一作者,并用 git 做版本管理。这里的文字出自我们;旁边的每一个数字都由爬虫实测得出,并在每次请求时重新算出。这不是维基,本页也没有任何编辑它的途径。 + + + 从这里开始 + + + 代码库 + + + 客户端 + + + 协议 + + + 入门 + + + 代码库 + + + 客户端 + + + 协议 + + + 未找到 + + + 这里没有参考页面。本节由人手写,所以缺口是还没人做的工作,而不是被移除的内容——{index}。 + + + 看看现有的内容 + + + 运行它的游戏 + + + 我们尚未识别到任何一个。这是关于这台爬虫实测到了什么的事实,而不是关于存在什么的事实——我们没有连通过的游戏,或者代码库读不出来的游戏,都不算进这里。 + + + {count, plural, other {已列出#个}} + + + {count, plural, other {已归档#个}} + + + 实测,绝非断言 + + + 这是本次请求时从目录中数出的,用的正是上方链接所带的同一个筛选条件——所以这个数字和那份列表出自同一个查询,不可能对不上。 + + + 在它们的握手中提供:{protocols} + + + 实测采用情况 + + + 尚无实测。 + + + 在{listed, plural, other {#个已列出的游戏}}中——{percent} + + + 未算进这里的游戏,并不是没有这个协议的游戏。一个游戏被算进这里,条件是我们观测到它的服务器在一次握手中提供了这个选项;其余的既包括没有向我们提供它的服务器,也包括我们还没有读到过握手的服务器,而我们说不出是哪一种。 + + + 在一次握手中被观测到提供 {protocol} 的游戏,按我们识别出的其运行代码库分组。 + + + 代码库 + + + 已提供 + + + 已识别 + + + 另见 + + + 能力 + + + {count, plural, other {在{total}项中,有#项依据项目自己的文档确定}} + + + 逐项读自各项目自己的文档,而非由我们实测——客户端没有握手可供我们观测。“{unknown}”表示我们查过,没能确定。它绝不表示“否”。 + + + 客户端能力,每一项都读自项目自己的文档。未知表示我们没能确定,而绝不表示这个客户端不具备该项。 + + + 有文档 + + + 来源 + + + 我们没有找到 + + + 有 + + + 否 + + + 未知 + + + 运行平台:{platforms} + + + 我们识别为运行该代码库的游戏 + + + 尚无。这是关于我们实测到了什么的说法,而不是关于存在什么的说法——我们没有连通过的游戏,或者代码库读不出来的游戏,都不算进这里。 + + + {listed, plural, other {已列出#个}},{archived, plural, other {已归档#个}} + + + 在它们的握手中实测到:{protocols} + + + 在我们读到过的它们的任何一次握手中,都没有提供过任何东西。 + + + 在{listed, plural, other {#个已列出的游戏}}中,有 {offering} 个被观测到提供了它({percent}) + + + 按代码库,在我们已识别的游戏中 + + + {identified} 个中有 {offering} 个提供了它 + + + {count, plural, other {在{total}行中,有#行是未知}}:我们没有找到项目自己的文档给出任何一种说法。一张诚实的短表胜过一张靠猜的长表。 + + + 我的游戏 + + + 账户需要数据库支撑,而本站正运行在演示数据上。 + + + 登录 + + + 已以 {name} 登录。 + + + 退出登录 + + + 你还没有认领任何游戏。在{listing}中找到你的游戏,在它的页面上点击{claimControl}。 + + + 列表 + + + 认领此游戏 + + + 已放弃。 + + + 相关记录会保留,你随时可以再发布一个新令牌,重新证明控制权。 + + + 已保存。 + + + 那个游戏 + + + {game} 的页面现在把它标为所有者自述。 + + + 我们已停止发布 {game} 的连接画面。页面会明确说明,而不是留一个空洞。 + + + {game} 的连接画面又回到了它的页面上。 + + + 我们已停止向 {game} 拨号,我们掌握的它的每一个地址都停了。它的页面保留你开口之前实测到的一切。 + + + 我们又开始向 {game} 拨号,从它在排期中的下一轮起。 + + + {game} 已不在列表、排行和每日数字中。它的页面和它曾用过的每一个 URL 仍然照常应答。 + + + {game} 已回到列表中。一次得到应答的探测,就足以让它重新被实测。 + + + {field} 未作改动。 + + + 这些是一行以内的回答;我们最多保存 {max} 个字符。 + + + 该字段是实测的。认领让你补上 MSSP 放不下的内容;它绝不让任何人改动我们观测到的内容,我们自己也不例外。 + + + 已认领 + + + 已于 {date} 验证 + + + 已于 {date} 验证,令牌最近一次见于 {seen} + + + 检查你的 MSSP + + + {count, plural, other {另有 {names} 共同拥有——各自验证过自己的令牌。}} + + + 另一个账户 + + + 把你的在线人数放到自己的网站上 + + + 这个徽标同时带上计数和我们实测它的时间,因为一个没有时间的数字,正是本站要取而代之的东西。 + + + 我们未能计数时,它显示{unknown}而不是零;游戏停止应答时,它显示{archived}。 + + + 还有{json},如果你更愿意自己画。 + + + 历史 + + + 已签发令牌 + + + 已再次签发令牌 + + + 已验证——我们读到了你的令牌 + + + 令牌仍在发布 + + + 本次未读到令牌 + + + 已放弃认领 + + + 令牌未使用即过期 + + + 另一个账户证明了控制权,接管了这个游戏 + + + 已请求提前查看 + + + 放弃此认领 + + + 输入{word}以确认。任何内容都不会被删除,你可以再发布一个新令牌重新证明控制权;如果还有别人拥有它,这个游戏仍是已认领状态。 + + + 放弃 {game} + + + 等待令牌 + + + 令牌签发于 {issued},有效期至 {expires} + + + 通行密钥 + + + 未命名 + + + 添加于 {date} + + + 添加于 {date} · 仅在一台设备上 + + + 这个通行密钥只存在于一台设备上。万一丢了,你仍可以在自己的游戏上发布一个新令牌重新进来,但再加一个通行密钥更快。 + + + 再添加一个通行密钥 + + + 没有这个游戏 + + + 认领 {game} + + + 认领需要数据库,而本站正运行在演示数据上。 + + + 你需要先有一个账户。只需一个通行密钥和一个名称。 + + + 登录或创建账户 + + + 我的游戏 + + + {count, plural, other {这个游戏已经有#位所有者证明了对该服务器的控制权。}}你也可以证明——两种情况下的检验方式相同——但我们需要知道你的意思是什么,因为从令牌上看不出来。 + + + 我也在运营它——把我加为所有者 + + + 每个人都保留自己的认领。这是两个人一起运营一个游戏。 + + + 我已经接手了它——把它转给我 + + + {count, plural, other {你的令牌通过验证后,现有的认领会被撤销,这个游戏归你。}}他们会在自己的历史里看到原因。任何内容都不会被删除,他们也可以用你即将采用的同一种方式重新证明控制权。 + + + 已验证。 + + + 我们在 {date} 从这个游戏的 MSSP 报告中读到了你的令牌。 + + + 我们在 {date} 从连接画面上读到了你的令牌。 + + + 把令牌留在原处。它同时也是一个身份标记,这样即使这个游戏换了主机或改了名字,我们仍认得出它。把它撤掉不会取消你的认领。 + + + 把这个令牌发布在游戏会展示给匿名连接的任何地方。下一次探测就会读到它,这就证明了你能写入那台服务器。 + + + 这是一次转移。 + + + {count, plural, other {我们读到这个令牌时,当前所有者对这个游戏的认领会被撤销,它归你。}} + + + 两种方式都可以 + + + 一个 MSSP 变量 + + + 在 {codebase} 中那是 {file} 里的一行;每个支持 MSSP 的代码库都有对应的做法。 + + + {aliases} 同样可用。 + + + 连接画面上的一行 + + + 画面中任何位置都行,周围有颜色代码也没关系。 + + + 然后 + + + 我们按平常的爬取排期来查看。这个令牌有效期至 {date}。你随时可以再来;没有什么需要记下来。 + + + 提前查看 + + + 把你的游戏排到队列前面。我们按自己的排期拨号,所以这只是提前,而不是立刻。 + + + 刚刚请求过。请过几分钟再试——之所以限量,是因为它会让我们比原定更早去拨一台真实的服务器。 + + + 关于 {game},只有你能告诉我们的事 + + + 这些是 MSSP 没有字段可放的东西。它们会以{declared}的身份出现在你游戏的页面上,带着你最后一次确认的日期,与我们实测到的内容并列——绝不取而代之。实测到的任何内容都无法从这里编辑,你不能,我们也不能。 + + + {age}自述。清空此框即可撤回——不论如何,它曾写过什么的记录都会保留。 + + + 保存你的自述 + + + {game} 报告了什么,以及你更希望我们显示什么 + + + 你的 MSSP 是每个爬虫都会读到的内容,无论你在这里填了什么,我们都会把它并列显示出来——你给的答案不会遮住你游戏给的答案。 + + + 实测到的任何内容都无法从这里编辑:人数计数不行,能力不行,可连通的某个小时也不行。 + + + 如果下面某一行在你的 {file} 里就是错的,在那里改掉,处处都会跟着改。 + + + 你的游戏报告 {value},{age}确认 + + + 你的游戏在这里什么也没有报告 + + + 改名会同时改变 {game} 在列表中的名称和它页面的地址。旧地址永远继续有效——这个游戏曾用过的每一个 URL 都会重定向到它当前的地址——而清空此框,名称就交还给你的 MSSP 所说的内容。 + + + 你的连接画面 + + + 我们不再发布它。页面会明确说明,而不是留一个空洞;爬虫仍会继续读它——你的游戏搬了家,我们靠的就是它认出你。 + + + 重新显示 + + + 我们展示它,是因为你的服务器本来就把它发给每一个匿名连接。你若不愿意,说一声我们就撤下。我们不会问为什么。 + + + 不要再展示我们的连接画面 + + + 被爬取 + + + 我们已经停下。{game} 上没有任何地址还在被拨号,页面保留你开口之前实测到的一切——那些空白的小时不指明原因,因为{ourFact}是关于我们的事实,而不是对你游戏的实测。 + + + 你要求我们停下 + + + 重新开始爬取我们 + + + 这一条来自你自己的服务器,而不是来自这里——{routes}。要重新被爬取,请停止发布它;下一个周期我们就会知道。 + + + 你的 MSSP 报告发布了 {variable} + + + 一条 {label} TXT 记录要求我们停下 + + + 一份我们记录在案的请求 + + + 我们已在 {stopped} 上停下,仍在拨 {dialling}。这通常是在退出声明之后又新增的端口。 + + + 在每一个地址上也都停下 + + + 我们按排期向 {game} 拨号,读取任何匿名连接都能看到的内容。你若不愿意,说一声我们就停——在一个爬取周期之内,在我们掌握的你的每一个地址上,而且我们不会问为什么。 + + + 已经实测到的内容不会被删除:你的页面保留它的历史和它的 URL,而你收回这项设置之后,一次探测就会让它重新开始。你也可以不经过我们,在自己的配置里说出来——MSSP 里的 {mssp},或者一条 {dns} TXT 记录——无论有没有人在这里认领过这个游戏,我们都照办。 + + + 不要再爬取我们 + + + 被列出 + + + {game} 已不在列表中,不在排行中,也不在每日数字中。它的页面和它曾用过的每一个 URL 仍然照常应答,你开口之前实测到的一切也都还在上面。任何内容都没有被删除;它只是不再是读者靠浏览就能走到的地方。 + + + 把我们放回列表 + + + 一次得到应答的探测同样能做到这件事。你的退出声明还立着的时候我们不会拨号,所以不会有这样的探测——但你收回它的那一天,这个地址会在一周之内轮到,得到应答的那次探测就会把你放回去。你不必再来问我们第二次。 + + + 我们已经不再向你拨号,而你的页面仍在列表中,带着在那之前我们实测到的内容。你若不愿意,说一声它就撤出来——从列表、排行和每日数字中撤出。 + + + 任何内容都不会被删除,也不会有什么坏掉:页面照常应答,它曾用过的每一个 URL 仍然重定向到它,你指引过去的人都能看到它。它只是不再是读者靠浏览就能走到的地方。这可以从这里撤销,而你收回退出声明之后,任何一次得到应答的探测也能撤销它。 + + + 把我们也移出列表 + + + 实测到了什么 + + + 隐藏这些会把它们从当前列表中移出;这并不表示该游戏里没有人。 + + + 未能计数 + + + 无法连通 + + + 已从此列表中隐藏 + + + 已从此列表中隐藏 + + + 左栏中,* 表示此列表已筛选为该值,- 表示此列表已把该值排除。两者都是查询中的选择,而不是关于某个游戏的事实。 + + diff --git a/src/MUI.Web/SiteComposition.cs b/src/MUI.Web/SiteComposition.cs index bc336b7..f502805 100644 --- a/src/MUI.Web/SiteComposition.cs +++ b/src/MUI.Web/SiteComposition.cs @@ -1,3 +1,5 @@ +using Microsoft.Extensions.Localization; + using MUI.Catalog; using MUI.Crawler; using MUI.Web.Accounts; @@ -6,6 +8,7 @@ using MUI.Web.Data; using MUI.Web.Fixtures; using MUI.Web.Icons; +using MUI.Web.Localization; using MUI.Web.Submissions; using MUI.Web.Theme; @@ -52,6 +55,9 @@ public static IServiceCollection AddMuiSite( services.AddRazorComponents(); + // The chrome's own words, before anything that renders them. + services.AddMuiLocalization(); + // The read API (spec §10) reads through the same IGameQueries the pages do, so the two // surfaces cannot disagree about a fact. What it adds of its own — the dataset licence, the // slug aliases and the attribution list — is configuration, because none of it is a @@ -112,6 +118,25 @@ public static IServiceCollection AddMuiSite( return services; } + /// + /// The resource set the chrome's strings are read from. + /// + /// + /// The arrangement SharpMUSH's portal uses for its own chrome — AddLocalization over a + /// Resources folder, resolved through a marker class, with the SDK compiling one + /// satellite assembly per culture and no <EmbeddedResource> entries anywhere. What + /// differs is the values: theirs are composite-format strings and these are ICU patterns, + /// because {0} substitutes and cannot agree. See . + /// + public static IServiceCollection AddMuiLocalization(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddLocalization(options => options.ResourcesPath = "Resources"); + + return services; + } + /// /// The middleware and the routes, in the order they have to be in. /// @@ -129,6 +154,18 @@ public static WebApplication UseMuiSite(this WebApplication app, string? connect // limit. app.UseSubmitterAddress(); + // Before anything routes, because the locale is a path segment and every @page directive is + // written without it: the middleware moves the prefix into PathBase so one route table + // serves every language. It is also before the not-found page, so a mistyped URL inside a + // locale is answered in that locale rather than in English. + app.UseMuiLocale(); + + // Explicit, and that is load-bearing rather than tidy: with no UseRouting call of its own an + // app gets one inserted at the very top of the pipeline, which resolved the endpoint before + // the middleware above had rewritten the path — so every localized URL 404'd while the + // unprefixed one worked. Naming it here is what puts routing after the rewrite. + app.UseRouting(); + // A reader who mistyped a URL, and a crawler indexing one, both got a 404 with an empty body: // the fragment inside is never rendered under static server rendering, so // the site's own "no game here" paragraph was dead copy. This answers those with the page — @@ -168,6 +205,10 @@ public static WebApplication UseMuiSite(this WebApplication app, string? connect // deployment has the same eyes as a reader of the real one. app.MapMuiTheme(); + // Beside the theme endpoint and for the same reason: it writes a cookie and redirects, and + // a reader of the demo deployment reads the same language as a reader of the real one. + app.MapMuiLocale(); + // §5.7, and before the route that would answer with "not found": a slug this game used to // have is a URL somebody is still holding, and it redirects to the page it has now — // permanently, and for an archived game exactly as for a live one. diff --git a/src/MUI.Web/Submissions/SubmissionEndpoint.cs b/src/MUI.Web/Submissions/SubmissionEndpoint.cs index e503b7d..53d60fc 100644 --- a/src/MUI.Web/Submissions/SubmissionEndpoint.cs +++ b/src/MUI.Web/Submissions/SubmissionEndpoint.cs @@ -3,6 +3,7 @@ using MUI.Catalog.Persistence; using MUI.Discovery; using MUI.Web.Components; +using MUI.Web.Localization; namespace MUI.Web.Submissions; @@ -56,7 +57,9 @@ await sources.OfAsync(SubmitterAddress.Of(context), context.RequestAborted), ? game.Slug : null; - return Results.Redirect(SubmitLinks.For(receipt.Outcome, receipt.Address, slug)); + return Results.Redirect(LocaleRouting.Link( + context.LocaleOf().Tag, + SubmitLinks.For(receipt.Outcome, receipt.Address, slug))); }); } } diff --git a/src/MUI.Web/Theme/ThemeEndpoint.cs b/src/MUI.Web/Theme/ThemeEndpoint.cs index 0904882..7b0b61b 100644 --- a/src/MUI.Web/Theme/ThemeEndpoint.cs +++ b/src/MUI.Web/Theme/ThemeEndpoint.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Mvc; +using MUI.Web.Localization; namespace MUI.Web.Theme; @@ -42,7 +43,13 @@ public static IEndpointRouteBuilder MapMuiTheme(this IEndpointRouteBuilder endpo // 303 rather than 302: the reader posted a form and what they should now be holding is // the page they came from, fetched with GET. A 302 gets there by convention on every // browser written since 1996, and this says what was meant. - context.Response.Headers.Location = ReaderTheme.Back(returnTo); + // + // And back into the language they were reading, which the return field cannot carry: it + // holds Request.Path, from which the middleware has already taken the prefix. A reader + // who followed a shared /de/… link has no cookie either, so this redirect was the whole + // of what stood between them and an English page — for choosing a light background. + context.Response.Headers.Location = + LocaleRouting.Link(context.LocaleOf().Tag, ReaderTheme.Back(returnTo)); return Results.StatusCode(StatusCodes.Status303SeeOther); }).DisableAntiforgery(); diff --git a/src/MUI.Web/wwwroot/app.css b/src/MUI.Web/wwwroot/app.css index 09d448e..0e0ae29 100644 --- a/src/MUI.Web/wwwroot/app.css +++ b/src/MUI.Web/wwwroot/app.css @@ -12,23 +12,74 @@ */ :root { - --cpad: 14px; /* the density control writes this, and only this */ - --row-pad: 9px; + /* + Spacing, radii and motion, in the relationships the design handoff's own token files carry. + The values are theirs; the colours below are ours — the handoff's palette is a different + product's and its README says so. What transfers is the scale: content padding at 20px rather + than the 14 this site had, one radius for controls and a larger one for the surfaces that hold + them, and a single easing curve at an eighth of a second. + */ + --cpad: 24px; /* the density control writes this, and only this */ + --row-pad: 13px; /* a listing row: 13px 20px, over a 44px floor */ + + /* + The one horizontal inset the whole site shares — the bar, every content band, every listing + row. Fluid rather than fixed because the page is full-bleed now: at the width the handoff was + drawn to it lands on the 24px the drawing uses, and it grows a little rather than leaving a + wide monitor's chrome pinned to the very edge of the glass. + */ + --gutter: clamp(14px, 1.6vw, 28px); + + --radius: 9px; /* controls, chips, rows, nav items */ + --radius-lg: 14px; /* cards and panels — the things controls sit in */ + --radius-pill: 99px; /* pills and badges */ + + --ease: cubic-bezier(0.4, 0, 0.2, 1); + --dur: 0.14s; --bg: #0f1113; --surface: #16191c; --raised: #1d2125; --recessed: #0b0d0f; + + /* + Two hairline steps, as the handoff's tokens carry them: --line is a control's own edge and + --line-soft is the rule between two panels. One value did both, so a filter column's edge and + a button's border were the same weight — and the panel rules, which are ten times longer, + read as the loudest thing on the page. + */ --line: #262b30; + --line-soft: #1c2024; --text: #e8eaec; /* values and names */ --dim: #9aa2a9; /* prose and secondary */ --faint: #6b747c; /* provenance, ages, meta */ --accent: #35d29a; /* measured */ + + /* + The accent as *text on an accent tint* — the selected facet row, which paints a 12% wash of + the accent behind accent-coloured words. Every ratio beside a token here is measured against + the page, and a tinted row is not the page: in light, --accent is 5.0:1 on the background it + was tuned for and 3.9:1 on this one, which is the row a reader has just clicked. Dark clears + it either way, so there the two are the same colour. + */ + --accent-ink: var(--accent); --amber: #d8a13a; /* declared / ageing */ --derived: #a394f0; /* our own classification, and nobody else's */ + /* + Refusal, and only refusal. Not a provenance colour — nothing on this site is red because of + what we did or did not measure. It marks one thing: a facet value the reader has excluded, + where the handoff draws a danger tint, a danger ring and a struck-through label. A state the + reader put the control into, never a state of the data. + */ + --danger: #f4574c; + + /* The card's own elevation, and the one glow the accent is allowed. */ + --shadow: 0 1px 0 rgb(255 255 255 / 3%) inset, 0 4px 16px rgb(0 0 0 / 40%); + --glow: 0 0 8px color-mix(in srgb, var(--accent) 55%, transparent); + --sans: "Hanken Grotesk", ui-sans-serif, system-ui, -apple-system, sans-serif; --mono: "Cascadia Mono", ui-monospace, "SFMono-Regular", Consolas, monospace; --kick: "JetBrains Mono", var(--mono); @@ -60,38 +111,69 @@ data-theme wins over the system in both directions, so a toggle can be added without moving any of this. */ +/* + ── the light theme, and why none of it is white ─────────────────────────── + The page surface was #ffffff, and once the card went full-bleed that stopped being a panel and + became the whole window. A large field of pure white glares: the halation it produces around dark + text is worst for readers with astigmatism and is one of the things dyslexic readers most often + ask to be turned down, and 21:1 body contrast is past the point where more helps and into the + point where it tires. Nobody's guideline forbids it — WCAG has a floor and no ceiling — which is + why every design system that has thought about it lands in the same place by convention instead: + GitHub Primer's canvas.subtle #f6f8fa, IBM Carbon's #f4f4f4 background under white layers, + Material 3's tinted surfaces, Solarized Light's cream. All of them are off-white. + + So the ramp is a soft cool grey and the near-white is reserved for the things that are supposed to + sit *above* it — tiles, the hover plane. That also restores a distinction the light theme had lost + entirely: --surface and --raised were both #ffffff, so a card lifted off the page by a step in the + dark theme was lifted by nothing at all here, and its border was doing all the work. + + Text is a near-black with the ramp's own hue rather than #000, at ~15:1 — comfortably past the + 4.5:1 floor and short of the glare. Every step below was measured against the new surface, and + --faint moved because it did not clear 4.5:1 against the old one: it carries ages and provenance + at 12px, which is exactly the small low-contrast text the handoff asked to have checked. +*/ @media (prefers-color-scheme: light) { :root { - --bg: #f7f8f9; - --surface: #ffffff; - --raised: #ffffff; - --recessed: #eef0f2; - --line: #dfe3e6; - - --text: #14181b; - --dim: #4d565d; - --faint: #79838a; - - --accent: #04795b; /* same hue, dropped in value to clear 4.5:1 on white */ - --amber: #8a5d00; - --derived: #5b46c4; + --bg: #eef1f2; /* behind the page; barely visible now the shell is full-bleed */ + --surface: #f4f6f7; /* the page itself */ + --raised: #fbfcfc; /* tiles, hover planes — above the page, never below it */ + --recessed: #e9edee; /* the bar, the filter column */ + --line: #d3d9dc; + --line-soft: #e2e7e9; + + --text: #1a1f23; /* 15.3:1 — past the floor, short of the glare */ + --dim: #4d585f; /* 6.7:1 */ + --faint: #646f76; /* 4.8:1 — was 3.5:1 and carried 12px ages */ + + --accent: #04795b; /* same hue, dropped in value: 5.0:1 */ + --accent-ink: #036349; /* 5.3:1 on the accent's own 12% tint, where --accent is 3.9:1 */ + --amber: #7d5400; /* 6.2:1 */ + --derived: #5240b8; /* 7.0:1 */ + --danger: #a8261e; /* 6.5:1 */ + + --shadow: 0 1px 2px rgb(16 24 32 / 4%), 0 4px 16px rgb(16 24 32 / 5%); } } :root[data-theme="light"] { - --bg: #f7f8f9; - --surface: #ffffff; - --raised: #ffffff; - --recessed: #eef0f2; - --line: #dfe3e6; + --bg: #eef1f2; + --surface: #f4f6f7; + --raised: #fbfcfc; + --recessed: #e9edee; + --line: #d3d9dc; + --line-soft: #e2e7e9; - --text: #14181b; - --dim: #4d565d; - --faint: #79838a; + --text: #1a1f23; + --dim: #4d585f; + --faint: #646f76; --accent: #04795b; - --amber: #8a5d00; - --derived: #5b46c4; + --accent-ink: #036349; + --amber: #7d5400; + --derived: #5240b8; + --danger: #a8261e; + + --shadow: 0 1px 2px rgb(16 24 32 / 4%), 0 4px 16px rgb(16 24 32 / 5%); } :root[data-theme="dark"] { @@ -100,19 +182,29 @@ --raised: #1d2125; --recessed: #0b0d0f; --line: #262b30; + --line-soft: #1c2024; --text: #e8eaec; --dim: #9aa2a9; --faint: #6b747c; --accent: #35d29a; + + /* Reset with the rest of them. The light media block sets this unconditionally, so a reader who + pinned dark on a light-preferring system kept the light ink — a dark green on a dark tint, + 2.7:1, which is worse than the value it was added to fix. Every token this block redeclares + is redeclared for that reason; this one was missed. */ + --accent-ink: var(--accent); --amber: #d8a13a; --derived: #a394f0; + --danger: #f4574c; + + --shadow: 0 1px 0 rgb(255 255 255 / 3%) inset, 0 4px 16px rgb(0 0 0 / 40%); } :root[data-density="compact"] { - --cpad: 9px; - --row-pad: 5px; + --cpad: 13px; + --row-pad: 8px; } * { box-sizing: border-box; } @@ -125,16 +217,74 @@ body { text-wrap: pretty; } -main { max-width: 1080px; margin: 0 auto; padding: calc(var(--cpad) * 2) var(--cpad); } +/* + The shell: everything the handoff draws sits in one card — the bar along its top edge, the content + under it — rather than loose on the page. It is the difference between a document and an + instrument, and this site is the second: a directory whose bar, filters and rows are one panel. +*/ +/* + ── the shell ────────────────────────────────────────────────────────────── + Full-bleed. The handoff draws the site as one card floating on a ground, and that is a drawing + convention rather than a product decision — a directory is an instrument you fill the window with, + not a document you frame. So the card's border, radius, shadow and side margins are gone and the + page IS the surface; what the card was actually carrying — one bar along the top, bands separated + by hairlines, every block sharing one left edge — is unchanged and is the part that mattered. + + The radial that used to sit on the body moves here, because the body is no longer visible behind + anything. It is the one thing keeping a full-height dark page from reading as flat black. +*/ +.shell { + min-height: 100vh; + margin: 0; + background: var(--surface) radial-gradient(80% 90% at 78% -20%, + color-mix(in srgb, var(--raised) 45%, var(--surface)) 0%, var(--surface) 100%) fixed; + + /* + Clipped sideways and open downwards. `hidden` on both axes is what a card wants and it + swallowed the nav's own menu: at the width where both groups collapse into a disclosure, the + panel is positioned under the bar and every pixel of it was being clipped away, so the one + control that carries the whole catalogue on a narrow window opened onto nothing. `clip` pairs + with `visible` — `hidden` would coerce it to `auto` and hand the page a scrollbar. + */ + overflow-x: clip; + overflow-y: visible; +} + +/* + The content column's own padding, at the handoff's 30px/24px/26px. A page whose blocks bleed to + the card's edges — the listing's two columns, the game page's hairline-separated bands — cancels + it and pads each block itself, because a full-bleed rule cannot be drawn inside a padded box. +*/ +main { padding: 30px var(--gutter) 40px; } +main:has(> .card-page) { padding: 0; } + +/* + One vertical rhythm for the whole content column, as a gap rather than as margins on each block. + Margins collapse, disagree with each other and leave a dead band wherever a block renders nothing + — which is what the absent crawler strip did to the front page. +*/ +main > .card-page, +.hero { display: flex; flex-direction: column; gap: 22px; } h1 { font-size: 26px; font-weight: 600; letter-spacing: -0.01em; margin: 0 0 6px; } -h2 { font-size: 19px; font-weight: 600; margin: calc(var(--cpad) * 2) 0 var(--cpad); } +h2 { font-size: 20px; font-weight: 600; margin: calc(var(--cpad) * 1.4) 0 10px; } a { color: inherit; } .dim { color: var(--dim); } .faint { color: var(--faint); } .mono { font-family: var(--mono); font-variant-ligatures: none; } +/* Every count and every age, so the columns scan. */ +.mono, .tabular { font-variant-numeric: tabular-nums; } + +/* + ── the kicker, and why its typography is locale-gated ────────────────────── + Uppercase plus 0.16em tracking is this site's most recognisable label, and it is Latin-specific + twice over (i18n S1). Letter-spacing severs the cursive joins in Arabic, so connected words come + apart into unrelated shapes; Devanagari and Thai lose conjuncts and mark stacks the same way. + text-transform: uppercase is a no-op in CJK, turns ß into SS, and gets Turkish dotted/dotless i + wrong. The class is the semantics; :lang() decides the typography. +*/ .kicker { font: 10px/1 var(--kick); letter-spacing: 0.16em; @@ -142,18 +292,100 @@ a { color: inherit; } color: var(--faint); } -/* ── site chrome ───────────────────────────────────────────────────────── */ +/* Cursive and complex scripts: prominence from weight and size, never from tracking. */ +:is(.kicker, .nav-label, .bar-label, .facet-name, .evidence, .kick):is( + :lang(ar), :lang(he), :lang(fa), :lang(ur), :lang(th), :lang(hi), :lang(mr), :lang(ne)) { + text-transform: none; + letter-spacing: normal; + font-weight: 600; + font-size: 11.5px; + line-height: 1.5; +} + +/* CJK: uppercase does nothing, tracking looks like a fault, and 10px is unreadable. */ +:is(.kicker, .nav-label, .bar-label, .facet-name, .evidence, .kick):is( + :lang(zh), :lang(ja), :lang(ko)) { + text-transform: none; + letter-spacing: 0.02em; + font-size: 12px; +} + +/* ── site chrome ─────────────────────────────────────────────────────────── + A fixed 60px bar that never wraps. It stretches rather than centres, so a nav item is a + full-height box and its current-page marker can land on the bar's own bottom edge. */ header.site { + /* One row is 60px and stays 60px; a bar that has wrapped is as tall as what it holds. */ + min-height: 60px; + row-gap: 4px; + flex: none; border-bottom: 1px solid var(--line); - padding: var(--cpad); + background: var(--recessed); + padding: 0 var(--gutter); display: flex; - gap: var(--cpad); - align-items: baseline; + align-items: stretch; + justify-content: space-between; + gap: 20px; + + /* + A bar spanning the window is chrome, so it stays where chrome belongs. The game page runs to + three and a half thousand pixels and the catalogue is at the top of it; scrolling back up to + reach the nav is a cost the card layout hid by being short. + */ + position: sticky; + top: 0; + z-index: 30; + + /* + Sideways only. `overflow: hidden` is what a fixed-height bar wants — nothing may spill out of + it — but it clipped the nav's own dropdown to nothing at the width where that dropdown IS the + navigation. `clip` on one axis leaves `visible` legal on the other; `hidden` would coerce it. + */ + overflow-x: clip; + overflow-y: visible; } -header.site .mark { font-family: var(--mono); font-weight: 600; text-decoration: none; } -header.site .mark .star { color: var(--accent); } +/* + The bar is one line of chrome and the demo banner above it is a statement about the whole site, so + the banner scrolls away and the bar does not. Where there is no banner the bar is simply at the + top of the document. +*/ +.demo-banner { position: relative; z-index: 31; } + +/* + The bar measures itself. + + Every threshold in the ladder below used to be a pixel count, and every one of them was really a + measurement of *English*: the seven catalogue links are 430px in English and 517px in Japanese, so + 860 was the width at which the English bar stopped fitting and German, Dutch and Japanese spent the + whole 861–1100 band with links running out of their box. 1024 is one of the four widths this design + is reviewed at, and all three failed at it. + + `max-content` is the browser's own answer to "how wide is this text, in this language, in this + font" — so the nav asks for exactly what its links need and `flex-wrap` gives it its own row the + moment the bar cannot. No number to keep in step with a translation, nothing to re-tune when a + string changes, and a language nobody has measured yet is right on arrival. + + What the reader gets in that band is a two-row bar rather than a menu, which is the trade: every + destination stays visible and in the same order, and the row that moves is the one that was being + clipped. Below the collapse width it is still one disclosure, because a menu is the right shape on + a phone whatever the language. +*/ +header.site { flex-wrap: wrap; } +header.site .mark { flex: 0 0 auto; } +header.site .site-nav { flex: 1 1 auto; min-width: max-content; } +header.site nav.account { flex: 0 0 auto; } +header.site form.theme { flex: 0 0 auto; } + +header.site .mark { + display: flex; + align-items: center; + font: 700 17px/1 var(--sans); + letter-spacing: -0.01em; + text-decoration: none; + white-space: nowrap; +} +header.site .mark .star { color: var(--accent); text-shadow: var(--glow); } /* ── provenance chip ─────────────────────────────────────────────────────── Not a box. At rest it is the value, one state glyph and a relative age — no border, no fill, @@ -178,14 +410,43 @@ header.site .mark .star { color: var(--accent); } /* ── capability matrix ─────────────────────────────────────────────────── */ -table.matrix { border-collapse: collapse; width: 100%; } +/* + A framed table, as the handoff draws it: a header row on the recessed surface, hairlines between + the rows, and the caption as a foot band inside the same frame rather than a loose line under it. + A caption that sits outside the border reads as a note about the section; inside it, it reads as + what it is — the table's own summary, with the table still in view. +*/ +table.matrix { + border-collapse: collapse; + width: 100%; + border: 1px solid var(--line-soft); + border-radius: var(--radius); + overflow: hidden; +} table.matrix th, table.matrix td { text-align: left; - padding: var(--row-pad) var(--cpad); - border-bottom: 1px solid var(--line); + /* Its own padding rather than the row token: this table is six rows of two-word states, and a + listing row's 13px by 20px turns each of them into a paragraph. */ + padding: 11px 16px; + border-top: 1px solid var(--line-soft); + white-space: nowrap; +} +table.matrix thead th { + font: 10px/1 var(--kick); letter-spacing: 0.16em; text-transform: uppercase; color: var(--faint); + background: var(--recessed); + border-top: 0; + padding: 10px 16px; } -table.matrix thead th { font: 10px/1 var(--kick); letter-spacing: 0.16em; text-transform: uppercase; color: var(--faint); } +table.matrix tbody tr:first-child th, table.matrix tbody tr:first-child td { border-top: 1px solid var(--line-soft); } table.matrix tr.disagrees { background: color-mix(in srgb, var(--amber) 9%, transparent); } +/* At the foot and inside the frame, where a caption belongs on a table whose heading is directly + above it — and stated once, here, rather than in the heading as well. */ +table.matrix caption.count { + caption-side: bottom; text-align: left; font-size: 12.5px; + padding: 10px 16px; + background: var(--recessed); + border-top: 1px solid var(--line-soft); +} .state-present { color: var(--accent); } .state-absent { color: var(--dim); } .state-unknown { color: var(--faint); } @@ -210,15 +471,30 @@ td.gap { background: transparent; outline: 1px dashed var(--faint); out Foreign colour is quoted, not hosted. Its own fixed black ground and its own locked 16-colour table in both themes, so arbitrary SGR is expected inside and nothing bleeds either way. */ -figure.ansi { margin: 0; border: 1px solid var(--line); background: #000; } +/* + One panel holding the art, the words and the caption — rather than a black box with a kicker strip + welded under it and the words in a separate disclosure below. The frame is the site's own recessed + surface at the controls' radius; the art keeps its fixed black ground inside, because the SGR it + carries is somebody else's and has to sit on the terminal it was drawn for. +*/ +figure.ansi { + margin: 0; + border: 1px solid var(--line-soft); + border-radius: var(--radius); + background: var(--recessed); + padding: 16px 18px; + display: flex; + flex-direction: column; + gap: 12px; + overflow: hidden; +} figure.ansi figcaption { - font: 10px/1 var(--kick); - letter-spacing: 0.16em; - text-transform: uppercase; + font-family: var(--mono); + font-size: 11.5px; color: var(--faint); - padding: 8px var(--cpad); - border-top: 1px solid var(--line); - background: var(--surface); + padding: 0; + background: none; + border: 0; } /* @@ -258,22 +534,259 @@ figure.ansi figcaption { .skip:focus { left: 8px; top: 8px; } -:where(a, button, input, summary):focus-visible { +:where(a, button, input, select, summary):focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } /* ── chrome ────────────────────────────────────────────────────────────── */ -/* Wrapping, because seven catalogue links do not fit a 390px phone on one line and the alternative - is a page that scrolls sideways — which the whole layout is otherwise careful never to do. */ -header.site nav { display: flex; gap: var(--cpad); flex-wrap: wrap; } -header.site nav a { color: var(--dim); text-decoration: none; } +/* + Every item is a full-height flex box, and nothing wraps. The bar's job is to be one line of + chrome; when it stops fitting it degrades through the steps below rather than reflowing. +*/ +header.site nav { display: flex; align-items: stretch; min-width: 0; } +header.site nav a, header.site nav span.nav-item { + display: flex; align-items: center; white-space: nowrap; + color: var(--dim); text-decoration: none; font-size: 13.5px; +} header.site nav a:hover { color: var(--text); text-decoration: underline; text-underline-offset: 3px; } -/* The one link in this bar that leads somewhere only you can go, so it is not dimmed like the - catalogues beside it. */ +/* ── the two groups ──────────────────────────────────────────────────────── + Places to browse, and things to read about the hobby. The label is the kicker face the rest of + the site uses for column heads, and it is aria-hidden: the group carries the same word as its + accessible name, and a screen reader announcing "browse" as a text node and again as a label is + the duplication this pass exists to remove. */ + +header.site .site-nav { display: flex; align-items: stretch; justify-content: center; gap: 14px; } +header.site .nav-group { display: flex; align-items: stretch; gap: 11px; min-width: 0; } +header.site .nav-label { + display: flex; align-items: center; white-space: nowrap; + font: 9px/1 var(--kick); letter-spacing: 0.16em; text-transform: uppercase; color: var(--faint); +} + +/* A hairline between the groups rather than a wider gap: the bar is short and the gap that would + read as a separation is one that reads as a wrap. 18px, centred in the 60px bar. */ +header.site .nav-rule { flex: 0 0 auto; width: 1px; margin: 21px 0; background: var(--line); } + +/* + The page you are on, marked *inside* the item's own box. + + The marker is an inset shadow and NOTHING else — no border, no padding change. Either of those + adds a pixel to the current item's box, and because the bar is a flex line that grows to its + tallest child, the whole row moved down one pixel the moment a marked item was on it. A bar that + shifts when you arrive is a bar you cannot aim at, and it is the defect the handoff names by hand. +*/ +header.site nav a.on { color: var(--text); box-shadow: inset 0 -2px 0 0 var(--accent); } +header.site nav a.on:hover { text-decoration: none; } + +/* The far end of the bar: what to do, rather than where to go. Submit is an action and about is + site meta, so neither is a catalogue and neither sits in a group above. */ +header.site nav.account { display: flex; align-items: center; gap: 12px; } header.site nav.account a { color: var(--text); } +header.site nav.account a.action { + border: 1px solid var(--line); border-radius: var(--radius); padding: 6px 12px; + font-size: 13px; white-space: nowrap; +} +header.site nav.account a.action:hover { border-color: var(--dim); text-decoration: none; } + +/* The accent button: ring and tint together, as the handoff draws it — the fill is what tells it + apart from the bordered one beside it at a glance. */ +header.site nav.account a.action.primary { + border-color: color-mix(in srgb, var(--accent) 40%, transparent); + background: color-mix(in srgb, var(--accent) 12%, transparent); + color: var(--accent); +} + +/* + ── the nav's degradation order (i18n S2) ────────────────────────────────── + Width triggers the steps, never language: a German reader on a wide monitor never leaves step 0 + and an English reader in a narrow window reaches step 2. Each step loses DECORATION, never a + destination — which is the whole point, and why the collapse at the end is a disclosure holding + every link rather than a shorter bar. + + step 1 group labels drop; the divider keeps the grouping + step 2 submit loses its border, becoming a plain link + step 3 both groups collapse into one `menu` disclosure + + Budget every nav string at 1.4x the English width when reviewing a locale. +*/ + +/* + Step 0 to 2 the disclosure is not a disclosure: the summary is hidden and its content forced + visible, so the two groups sit in the bar exactly as if the were not there. Guarded on + ::details-content, because a browser without it cannot be made to show a closed — there + the summary stays and the nav is a menu button at every width, which is a worse bar and a working + one. +*/ +header.site .nav-menu { display: flex; position: relative; align-items: stretch; min-width: 0; } +header.site .nav-menu > summary { + display: flex; align-items: center; gap: 6px; cursor: pointer; + color: var(--dim); font-size: 13.5px; white-space: nowrap; list-style: none; +} +header.site .nav-menu > summary::-webkit-details-marker { display: none; } +header.site .nav-menu > summary:hover { color: var(--text); } +header.site .nav-menu[open] > summary { color: var(--text); } + +/* The panel, as a dropdown. Above the collapse width the same element is the bar's own centre row. */ +header.site .nav-menu .menu-panel { + position: absolute; top: 100%; left: 0; z-index: 20; min-width: 200px; + display: flex; flex-direction: column; gap: 2px; + padding: 8px; margin-top: 1px; + background: var(--raised); border: 1px solid var(--line); + border-radius: var(--radius); box-shadow: var(--shadow); +} +header.site .nav-menu .menu-panel > .nav-group { flex-direction: column; align-items: stretch; gap: 2px; } +header.site .nav-menu .menu-panel a { padding: 6px 10px; border-radius: 6px; } +header.site .nav-menu .menu-panel a:hover { background: var(--surface); text-decoration: none; } + +/* + The current page, marked for a list rather than for a bar — and only while the panel IS a list. + The inset shadow that lands on the bar's bottom edge becomes a full-width rule under one row of a + dropdown, which reads as a text field rather than as "you are here", so in the panel the marker + moves to the leading edge. Scoped to the collapsed width: unscoped it drew a vertical accent bar + and a tinted box down the side of the current item in the open bar. +*/ +@media (max-width: 880px) { + header.site .nav-menu .menu-panel a.on { + box-shadow: inset 2px 0 0 0 var(--accent); + background: color-mix(in srgb, var(--accent) 10%, transparent); + color: var(--accent); + } +} +header.site .nav-menu .menu-panel .nav-label { padding: 6px 8px 2px; } +header.site .nav-menu .menu-panel .nav-rule { display: none; } +header.site .menu-tail { display: flex; flex-direction: column; gap: 2px; } + +@supports selector(::details-content) { + @media (min-width: 881px) { + header.site .nav-menu > summary { display: none; } + + /* + The wrapper has to carry the bar's full height, not its content's. A nav item is a + full-height box so that the current-page marker — an inset shadow, drawn inside the box — + lands on the bar's own bottom edge; with the wrapper sized to its text the marker floated + eighteen pixels above it, which is the underline hanging in mid-bar rather than sitting on + it. `display: flex` is what makes the panel below a stretched flex item. + */ + header.site .nav-menu::details-content { + content-visibility: visible; + block-size: 100%; + display: flex; + align-items: stretch; + } + + /* The panel is the bar's middle row: a static flex line, not a dropdown. */ + header.site .nav-menu .menu-panel { + position: static; min-width: 0; height: 100%; + display: flex; flex-direction: row; align-items: stretch; gap: 14px; + padding: 0; margin: 0; background: none; border: 0; border-radius: 0; box-shadow: none; + } + header.site .nav-menu .menu-panel > .nav-group { flex-direction: row; align-items: stretch; gap: 11px; } + header.site .nav-menu .menu-panel a { padding: 0; border-radius: 0; } + header.site .nav-menu .menu-panel a:hover { background: none; } + header.site .nav-menu .menu-panel .nav-rule { display: block; } + header.site .nav-menu .menu-panel .nav-label { padding: 0; } + + /* Step 4's overflow home, which the bar itself still carries at these widths. */ + header.site .menu-tail { display: none; } + } + + /* + Step 1 — the labels go and the divider stays. The grouping is the fact; the words naming the + groups are a gloss on it, and the rule between them carries the same fact in one pixel. Losing + both at once would leave seven links in a row in no order anybody could name, which is the + state this bar was rebuilt out of. + */ + @media (min-width: 881px) and (max-width: 1180px) { + header.site .nav-label { display: none; } + header.site .nav-menu .menu-panel { gap: 14px; } + header.site .nav-menu .menu-panel > .nav-group { gap: 11px; } + } +} + +@media (max-width: 1000px) { /* step 2 — submit keeps the word, loses the box */ + header.site nav.account a.action:not(.primary) { + border-color: transparent; padding: 6px 0; color: var(--dim); + } + header.site nav.account a.action:not(.primary):hover { color: var(--text); } +} + +@media (max-width: 880px) { /* step 3 — one disclosure, every destination kept */ + header.site .site-nav { justify-content: flex-start; } + + /* + And it stops being the bar's shock absorber. Above this width the nav holds seven links and + `flex: 1 1 auto; min-width: 0` is what lets it give room back to the two ends; below it the + nav is one word and a chevron, and the same rule let flexbox squeeze that word to 33px — the + German bar rendered "Men" with the chevron cut off, on the only control that reaches the other + six pages. The ends are sized by their content and the middle was the only thing that could + yield, so a longer word at either end came out of the one item that must never shrink. + */ + header.site .site-nav { flex: 0 0 auto; } +} + +/* + Step 4 — the right cluster runs out too. `submit` joins the menu, which is where the markup + already carries it; `about` and `sign in` stay, the first because it is two words and the second + because it is the one item at that end that changes what the site will let a reader do. Still no + destination lost. +*/ +@media (max-width: 640px) { + header.site { padding: 0 14px; gap: 12px; } + header.site nav.account a.action:not(.primary) { display: none; } + header.site form.theme { gap: 6px; } +} + +/* + Step 5 — below a small phone the type gives way before anything else does. Nothing is dropped and + nothing moves; the two clusters at the ends are simply set a step smaller, which is the last + decoration the bar has left to spend. +*/ +@media (max-width: 460px) { + header.site { padding: 0 8px; gap: 6px; } + header.site .mark { font-size: 15px; } + header.site form.theme { gap: 4px; } + header.site form.theme button { font-size: 12px; } + header.site nav.account { gap: 6px; } + header.site nav.account a { font-size: 12.5px; } + header.site nav.account a.action { padding: 5px 8px; } +} + +/* + Step 6 — and the bar is measured in a language, not in English. + + Every step above was tuned against a bar reading "menu · about · submit · sign in · auto light + dark", which is the shortest this chrome is ever going to be. The same bar in German is "Menü · + über uns · anmelden · auto hell dunkel" and wanted seventeen more pixels than a 430px phone has; + Dutch was twelve pixels from the same wall. So the last step is a real one rather than a hedge, + and it spends the only thing left that is not a destination: the theme control's three words + become the row's tightest type, and the wordmark gives up a point with them. Nothing moves and + nothing is dropped — the German bar and the English bar hold the same seven pages either side of + it, which is the property the whole ladder exists to keep. +*/ +/* + Step 7 — and on the narrowest phone the bar stops being one line, for whichever language needs it. + + At 360px the German bar wants 412px: "mu*index · Menü ▾ · über uns · anmelden · auto hell dunkel". + Everything above this step spends decoration and the ladder has none left — and the item that fell + off the end was the theme control, clipped past `.shell`'s `overflow-x: clip` and so not merely + cramped but gone. A reader on a 360px phone in German could not change the theme at all. + + No width triggers this and no language is named in it: the bar already wraps on content, so the + theme control takes a second row exactly where it stops fitting. German and Dutch reach it at 360 + and English and Chinese do not, which is the ladder working rather than a rule about German. The + one thing set here is what happens *after* a wrap — the control keeps the trailing edge it has on + one line, so the bar does not appear to change its mind about which end it belongs to. +*/ +header.site form.theme { margin-inline-start: auto; } + +@media (max-width: 480px) { + header.site .mark { font-size: 14px; letter-spacing: 0; } + header.site form.theme { gap: 4px; } + header.site form.theme button { font-size: 11.5px; } + header.site nav.account a { font-size: 12px; } +} /* ── the reader's theme ──────────────────────────────────────────────────── Buttons drawn as the links beside them, because they belong to that row and a row of three real @@ -281,7 +794,10 @@ header.site nav.account a { color: var(--text); } brighter: a state carried by colour alone is a state a greyscale display does not carry, which is the same rule the heatmap's hatch exists for. */ -form.theme { display: flex; align-items: baseline; gap: 8px; margin: 0; } +/* Centred in the bar rather than baseline-aligned to it: the bar stretches its children to 60px, + and a baseline inside a stretched box lands wherever the box's first line happens to be — which + put this cluster sixteen pixels above the buttons beside it. */ +form.theme { display: flex; align-items: center; gap: 8px; margin: 0; } form.theme .kick { font: 10px/1 var(--kick); letter-spacing: 0.16em; @@ -312,19 +828,55 @@ pre.plain { white-space: pre-wrap; font-size: 13px; line-height: 1.6; color: var /* ── home ──────────────────────────────────────────────────────────────── */ -.hero h1 { font-size: 28px; max-width: 24ch; line-height: 1.2; } +.page-flow { display: flex; flex-direction: column; gap: 22px; } + +/* + The heading runs to whatever width it has. It carried a 24ch clamp, which broke "A directory of + the MU* hobby" across two lines at every width including 1440 — a measure is for paragraphs, and + this is one short line whose whole job is to be read at a glance. +*/ +.hero-head { display: flex; flex-direction: column; gap: 8px; } +.hero h1 { font-size: 26px; line-height: 1.2; letter-spacing: -0.01em; margin: 0; } -form.search { display: flex; gap: 8px; max-width: 560px; margin: var(--cpad) 0; } -form.search input { flex: 1; min-width: 0; } +/* The one sentence a stranger needs before they read a count. Dimmed a step so the heading above it + stays the first thing the eye takes, and measured so it never runs past a comfortable line. */ +.hero .lede { font-size: 14.5px; line-height: 1.6; color: var(--dim); max-width: 62ch; margin: 0; } +/* + One field with the search glyph inside it, and the submit beside it at the field's own height. + The two used to be separate boxes at two different sizes, which read as a form rather than as the + one control it is. +*/ +form.search { display: flex; gap: 8px; max-width: 520px; margin: 0; } +.search-field { + flex: 1 1 auto; min-width: 0; + display: flex; align-items: center; gap: 10px; + background: var(--recessed); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 0 14px; +} +.search-field:focus-within { border-color: color-mix(in srgb, var(--accent) 55%, var(--line)); } +.search-glyph { color: var(--accent); font-size: 15px; line-height: 1; } input[type="search"], input[type="text"] { background: var(--recessed); border: 1px solid var(--line); color: var(--text); font: 14px var(--sans); padding: 8px 11px; - border-radius: 6px; + border-radius: var(--radius); +} + +/* After the generic input rule, not before it: the field's own box is the bordered thing here and + the input inside it is only the caret. Ordered the other way round the generic rule won on equal + specificity and drew a second border inside the first. */ +.search-field input[type="search"] { + flex: 1; min-width: 0; + background: none; border: 0; padding: 11px 0; border-radius: 0; + color: var(--text); font: 14px var(--sans); } +.search-field input[type="search"]:focus { outline: none; } +.search-field input[type="search"]::placeholder { color: var(--faint); } button { background: var(--raised); @@ -332,95 +884,268 @@ button { color: var(--text); font: 14px var(--sans); padding: 8px 14px; - border-radius: 6px; + border-radius: var(--radius); cursor: pointer; } -ul.counts { +/* ── the four figures ────────────────────────────────────────────────────── + A label and a value, in a framed tile, at the size a front page's whole claim deserves. They were + one 12px mono line reading like a caption, which is what a count of the hobby is not. */ + +ul.tiles { list-style: none; margin: 0; padding: 0; - display: flex; flex-wrap: wrap; gap: 20px; font-size: 12px; color: var(--dim); + display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 12px; +} +ul.tiles li { + border: 1px solid var(--line-soft); border-radius: var(--radius-lg); + background: var(--surface); box-shadow: var(--shadow); padding: 16px; + display: grid; gap: 8px; align-content: start; +} +ul.tiles a { text-decoration: none; display: grid; gap: 8px; } + +/* + The value stays in the machine face. The drawing sets it in the UI sans, and the handoff's own + token rules say the opposite two pages earlier — "mono for anything the machine said: counts, + versions, ages … the one typographic rule carrying meaning". A count of the hobby is the purest + case of a machine-said value on this site, so the prose wins over the component. The size is the + drawing's. +*/ +ul.tiles .kicker { color: var(--faint); } +ul.tiles .figure { + font-size: 28px; font-weight: 600; letter-spacing: -0.01em; color: var(--text); + line-height: 1; } -ul.counts strong { color: var(--text); font-weight: 400; } +ul.tiles .figure.state-present { color: var(--accent); } /* The crawler strip, under the counts it is the provenance of. A step smaller and dimmer than they are, because it is about the instrument rather than about the hobby — a reader who never notices it has lost nothing, and one who comes looking for it knows where to look. */ +/* Hairlines top and bottom, as the handoff frames it: the strip is a band of provenance under the + figures it qualifies, and a band reads as one thing where a loose line reads as a footnote. */ p.crawler { - margin: 10px 0 0; font-size: 11px; + margin: 0; padding: 12px 0; font-size: 12.5px; + display: flex; align-items: center; gap: 10px; + border-top: 1px solid var(--line-soft); border-bottom: 1px solid var(--line-soft); } +p.crawler .state-present { text-shadow: var(--glow); } .feeds { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); - gap: var(--cpad); - margin: calc(var(--cpad) * 2) 0; + gap: 22px; + margin: 0; } +/* A column: its heading, then rows. The heading carried a 33.6px top margin from the global h2 + rule, which put each of the three columns a block lower than the rhythm the page is set on. */ +.feeds .feed { display: flex; flex-direction: column; } +.feeds .feed h2 { margin: 0 0 10px; } +.feeds .feed-lit h2 { color: var(--accent); } +.feeds .feed-dark h2 { color: var(--amber); } + /* ── liveness feed cards ─────────────────────────────────────────────────── One shape, three registers. Neutral, unlit, and — for a return — the one place the site raises its voice, because a game dark for two years answering is what no incumbent can tell you. */ -.feed-card { - border: 1px solid var(--line); - background: var(--recessed); - border-radius: 8px; - padding: 12px 14px; - margin-bottom: 10px; +/* A row: name left, age right, a hairline between. Three columns of boxed cards gave a name and an + age the furniture of a section, and framed things on this site are supposed to mean something — + the connect screen is quoted in a frame because it is somebody else's. */ + +.feed-row { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 12px; + padding: 9px 0; + border-bottom: 1px solid var(--line-soft); } -.feed-card .feed-name { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; margin-top: 6px; } -.feed-card .feed-name a { font-size: 15px; font-weight: 600; text-decoration: none; } -.feed-card .detail { font-size: 11.5px; color: var(--dim); margin: 4px 0 0; } -.feed-card.unlit { opacity: 0.78; } -.feed-card.unlit .feed-name a { color: var(--dim); } +/* The last row of a column closes on the column's own edge rather than on a rule to nothing. */ +.feeds .feed-row:last-child { border-bottom: 0; } -.feed-card.returned { - border-color: color-mix(in srgb, var(--accent) 34%, transparent); - background: color-mix(in srgb, var(--accent) 10%, transparent); - animation: return-glow 1.4s ease-out 1; +.feed-row .feed-name { + min-width: 0; font-size: 14px; color: var(--text); text-decoration: none; +} +.feed-row .feed-name:hover { text-decoration: underline; text-underline-offset: 3px; } +.feed-row .feed-state { + flex: none; display: inline-flex; align-items: baseline; gap: 5px; + font-size: 12.5px; color: var(--faint); +} +.feed-row .feed-state .sep { color: var(--faint); } +.feed-row.unlit .feed-name { color: var(--dim); } + +/* The return is the same row as the other two. The accent is on the word that says it is back and + nowhere else — a tinted card with a glow gave one column furniture the other two did not have, + and the eye cannot run down three lists that are drawn as different kinds of thing. */ +.feed-row.returned .feed-state .state-present { color: var(--accent); } + +/* ── the card's own footer ───────────────────────────────────────────────── + The same row on every page: where else to go, and the text mirror last. A rule above it, because + it is the end of the card and not another block in the column. */ + +.card-footer { + display: flex; flex-wrap: wrap; gap: 16px; + margin: 0; padding-top: 14px; + border-top: 1px solid var(--line-soft); + font-size: 13px; color: var(--dim); +} + +/* On a card page the footer is a band of the card rather than a block in a padded column, so it + pads itself to the same edge every other band uses. */ +.card-page > .card-footer { padding: 14px var(--gutter) 22px; } +.card-footer { align-items: baseline; } +.card-footer a { text-decoration: none; } +.card-footer a:hover { text-decoration: underline; text-underline-offset: 3px; } +.card-footer .sep { color: var(--faint); } + +/* + The language switcher, at the far end of the footer beside the plain-text link — the two controls + that change how the page is read rather than which page it is. Pushed there rather than placed, so + the destinations keep the left edge they had. +*/ +form.locale { display: flex; align-items: center; gap: 10px; margin: 0 0 0 auto; } + +/* The label is the theme control's kicker, because these two sit side by side and are the same kind + of thing: a small named control that changes how the page is read. */ +form.locale .kick { + font: 10px/1 var(--kick); + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--faint); } -/* Motion budget, one of three: once, on arrival, never repeating. */ -@keyframes return-glow { - from { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 55%, transparent); } - to { box-shadow: 0 0 0 14px transparent; } +/* + One box, drawn as the search field is: the control and its submit inside a single hairline rather + than a native dropdown and a wide button beside it. The footer is otherwise text, and two heavy + rectangles in it read as the loudest thing on the page — which a language switcher is not. +*/ +.locale-field { + display: flex; align-items: center; gap: 2px; + background: var(--recessed); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 0 4px 0 11px; +} +.locale-field:focus-within { border-color: color-mix(in srgb, var(--accent) 55%, var(--line)); } + +/* Stripped of the platform's own chrome so it matches the site rather than the OS — a native select + is a different shade, a different radius and a different caret on each of the three, and none of + them is this one. */ +form.locale select { + appearance: none; + background: none; border: 0; border-radius: 0; margin: 0; + padding: 7px 18px 7px 0; + color: var(--text); font: 13px var(--sans); + cursor: pointer; + + /* Sized to the option in force rather than to the longest one in the list. A select is as wide + as its widest child by default, so "English" sat in a box cut for "Русский (CI canary)" and + the caret ended up a hand's width from the word it belongs to. Where this is not supported the + box is merely the old width, which is the behaviour it replaces — nothing is lost by it. */ + field-sizing: content; +} +/* + The ring goes round the box, not round the flat control inside it. Stripping `appearance` takes the + platform's focus ring with it, and the border tint below is a mouse affordance rather than an + indicator a keyboard reader can rely on — 2.4.7 wants something that survives at any contrast. +*/ +form.locale select:focus { outline: none; } +.locale-field:has(select:focus-visible) { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +/* The nav's chevron, not a new glyph. Pulled back over the select's right padding and transparent to + the pointer, so the caret is part of the same click target rather than a dead spot beside it. */ +.locale-caret { + margin-left: -16px; + color: var(--faint); font-size: 11px; line-height: 1; + pointer-events: none; +} + +/* The search's own arrow. A submit is needed because this site runs no script, and this is the + smallest shape that says "commit" in a vocabulary the reader has already met. */ +.locale-go { + flex: none; display: flex; align-items: center; justify-content: center; + width: 22px; height: 22px; padding: 0; + background: none; border: 0; border-radius: 6px; + color: var(--faint); font-size: 13px; cursor: pointer; } +.locale-go:hover { color: var(--accent); background: var(--raised); } + -.feed-card.returned .return-line { font-size: 15px; line-height: 1.5; color: var(--text); margin: 6px 0 0; } /* ── game page ─────────────────────────────────────────────────────────── */ -.game-head { display: flex; gap: var(--cpad); flex-wrap: wrap; align-items: flex-start; } -.game-head .game-title { flex: 1; min-width: 260px; } +/* The header band: padded like every other band on a card page, with a rule under it running the + card's full width. */ +.game-head { + display: flex; gap: 20px; flex-wrap: wrap; + align-items: flex-start; justify-content: space-between; + padding: 24px var(--gutter); + border-bottom: 1px solid var(--line-soft); +} + +.game-identity { display: flex; gap: 16px; align-items: flex-start; min-width: 0; flex: 1 1 340px; } +.game-head .game-title { display: flex; flex-direction: column; gap: 6px; min-width: 0; } +.game-head .game-title h1 { margin: 0; } +.game-head .claim-line { margin: 0; font-size: 13.5px; } .game-head .plate { - width: 68px; height: 68px; flex: none; - border: 1px solid var(--line); border-radius: 8px; + width: 64px; height: 64px; flex: none; + border: 1px solid var(--line-soft); border-radius: 12px; background: var(--recessed); color: var(--dim); display: grid; place-items: center; font-size: 22px; + padding: 8px; box-sizing: border-box; +} + +/* + The count and the probe that produced it, as one block. It was a number and a provenance chip on + one line at body size, which set the fact most readers came for at the size of the metadata + qualifying it. +*/ +.game-figure { + flex: 0 0 auto; display: flex; flex-direction: column; align-items: flex-end; gap: 6px; + text-align: right; } +.game-figure .figure-count { + margin: 0; font-size: 20px; color: var(--accent); + text-shadow: var(--glow); font-variant-numeric: tabular-nums; +} +.game-figure .figure-count .unit { font-size: 15px; } + +/* 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; } +.game-figure .figure-count.unknown { + color: var(--faint); text-shadow: none; font-size: 15px; font-variant-numeric: normal; +} +.game-figure .figure-age { margin: 0; } + +/* One column, and the sections spaced by it rather than by margins that disagree. */ +.game-body { display: flex; flex-direction: column; gap: 28px; padding: 28px var(--gutter) 8px; } +.game-body > section { display: flex; flex-direction: column; gap: 10px; } +.game-body > section > h2 { margin: 0; font-size: 17px; } /* An icon sits in the monogram's place and at its size. `contain` rather than `cover`: a logo that is not square is letterboxed rather than cropped, because cropping somebody's artwork to fit our box is a small edit to a thing they published. The plate's own background shows through where the image does not reach, so a transparent PNG lands on the same recessed square as the monogram. */ .game-head img.plate { object-fit: contain; padding: 4px; } -.game-head .connect { flex: none; min-width: 240px; } -.game-head .lede { font-size: 15px; line-height: 1.65; max-width: 62ch; } -.game-head .tagline { font-size: 16px; } -.game-head .live { font-size: 14px; } +.game-head .lede { font-size: 14.5px; line-height: 1.6; max-width: 62ch; margin: 0; color: var(--dim); } +.game-head .tagline { font-size: 15px; margin: 0; } -/* The count's label, beside the count. Spaced in CSS rather than by a space in the markup, because - the renderer trims whitespace around a conditional — and sized to the line it qualifies. */ -.game-head .live .chip { margin-left: 6px; } -.game-head .live .chip .age { font-size: inherit; } +/* + An address is a line, not a card. Each was a bordered panel the full width of the title column, + which gave a hostname the furniture of the one framed thing on this page — somebody else's output, + quoted. +*/ .game-head .endpoint { - background: var(--raised); border: 1px solid var(--line); border-radius: 6px; - padding: 7px 10px; font-size: 12.5px; margin-bottom: 6px; + display: flex; flex-wrap: wrap; align-items: baseline; gap: 12px; + margin: 0; padding-top: 4px; + font-size: 13.5px; color: var(--text); } -/* Same reason as the chip above, and it shipped as "penultimatemush.com 9500answering here since - Jul 2026": the renderer trims the whitespace between the port and a conditional span, so the gap - has to be a margin rather than a space in the markup. */ -.game-head .endpoint > span { margin-left: 8px; } +.game-head .endpoint .faint { font-size: 12.5px; } +.game-head .endpoint.former .address { color: var(--dim); } .badges { margin: 4px 0 6px; } .badge.claimed { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 34%, transparent); } @@ -433,6 +1158,7 @@ p.crawler { } .archive-plate strong { color: var(--text); font-weight: 500; } +/* Kept for any surface that still wants two columns; the game page is one column now. */ .two-up { display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(0, 0.85fr); gap: calc(var(--cpad) * 2); } /* @@ -441,7 +1167,7 @@ p.crawler { on it was clipped, while .heat-wrap's own overflow-x sat there doing nothing. The only region allowed to scroll sideways is the ANSI frame. */ -.two-up > *, .feeds > *, .row-main > * { min-width: 0; } +.two-up > *, .feeds > *, .row-main > *, .game-body > * { min-width: 0; } .section-head { display: flex; align-items: baseline; justify-content: space-between; gap: var(--cpad); flex-wrap: wrap; } .section-head .count { font-size: 12px; margin: 0; } @@ -556,8 +1282,14 @@ nav.spans .span { padding: 6px 10px; border: 1px solid var(--line); border-radius: 999px; color: var(--dim); text-decoration: none; } -nav.spans .span:hover { border-color: var(--dim); color: var(--text); } -nav.spans .span.on { border-color: var(--accent); color: var(--accent); } +nav.spans a.span:hover { border-color: var(--dim); color: var(--text); } + +/* The pressed one, which is a span rather than a link to the page you are on. Tinted as well as + outlined, because with no hover to distinguish it the border alone was doing all the work. */ +nav.spans .span.on { + border-color: var(--accent); color: var(--accent); + background: color-mix(in srgb, var(--accent) 10%, transparent); +} /* The shape sits under the range and is the smaller question of the two, so it follows immediately rather than being spaced as a second block. */ nav.spans.shapes { margin-top: -4px; } @@ -569,11 +1301,37 @@ details.read-as-text[open] > *:not(summary) { animation: reveal 120ms ease-out; /* Motion budget, three of three. */ @keyframes reveal { from { opacity: 0; } to { opacity: 1; } } -ul.perday, ul.spells { +ul.spells { list-style: none; margin: 6px 0 0; padding: 0; font-family: var(--mono); font-size: 12px; color: var(--dim); } -ul.perday li, ul.spells li { padding: 2px 0; } +ul.spells li { padding: 2px 0; } + +/* The grid's text alternative: seven rows rather than a hundred and sixty-eight cells. Numbers are + tabular so the columns scan, and the caption sits under the table it describes. */ +table.perday { + width: 100%; margin: 6px 0 0; border-collapse: collapse; + font-family: var(--mono); font-size: 12px; color: var(--dim); + caption-side: bottom; +} +table.perday caption { padding: 8px 0 0; color: var(--faint); text-align: left; } +table.perday th, table.perday td { padding: 4px 8px 4px 0; text-align: left; } +table.perday thead th { + font: 10px/1 var(--kick); letter-spacing: 0.16em; text-transform: uppercase; color: var(--faint); + border-bottom: 1px solid var(--line); padding-bottom: 6px; +} +table.perday tbody th { color: var(--text); font-weight: 600; } +table.perday td.num { font-variant-numeric: tabular-nums; } + +/* Not enough measured days to draw a week. A panel rather than a bare line, so the section reads as + answered rather than as a graphic that failed to load. */ +.sparse { + margin: 10px 0 var(--cpad); padding: calc(var(--cpad) * 1.5) var(--cpad); + border: 1px dashed var(--line); border-radius: 8px; + background: var(--recessed); color: var(--dim); text-align: center; +} +.sparse p { margin: 0; } +.sparse .kicker { margin-bottom: 6px; } /* ── availability strip ──────────────────────────────────────────────────── Ninety bars, oldest left. Degraded is a *short* bar and not merely another colour; unreachable @@ -600,19 +1358,50 @@ dl.stats dd { margin: 3px 0 0; font-size: 17px; } /* ── the ANSI quotation frame ──────────────────────────────────────────── */ -figure.ansi .quote { position: relative; background: #000; } -figure.ansi .quote pre { - margin: 0; padding: var(--cpad); - font: 13px/1.5 var(--mono-cjk); font-variant-ligatures: none; - color: #aaaaaa; white-space: pre; overflow-x: auto; +/* Every row, once, in a region that scrolls — a screen twice the height of the frame is scrolled + rather than cropped and offered again below. The cap is about 26 rows, which is what a terminal + showed the player; past that the reader scrolls the same way they would have on connecting. + Focusable, because a scrolling region a keyboard cannot reach is one a keyboard cannot read. */ +figure.ansi .quote { + position: relative; background: #000; + border-radius: 6px; + max-height: 33em; overflow: auto; overscroll-behavior: contain; } -figure.ansi .crop-fade { - position: absolute; left: 0; right: 0; bottom: 0; height: 56px; - background: linear-gradient(to bottom, rgba(0, 0, 0, 0), #000); - pointer-events: none; +figure.ansi .quote:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } +figure.ansi .quote pre { + margin: 0; padding: 14px 16px; + font: 12px/1.4 var(--mono-cjk); font-variant-ligatures: none; + color: #aaaaaa; white-space: pre; } figure.ansi figcaption { display: flex; gap: 8px; flex-wrap: wrap; } -details.read-as-text .ansi { border: 1px solid var(--line); } + +/* + ── the words, in the same figure ────────────────────────────────────────── + The prose lines are what a player actually needs — the address and the connect/create/QUIT + instructions — and they were behind a disclosure outside the frame, which made the block a picture + with a footnote rather than a quotation with a reading. Under a hairline, with the kicker the + handoff names, and open: this is the one text copy of the art, and a text alternative a reader has + to open is one most readers never see. +*/ +.screen-text { + border-top: 1px solid var(--line-soft); + padding-top: 12px; +} +.screen-text > summary { cursor: pointer; list-style: none; } +.screen-text > summary::-webkit-details-marker { display: none; } +.screen-text > summary::after { content: " ▾"; color: var(--faint); letter-spacing: normal; } +.screen-text[open] > summary::after { content: " ▴"; } +.screen-text .kicker { color: var(--accent); } +.screen-text > summary:hover .kicker { color: var(--text); } +.screen-text > pre { margin-top: 8px; } +.screen-text pre { + margin: 0; + font: 12.5px/1.7 var(--mono-cjk); font-variant-ligatures: none; + color: var(--text); + white-space: pre-wrap; + overflow-wrap: anywhere; +} + pre.plain-screen { background: var(--recessed); padding: var(--cpad); overflow-x: auto; font-size: 12px; } .ansi-plate { @@ -637,28 +1426,54 @@ pre.plain-screen { background: var(--recessed); padding: var(--cpad); overflow-x ul.games, ul.archive { list-style: none; margin: 0; padding: 0; } +/* + The row pads itself and the hairline runs the column's full width. It used to hang on a negative + margin so a hover plane could reach past the reading measure — the results column owns its own + padding now, so the row simply is the width it is drawn at. +*/ ul.games > li, ul.archive > li { - border-bottom: 1px solid var(--line); - padding: var(--row-pad) var(--cpad); - - /* Negative margin so the hover plane runs to the reading measure's own edge and the text does - not shift when it appears — a row that moves under the pointer is a row you mis-click. */ - margin: 0 calc(var(--cpad) * -1); + border-bottom: 1px solid var(--line-soft); + padding: var(--row-pad) var(--gutter); + margin: 0; } -ul.games > li.game-row:hover { background: var(--surface); } +ul.games > li.game-row:hover { background: var(--raised); } + +/* The row is a touch target before it is a layout: the game's name is the link, and on a phone a + list of five hundred of them is a list of five hundred things to hit. The handoff's geometry — + 13px by 20px over a 46px floor — is what puts a comfortable target under every name. */ +ul.games > li.game-row { min-height: 46px; padding: var(--row-pad) var(--gutter); } /* Three columns: the game's face, what it is, and what we measured. The plate is what gives a five-hundred-row list a left edge the eye can run down — before it, every row began with a different-length name and the only way to find a game was to read them. `start` rather than `baseline` because a 36px box has no baseline worth aligning a name to. */ +/* Two zones on the row and a third under them: identity on the left, measurement on the right, and + the meta line spanning beneath the name. The handoff's geometry exactly. */ +/* + Three columns and two lines: identity, measurement, freshness across the top, and the machine + facts spanning beneath. The count and the age are siblings on one baseline — they were a nested + two-row grid in the third column, which stacked the age under the count and left neither able to + line up down the page. +*/ .row-main { display: grid; - grid-template-columns: auto minmax(0, 1fr) auto; - gap: 2px var(--cpad); - align-items: start; + grid-template-columns: minmax(0, 1fr) auto auto; + gap: 3px 20px; + align-items: baseline; } +/* + `contents` so the name and the metadata are grid items of the row itself rather than of a box + inside column one — that is what lets the metadata span all three columns while the name occupies + only the first. +*/ +.row-main .row-text { display: contents; } +.row-main .row-head { grid-column: 1; grid-row: 1; min-width: 0; } +.row-main .row-count { grid-column: 2; grid-row: 1; } +.row-main .row-seen { grid-column: 3; grid-row: 1; } +.row-main .meta { grid-column: 1 / -1; grid-row: 2; min-width: 0; } + /* The row's own plate. Same recessed square, same border, same monogram treatment as the game page's — one vocabulary at two sizes, so a reader who has seen the page recognises the row. */ .row-main .plate { @@ -683,9 +1498,9 @@ ul.games .name, ul.archive .name { font-size: 15px; font-weight: 600; text-decor ul.games .name:hover { text-decoration: underline; text-underline-offset: 3px; } ul.archive .name { color: var(--dim); } -/* The majority state of the catalogue, so it is the quiet word rather than the third pill. A badge - every row wears distinguishes no row from any other, and at 700 rows it is chrome. */ -ul.games .unclaimed { font: 10px/1 var(--kick); letter-spacing: 0.14em; text-transform: uppercase; color: var(--faint); } +/* No rule for "unclaimed": it is not on a row any more. The majority state of the catalogue said on + every row is an administrative fact about our records, repeated once per game at a reader looking + for somewhere to play. It stays on the game page, where it arrives with the way to change it. */ .row-text .tagline { margin: 2px 0 0; @@ -699,70 +1514,75 @@ ul.games .unclaimed { font: 10px/1 var(--kick); letter-spacing: 0.14em; text-tra white-space: nowrap; } -ul.games .meta, ul.archive .meta { margin: 3px 0 0; font-size: 11.5px; color: var(--faint); } +ul.games .meta, ul.archive .meta { margin: 3px 0 0; font-size: 12.5px; color: var(--dim); } ul.games .meta .protocol { white-space: nowrap; } +/* The protocols this row did not print. A count rather than a name, and the names are in its title + for a pointer and on the game's page for everybody. */ +ul.games .meta .protocol.more { color: var(--dim); } + /* A provenance chip on a listing or archive row. The chip's own age size is set for body copy, and these contexts are already smaller than that — so the age rides the row's size rather than growing larger than the text it qualifies. Everything else about the chip is unchanged, deliberately: the row, the archive and the game page have to be one vocabulary and not three that resemble each other. `.meta` is the class both lists give that line, so both are covered by naming it once. */ -.meta .chip .age, .row-figure .chip .age { font-size: inherit; } +.meta .chip .age { font-size: inherit; } + +/* ── the two measured columns ────────────────────────────────────────────── + Tabular figures so the counts line up down the page without a table, and right-aligned so the + units place is the edge the eye runs down. + + The count carries no unit and no provenance glyph. "on" after every number was one English word + repeated 515 times — the column is named once, above, which is also one string per locale rather + than five hundred — and where a number came from is a fact you weigh when choosing between two + games rather than while scanning five hundred, so it lives on the game page and on the facet + badges instead. */ -/* The measured column. Tabular figures so the counts line up down the page without a table. */ -.row-figure { +.row-count { margin: 0; + grid-column: 2; text-align: right; - font-size: 12px; white-space: nowrap; + font-size: 15px; font-variant-numeric: tabular-nums; - display: grid; - gap: 2px; - justify-items: end; + color: var(--text); } -/* ── the count, which is the fact most readers came for ──────────────────── - Players on now is a fact like any other and is labelled like any other — the glyph beside it means - measured or declared exactly as it does in every other column, because a second meaning for the - accent dot would cost it the first one. What sets it apart is scale and position, which carry no - meaning to lose: the number is 17px and nothing else in a row is above 15, so it is what the eye - lands on, and it sits at the head of a column that lines up down the page. - - The number's own weight then does the rest of the work. A count with somebody in it is at full - text colour; a measured zero is dimmed one step — still a measurement, still glyphed, still above - the break, and visibly not somewhere to go tonight. An unknown says so in words and is dimmer - again. Three states, three weights, no new colours. */ +/* Somebody is on. The one place a live count is marked as such, and the glow follows the digits + rather than a pip beside them. */ +.row-count.live { color: var(--accent); } -.row-figure .players { color: var(--text); display: block; } -.row-figure .players .count { font-size: 17px; font-weight: 600; letter-spacing: -0.01em; } -.row-figure .players .pip { margin-right: 5px; } +/* A measured zero: dimmed one step, never faint. We got in and nobody was there is a result, and + it is not the same fact as a count we could not read. */ +.row-count.empty { color: var(--dim); } -/* A count of nobody, measured. Dimmed rather than hidden — we got in and nobody was there is a - result, and the row above the break is where it belongs. */ -.row-figure .players.empty .count { color: var(--dim); font-weight: 400; } +/* Not a number at all, so it is not set as one: smaller, faint, and in words. */ +.row-count.unknown { color: var(--faint); font-size: 13.5px; font-variant-numeric: normal; } -/* The one place a live count is marked as such: a soft glow on the pip where somebody is actually on - right now. A text-shadow rather than a ring, because a ring is drawn on the glyph's inline box — - which is a tall narrow rectangle, not the dot — and rendered as a lozenge round a circle. This - follows the glyph itself at any font and any size. - - Static. Five hundred of these animating is a page that cannot be read, and the motion budget is - spent on the "came back" card. It is a reinforcement and never the carrier: the count, the glyph - and the word behind it all say the same thing without it. */ -.row-figure .players.live .pip.state-present { - text-shadow: 0 0 7px color-mix(in srgb, var(--accent) 60%, transparent); +.row-seen { + margin: 0; + grid-column: 3; + text-align: right; + white-space: nowrap; + font-size: 12.5px; + color: var(--faint); + font-variant-numeric: tabular-nums; } -/* The count's own chip rides beside the figure. Spaced in CSS and not by a space in the markup, - because the renderer trims whitespace around a conditional and "219 on" ran into its own glyph. */ -.row-figure .players .chip { margin-left: 6px; } -.row-figure .players .unit { color: var(--faint); } -.row-figure .players.unknown { color: var(--faint); } -.row-figure .seen { color: var(--faint); font-size: 11.5px; } - -/* What a window sort ranked this row on, printed on the row it ranked. Faint and small: it is the - basis for the order rather than a fact the row is about, and the count above it stays the figure. */ -.row-figure .window { color: var(--faint); font-size: 11.5px; } +/* + Claimed: a ring with a tick in it and no visible word. It was a pill the width of "claimed" on the + handful of rows that carry it — furniture for a fact a browsing reader did not come for. The name + is on the element, where a screen reader and a tooltip both reach it. +*/ +.claim-ring { + display: inline-flex; align-items: center; justify-content: center; + width: 16px; height: 16px; flex: none; + border-radius: 99px; + border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent); + background: color-mix(in srgb, var(--accent) 12%, transparent); + color: var(--accent); + font: 10px/1 var(--mono); +} /* Archived: dimmed one step, and that is the whole treatment. No red, no strikethrough, no "dead" — the entry is a library record for a periodical that ceased publication. */ @@ -788,26 +1608,84 @@ li.unranked-break { li.unranked-break:hover { background: var(--surface); } -dl.archive-facts { margin: 0; font-size: 12px; min-width: 220px; } +/* + The archive row is two zones, not the listing's three — it has no count column, and its facts + panel is a definition list rather than two cells. It shares .row-main with the listing for the + gap and the baseline, so it names its own columns rather than inheriting a third it has nothing + to put in. +*/ +ul.archive .row-main { grid-template-columns: minmax(0, 1fr) auto; } + +dl.archive-facts { margin: 0; font-size: 12px; min-width: 200px; } dl.archive-facts dd { margin: 0 0 6px; color: var(--dim); } +/* Below the width where a 200px facts panel and a game's metadata both fit, they stack — the panel + was holding its minimum and clipping the name and codebase beside it. */ +@media (max-width: 620px) { + ul.archive .row-main { grid-template-columns: minmax(0, 1fr); } + dl.archive-facts { min-width: 0; } +} + /* ── the listing's own header ────────────────────────────────────────────── */ -.listing-head { margin-bottom: var(--cpad); } -.listing-head p { margin: 0; max-width: 62ch; } +/* The card's own header band: padded like the drawing, with a rule under it running the full + width of the card rather than stopping at a content measure. */ +.listing-head { + margin: 0; + padding: 22px var(--gutter) 18px; + border-bottom: 1px solid var(--line-soft); + display: flex; flex-direction: column; gap: 7px; +} +.listing-head h1 { margin: 0; } +.listing-head p { margin: 0; max-width: 62ch; font-size: 14px; } + +/* ── the listing's toolbar ───────────────────────────────────────────────── + What order this is in on the left, the control that changes it and the two column names on the + right, over the columns they name. */ .listing-bar { display: flex; align-items: baseline; - gap: var(--cpad); + justify-content: space-between; + gap: 12px 20px; flex-wrap: wrap; - margin: calc(var(--cpad) * 1.5) 0 4px; + margin: 0; + padding: 11px var(--gutter); + border-bottom: 1px solid var(--line-soft); + font-size: 13px; + color: var(--dim); } -.listing-bar .result-count { margin: 0; font-size: 13px; } -.listing-bar .result-count strong { font-size: 19px; font-weight: 600; } .listing-bar .listing-order { margin: 0; } -.listing-bar .listing-order a { color: var(--faint); } +.listing-bar .listing-order .order-name { color: var(--text); } +.listing-bar .listing-order .sep { margin: 0 6px; color: var(--faint); } +.listing-bar .listing-order a { color: var(--dim); } + +.listing-tools { display: flex; align-items: baseline; gap: 20px; } +.listing-tools .column-head { margin: 0; } + +/* ── a pressed-state toggle group ────────────────────────────────────────── + Not links to the page you are already reading. The one in force is a with aria-current, so + there is no keystroke that lands on a control whose only effect is to re-fetch this page — the + same fix the trend range and shape selectors already carry. */ + +.switch { display: flex; align-items: baseline; gap: 8px; font-size: 12.5px; } +.switch a { color: var(--dim); text-decoration: none; } +.switch a:hover { color: var(--text); text-decoration: underline; text-underline-offset: 3px; } +.switch .on { color: var(--accent); } +/* A rule, not a wider gap: the two halves of the switch are different kinds of order — three that + change which measurement the column shows, and two that only change the stacking. */ +.switch .switch-rule { + width: 1px; height: 13px; align-self: center; margin: 0 2px; background: var(--line); +} + +/* The window under the statistic it belongs to, quieter than the switch that produced it. */ +.switch.windows { + padding: 8px var(--gutter); + border-bottom: 1px solid var(--line-soft); + font-size: 12px; + justify-content: flex-end; +} /* Nothing matched. A plate rather than a paragraph, because an empty listing is a state and not an @@ -829,9 +1707,25 @@ dl.archive-facts dd { margin: 0 0 6px; color: var(--dim); } /* ── one column below 900px; no horizontal scroll except inside the frame ── */ @media (max-width: 900px) { - .two-up, .feeds { grid-template-columns: minmax(0, 1fr); } - .game-head { flex-direction: column; } - header.site { flex-wrap: wrap; } + .feeds { grid-template-columns: minmax(0, 1fr); } + + /* + The theme control is the one thing in the bar that goes nowhere, so it is what the bar gives + up its words for first: three buttons become their initials' worth of room. + + Taken out of the drawing and left in the accessibility tree, because the form names itself + with `aria-labelledby="theme-label"` and this is that label. `display: none` removed it from + both, so below 900px a screen reader met three buttons in a group with no name at all — the + one width where the surrounding words that would have explained them are gone too. + */ + header.site form.theme .kick { + position: absolute; + width: 1px; height: 1px; + padding: 0; margin: -1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + } /* All 168 cells at 12px rather than a scrolling grid — the whole week at a glance is the reason @@ -933,7 +1827,22 @@ ul.sole-use-names { ul.sole-use-names li { color: var(--dim); } /* Wide content scrolls inside its own box; the page body never scrolls sideways. */ -.table-wrap { overflow-x: auto; margin: var(--cpad) 0; } +/* + A scroller contains its own absolutely positioned descendants — and until it did, the game page + scrolled sideways. + + `.sr-only` is `position: absolute`, and with no positioned ancestor its containing block was the + initial one: the text a screen reader reads out of the capability matrix was laid out at the + matrix's own width, 463px in German, *outside* this box and outside `.shell`'s `overflow-x: clip`. + So five spans nobody can see pushed the document 59px wider than a 360px phone, and the whole page + scrolled to reveal nothing. Visually-hidden is not the same as out of the layout, and one + `position: relative` is what makes the difference invisible in both directions. +*/ +.table-wrap { position: relative; overflow-x: auto; margin: var(--cpad) 0; } + +/* Inside a section that already spaces its children, the wrapper carries no margin of its own — + and it keeps the table's rounded frame from being clipped square by the scroll box. */ +.game-body .table-wrap { margin: 0; border-radius: var(--radius); } table.protocols, table.ranking { border-collapse: collapse; width: 100%; } table.protocols th, table.protocols td, @@ -981,13 +1890,12 @@ table.ranking tbody th { font-weight: 400; } is the only reason they are there. ═══════════════════════════════════════════════════════════════════════════ */ -form.facet-form { - border: 1px solid var(--line); - border-radius: 10px; - background: var(--surface); - padding: var(--cpad); - margin: 0 0 var(--cpad); -} +/* + No box. It was a panel on a page — border, radius, its own surface — and inside the card it became + a box within a box, fighting the hairline that already separates it from the rows. The column's + own edge is the separation; the form is just its contents. +*/ +form.facet-form { border: 0; background: none; padding: 0; margin: 0; } /* ── the bar ─────────────────────────────────────────────────────────────── */ @@ -995,7 +1903,58 @@ form.facet-form { The archive's own search box is the same control with a legend on it, so it is styled here rather than growing a second look for one question asked on two pages. */ -.filter-bar, fieldset.facets { +/* ── the panel, as one disclosure below 900px ────────────────────────────── + Above that width the summary is hidden and the content is forced open, so the same markup is a + column on a laptop and a disclosure on a phone with no second rendering and no script. + + Guarded on ::details-content: where a browser does not have it, forcing a closed open + from CSS is not possible, so the summary stays visible at every width — a "filters" disclosure at + the top of the column rather than an unreachable panel behind a hidden control. */ + +.facet-collapse { min-width: 0; } +.facet-collapse > summary { + display: flex; align-items: center; gap: 8px; + cursor: pointer; list-style: none; + padding: 9px 8px; margin-bottom: 4px; + border: 1px solid var(--line); border-radius: var(--radius); + font: 10px/1 var(--kick); letter-spacing: 0.16em; text-transform: uppercase; + color: var(--dim); +} +.facet-collapse > summary::-webkit-details-marker { display: none; } +.facet-collapse > summary:hover { color: var(--text); border-color: var(--dim); } +.facet-collapse > summary::after { + content: "▾"; margin-left: auto; font-size: 11px; letter-spacing: normal; +} +.facet-collapse[open] > summary::after { content: "▴"; } +.facet-collapse .summary-count { + font-family: var(--mono); font-size: 11px; letter-spacing: normal; color: var(--accent); +} +.facet-body { display: flex; flex-direction: column; gap: 24px; } + +@supports selector(::details-content) { + @media (min-width: 901px) { + .facet-collapse > summary { display: none; } + .facet-collapse::details-content { content-visibility: visible; block-size: auto; } + } +} + +/* One field, and nothing else — the sort and the two inclusion switches that used to share this row + are a toolbar switch and two facet rows now. */ +.filter-bar { display: block; margin: 0; } +.filter-bar .search-field { padding: 0 8px 0 12px; } +.filter-bar .search-field input[type="search"] { padding: 7px 0; font-size: 13px; } + +/* The submit, inside the field. A 268px column has no room for a second box on that row, and a + submit that exists only as the return key is one a pointer-only reader cannot press. */ +.search-go { + flex: none; display: flex; align-items: center; justify-content: center; + width: 22px; height: 22px; padding: 0; + background: none; border: 0; border-radius: 6px; + color: var(--faint); font-size: 13px; cursor: pointer; +} +.search-go:hover { color: var(--text); background: var(--raised); } + +fieldset.facets { display: flex; gap: 10px; align-items: center; @@ -1004,7 +1963,7 @@ form.facet-form { fieldset.facets { border: 1px solid var(--line); - border-radius: 10px; + border-radius: var(--radius-lg); background: var(--surface); padding: var(--cpad); margin: 0 0 var(--cpad); @@ -1017,7 +1976,8 @@ fieldset.facets legend { color: var(--faint); } -.filter-bar input[type="search"], fieldset.facets input[type="search"] { flex: 1 1 16rem; min-width: 0; } +.filter-bar input[type="search"] { min-width: 0; width: 100%; } +fieldset.facets input[type="search"] { flex: 1 1 16rem; min-width: 0; } .bar-field { display: inline-flex; align-items: center; gap: 6px; } .bar-label { font: 10px/1 var(--kick); letter-spacing: 0.16em; text-transform: uppercase; color: var(--faint); } @@ -1030,39 +1990,125 @@ fieldset.facets legend { auto-fit at a narrow floor, so eight facets pack four to a row on a laptop, two on a tablet and one on a phone without a breakpoint being written for any of them. */ -.facet-grid { +/* ── the listing's two columns ───────────────────────────────────────────── + What you are asking on the left, what came back on the right. `stretch` so the filter column's + own edge runs the full height of the card: aligned to its content it stops under a short panel + and leaves a well beside the rows. */ + +.listing-grid { display: grid; - grid-template-columns: repeat(auto-fit, minmax(178px, 1fr)); - gap: 10px var(--cpad); - margin-top: var(--cpad); + grid-template-columns: minmax(0, 268px) minmax(0, 1fr); + align-items: stretch; + gap: 0; + margin: 0; } -/* The control sits at the bottom of its cell, so a label that wraps grows upward and every select in - the row stays on one line. Left to itself the grid aligns the tops, and one long facet name puts - one control half a row below its neighbours. */ +/* + Placed explicitly, and that is load-bearing: the panel renders TWO siblings into this grid — the + form, and the row of active-filter chips that sits outside it because a chip is a link and not a + control. Left to auto-placement the chips took the second column and the listing dropped to the + first, under a filter column a thousand pixels tall — so applying a filter looked like a page that + had lost its results. +*/ +/* + The filter column is a surface a step below the card, so it reads as a column even where its rows + run out — the border alone left it as an unmarked area of the same fill as the results beside it. +*/ +.listing-grid > .facet-form { + grid-column: 1; + grid-row: 1 / span 99; + background: var(--recessed); + border-right: 1px solid var(--line-soft); + + /* Less 8px than the gutter, because every row inside pads itself by that — which is what puts + a facet label on exactly the same left edge as the wordmark above it. */ + padding: 20px max(6px, calc(var(--gutter) - 8px)); + display: flex; + flex-direction: column; + gap: 24px; +} +.listing-grid > * { grid-column: 2; min-width: 0; } +.listing-side { min-width: 0; } +.listing-grid > .active-filters { padding: 12px var(--gutter) 0; margin: 0; } + +/* + ── the filter column, narrow ────────────────────────────────────────────── + A disclosure above the listing rather than eleven hundred pixels of open panel to scroll past + before the first game. Closed by default and open whenever the reader has actually asked + something, because a filter in force that nothing shows is the defect this control replaces. +*/ +@media (max-width: 900px) { + .listing-grid { grid-template-columns: minmax(0, 1fr); } + .listing-grid > * { grid-column: 1; } + .listing-grid > .facet-form { + grid-column: 1; grid-row: auto; + border-right: 0; + border-bottom: 1px solid var(--line-soft); + } + .listing-grid > .active-filters { padding: 12px var(--gutter) 0; } + + /* Two columns of groups where there is room for them, so the opened panel is a band rather + than a column a reader has to scroll the length of. */ + .facet-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 24px; } +} + +/* In a 268px column the facets stack, one group per row. Twenty-four pixels between groups, which + is the panel's own rhythm and twice the gap between a group's header and its rows. */ +.facet-grid { + display: flex; + flex-direction: column; + gap: 24px; + margin: 0; +} + +/* A facet is a heading, a note and a list of rows. */ .facet { min-width: 0; - display: grid; - grid-template-rows: 1fr auto; - align-content: end; + display: flex; + flex-direction: column; + gap: 9px; + border: 0; + padding: 0; + margin: 0; } +/* + The header block sits at the rows' own left padding, so the group name, the note and every row + label share one left edge — which is the whole reason the rows are padded rather than the panel. +*/ .facet > label, .facet > legend { display: flex; - gap: 6px; - align-items: baseline; - flex-wrap: wrap; - margin-bottom: 3px; - padding: 0; + flex-direction: column; + gap: 3px; + margin: 0; + padding: 0 8px; + width: 100%; + box-sizing: border-box; +} + +.facet .facet-head { + display: flex; align-items: center; justify-content: space-between; gap: 8px; } .facet .facet-name { font: 10px/1.4 var(--kick); - letter-spacing: 0.14em; + letter-spacing: 0.16em; text-transform: uppercase; color: var(--dim); } +/* + The one sentence a group needs at the moment somebody uses it, under its own heading rather than + once at the foot of the panel — the three groups say three different things and the line that + used to serve all of them contradicted the first. +*/ +.facet-note { + margin: 0; + font-size: 12px; + line-height: 1.5; + color: var(--faint); +} + select { background: var(--recessed); border: 1px solid var(--line); @@ -1074,9 +2120,139 @@ select { .facet select { width: 100%; max-width: 100%; } -/* A facet that is doing something says so at rest, and not only in colour — the chip row above the - results names it in words, and the select's own text is the value it is set to. */ -.facet select.chosen { border-color: color-mix(in srgb, var(--accent) 45%, var(--line)); } +/* ── the three-state facet row ───────────────────────────────────────────── + Off, only, anything but — one row per value, and the state legible without opening anything. The + two radios are hidden and the glyph is drawn, but they are real radios: the browser's own group + behaviour is what makes this work with no script, and every state is a word in the accessible + name as well as a shape and a tint. */ + +.facet .rows { display: grid; gap: 2px; margin: 0; } + +/* + The row's geometry, and it is a grid rather than a flex run for one reason: every column has to + land in the same place on every row of every group. As a flex run the widest label in a group + pushed that group's count and exclude columns ten pixels right of every other group's — and past + the panel's own edge, which is what the audit measured as a 253px row inside a 243px box. + + 15px the state glyph + 1fr the value, which is the one cell allowed to give up width + auto the count, right-aligned against the 1fr's slack so every count shares an edge + 18px the exclusion, where a "remove" belongs +*/ +.facet-row { + display: grid; + grid-template-columns: minmax(0, 1fr) 18px; + align-items: center; + gap: 9px; + min-height: 30px; + padding: 0 8px; + border-radius: 9px; + font-size: 13.5px; + color: var(--dim); + border: 1px solid transparent; +} + +/* One ordered scale: no exclusion, so no column for one. */ +.facet-row.single { grid-template-columns: minmax(0, 1fr); } + +.facet-row .pick { + display: grid; + grid-template-columns: 15px minmax(0, 1fr) auto; + align-items: center; + gap: 9px; + min-width: 0; + cursor: pointer; +} +.facet-row .tick { text-align: center; color: var(--faint); font-family: var(--mono); font-size: 12px; } +.facet-row .row-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +/* + `--dim` rather than `--faint`, because the panel is not the page. + + `--faint` was raised to 4.8:1 once already, measured against the page background — and the facet + panel is a more recessed surface than that, where the same token falls to 4.09:1 in dark and + 4.37:1 in light. Both are under the 4.5:1 this site holds itself to, and both are carried by a + 12px number, which is the smallest text in the panel and the one a reader most needs to read. + Raising the token instead would have moved every age and provenance line on the site to fix a + contrast the page itself does not have. +*/ +.facet-row .count { + font-family: var(--mono); font-size: 12px; font-variant-numeric: tabular-nums; color: var(--dim); +} + +/* The exclusion affordance, at the far edge where a "remove" belongs. Quiet until it is the state + the row is in. */ +.facet-row .drop { + display: block; text-align: center; cursor: pointer; + color: var(--faint); font-family: var(--mono); font-size: 12.5px; +} +.facet-row .drop:hover { color: var(--text); } +/* Links, so a click applies the filter rather than arming a control that waits for a button. */ +.facet-row a { text-decoration: none; color: inherit; border-radius: var(--radius); } + +.facet-row:hover { background: var(--raised); } + +/* + A rung of a scale that this filter leaves empty. It stays — a bounded facet is four named + thresholds a reader picks between, and deleting the empty ones meant narrowing the codebase + silently removed two of activity's four rows and shifted the panel under the reader's pointer. It + is dimmed rather than disabled, because 0 is an answer: "no Evennia game was active this week" is + a fact about the catalogue, and clicking it lands on the listing's own empty state. +*/ +.facet-row.empty { opacity: 0.55; } +.facet-row.empty:hover { opacity: 1; } + +/* Included: the accent as a tint and a ring, and the count goes accent with the label — the number + is the part of the row that says what the filter will return, so it is the part that has to look + chosen. */ +.facet-row.on { + color: var(--accent-ink); + background: color-mix(in srgb, var(--accent) 12%, transparent); + border-color: color-mix(in srgb, var(--accent) 40%, transparent); +} +.facet-row.on .tick, .facet-row.on .count { color: var(--accent-ink); } +.facet-row.on:hover { background: color-mix(in srgb, var(--accent) 16%, transparent); } + +/* + Excluded: the danger tint, ring and strikethrough the handoff draws. + + Red is not a provenance colour here and never becomes one — it marks a state the reader put the + control into, not a state of the data. That is why nothing else on the site is red: the accent + means measured, amber means declared, and this means refused. +*/ +.facet-row.excluded { + color: var(--danger); + background: color-mix(in srgb, var(--danger) 10%, transparent); + border-color: color-mix(in srgb, var(--danger) 35%, transparent); +} +.facet-row.excluded .tick, .facet-row.excluded .count { color: var(--danger); } +.facet-row.excluded .row-name { + text-decoration: line-through; + text-decoration-color: color-mix(in srgb, var(--danger) 50%, transparent); +} +.facet-row.excluded .drop { color: var(--danger); } +.facet-row.excluded:hover { background: color-mix(in srgb, var(--danger) 14%, transparent); } + +/* The radios are hidden, so the focus ring belongs to the row that holds them. */ +.facet-row a:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } + +/* The tail. A summary is a control, so it gets the row's own height and hit area. */ +.facet .facet-tail { margin-top: 2px; } +.facet .facet-tail summary { + cursor: pointer; padding: 4px 8px; font-size: 12px; color: var(--faint); border-radius: 9px; +} +.facet .facet-tail summary:hover { color: var(--text); background: var(--raised); } + +/* Said once, under the groups it applies to. */ +.facet-hint { margin: 12px 0 0; font-size: 11.5px; color: var(--dim); } + +/* The facets a reader goes looking for, rather than the three they arrive with. */ +.facet-more-groups { margin: 0; border-top: 1px solid var(--line-soft); padding-top: 14px; } +.facet-more-groups > summary { + cursor: pointer; font: 10px/1 var(--kick); letter-spacing: 0.16em; text-transform: uppercase; + color: var(--faint); padding: 4px 8px; border-radius: var(--radius); +} +.facet-more-groups > summary:hover { color: var(--text); background: var(--raised); } +.facet-more-groups .facet-grid { margin-top: 14px; } /* ── the presence facet ──────────────────────────────────────────────────── */ @@ -1088,11 +2264,15 @@ select { fieldset.facet.presence { border: 0; padding: 0; - margin: var(--cpad) 0 0; + margin: 0; min-width: 0; } -fieldset.facet.presence .checks { display: flex; flex-wrap: wrap; gap: 6px; } +fieldset.facet.presence .checks { + display: flex; flex-wrap: wrap; gap: 6px; min-width: 0; padding: 0 8px; +} +fieldset.facet.presence .check { max-width: 100%; } +fieldset.facet.presence .check .check-name { overflow-wrap: anywhere; min-width: 0; } fieldset.facet.presence .check { display: inline-flex; @@ -1110,9 +2290,6 @@ fieldset.facet.presence .check:hover { border-color: var(--dim); } fieldset.facet.presence .check:has(:checked) { border-color: color-mix(in srgb, var(--accent) 45%, var(--line)); } fieldset.facet.presence .check .count { color: var(--faint); font-variant-numeric: tabular-nums; } -/* Where the unticked-is-not-a-no sentence lives: inside the fieldset it is about, at the moment of - ticking, rather than in a key at the bottom of the panel. */ -fieldset.facet.presence .hint { margin: 6px 0 0; font-size: 11.5px; color: var(--faint); max-width: 62ch; } /* Measured, declared, and the one thing we concluded ourselves — three registers, never the only carrier of the difference, because the chip spells each out in words as well and colour is not a @@ -1137,25 +2314,28 @@ fieldset.facet.presence .hint { margin: 6px 0 0; font-size: 11.5px; color: var(- a title attribute: this is the difference the whole site exists to publish, and it stays in the document whether or not a pointer ever hovers anything. */ +/* + One line and one disclosure. The three-row provenance legend that used to stand here is behind the + disclosure now: every group wears its own evidence badge, which is where the word is actually + needed — before a filter is applied rather than after the panel has been read to the bottom. It + also wrapped its descriptions right-aligned, which is what a flex row does to a text node it did + not expect to break. +*/ .facet-key { display: flex; - flex-wrap: wrap; - gap: 4px 18px; - align-items: baseline; - margin-top: var(--cpad); - padding-top: 10px; - border-top: 1px solid var(--line); - font-size: 11.5px; + flex-direction: column; + gap: 8px; + margin: 0 8px; + padding-top: 14px; + border-top: 1px solid var(--line-soft); + font-size: 12px; color: var(--faint); + text-align: left; } -.facet-key .key-item { display: inline-flex; gap: 5px; align-items: baseline; } -.facet-key .facet-more { margin-left: auto; } -.facet-key summary { cursor: pointer; color: var(--dim); } - -.facet-key .facet-more[open] { flex: 1 0 100%; margin-left: 0; margin-top: 6px; } -.facet-key .facet-more ul { margin: 6px 0 0; padding-left: 1.2em; max-width: 72ch; } -.facet-key .facet-more li { margin-bottom: 5px; color: var(--dim); } +.facet-key summary { cursor: pointer; color: var(--dim); font-size: 11.5px; } +.facet-key .facet-more ul { margin: 8px 0 0; padding-left: 1.2em; } +.facet-key .facet-more li { margin-bottom: 6px; color: var(--dim); line-height: 1.5; } /* ── what the query is currently asking for ──────────────────────────────── */ @@ -1202,18 +2382,16 @@ fieldset.facet.presence .hint { margin: 6px 0 0; font-size: 11.5px; color: var(- /* ── the panel and the rows, narrow ──────────────────────────────────────── */ +/* + Narrow rows keep all three columns and all three stay aligned. They used to collapse into a flex + run under the name, which put the age beside the count on some rows and under it on others — the + same list drawn two ways down one screen. The name gives up width first, which is the one cell + that can spare it, and the two right-hand columns hold their edge. +*/ @media (max-width: 620px) { - /* The bar stacks: a search box sharing a row with a sort and a toggle has room for none of them. */ - .filter-bar input[type="search"] { flex: 1 0 100%; } - .filter-bar button { margin-left: auto; } - - /* The measured column goes under the name rather than fighting it for a width neither has — - under the name and not under the plate, so the row keeps the left edge the plate gives it and - the figure lines up with the text it belongs to. */ - .row-main { grid-template-columns: auto minmax(0, 1fr); } - .row-main .plate { grid-row: span 2; } - .row-figure { text-align: left; justify-items: start; margin-top: 4px; } - .row-figure { display: flex; gap: 12px; flex-wrap: wrap; align-items: baseline; } + .row-main { gap: 3px 12px; } + .row-count { font-size: 14px; } + .row-count.unknown { font-size: 12.5px; } } /* ── reference and orientation pages ─────────────────────────────────────── The hand-written section (spec §9). Two things it has to communicate that no @@ -1375,3 +2553,283 @@ ol.events .faint { margin-right: 8px; } details.resign form { margin-top: 6px; } details.resign input[type="text"] { display: block; margin: 6px 0; max-width: 16ch; } + +/* ── find a game ─────────────────────────────────────────────────────────── + The questions on the left, the count on the right, and the count still on + screen when the last question is answered — which is the whole premise of + this page. Until this block existed the page had no rule of its own at all: + it was the one surface left on user-agent form styling, drawn full-bleed + with 2px groove borders and 5px of padding. */ + +.find-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 310px); + align-items: stretch; + gap: 0; + margin: 0; + + /* + `clip`, never `hidden`. An ancestor with overflow: hidden is a scroll + container, and a sticky element can only travel inside its own — so the + panel would pin for a few hundred pixels and then scroll away for the + rest, leaving the count invisible while questions four to six were + answered. That is the exact failure this page was redrawn to fix, and + `clip` clips without establishing the container. + */ + overflow: clip; +} + +.find-questions { min-width: 0; grid-column: 1; } + +/* ── one question ───────────────────────────────────────────────────────── + 24 above the heading, 14 under it, 24 to the next rule. Symmetric outside, + tighter inside, so the heading binds to the options it labels and the + questions separate from each other. The numbers are on the elements + themselves rather than on a container: a renders on the border + edge and outside the padding box, which is why the drawing's own first + attempt produced 13px above the heading and 2px below it. */ + +.find-q { + padding: 24px var(--gutter); + border-top: 1px solid var(--line-soft); +} + +.find-q-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + margin: 0 0 14px; +} + +/* The question is a heading, so a screen reader can jump between the six and + the page has an outline. h2's own rhythm belongs to prose sections and + would fight the 24/14 above. */ +.find-q-head h2 { font-size: 16px; font-weight: 600; margin: 0; color: var(--text); } + +.find-opts { display: flex; flex-wrap: wrap; gap: 10px 8px; } + +/* + An option chip. 40px is the floor, not the height: a long gloss wraps and + the chip grows rather than clipping it. The row gap is deliberately larger + than the column gap — equal gaps make wrapped rows read as a grid instead of + as rows. +*/ +.find-opt { + display: inline-flex; + align-items: center; + gap: 9px; + min-height: 40px; + padding: 8px 14px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--raised); + color: var(--dim); + font-size: 13.5px; + text-decoration: none; + transition: background var(--dur) var(--ease), border-color var(--dur) var(--ease); +} + +.find-opt .tick { font-family: var(--mono); font-size: 12px; color: var(--faint); } +.find-opt .count { font-size: 12px; color: var(--faint); } +.find-opt:hover { background: color-mix(in srgb, var(--accent) 8%, var(--raised)); } +.find-opt:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } + +/* Chosen: the tint, the ring and the count in the accent, because the number + is the part of the chip that says what answering will return. */ +.find-opt.on { + color: var(--accent); + background: color-mix(in srgb, var(--accent) 12%, transparent); + border-color: color-mix(in srgb, var(--accent) 45%, transparent); +} +.find-opt.on .tick, .find-opt.on .count { color: var(--accent); } + +/* The long tail. A summary is a control, so it gets a control's hit area. */ +.find-tail { margin-top: 10px; } +.find-tail > summary { + cursor: pointer; + display: inline-block; + padding: 6px 10px; + font-size: 12px; + color: var(--faint); + border-radius: var(--radius); +} +.find-tail > summary:hover { color: var(--text); background: var(--raised); } +.find-tail .find-opts { margin-top: 10px; } + +/* ── the name field ─────────────────────────────────────────────────────── + Label above the field, not beside it: beside it the field lost half the + column and a typed name could not be read back. */ + +.find-name { padding: 0 var(--gutter); margin: 0; } +.find-name label { display: block; margin-bottom: 8px; } +.find-name .search-field { max-width: 460px; } + +/* ── the count ──────────────────────────────────────────────────────────── + The outer cell carries the fill and the edge at full row height; the inner + wrapper is what sticks. Splitting the two is what lets the column read as a + column even where the panel is short. */ + +.find-panel { + grid-column: 2; + background: var(--recessed); + border-left: 1px solid var(--line-soft); + min-width: 0; +} + +.find-panel-inner { + position: sticky; + top: 20px; + display: flex; + flex-direction: column; + gap: 14px; + /* 24 at the top so the first line lands on the first question's heading. */ + padding: 24px 22px; +} + +.find-count { display: flex; align-items: baseline; gap: 8px; margin: 0; } + +.find-n { + font-size: 40px; + font-weight: 600; + line-height: 1; + color: var(--accent); + font-variant-numeric: tabular-nums; +} + +.find-noun { font-size: 13.5px; color: var(--dim); } +.find-basis { margin: 0; font-size: 12px; color: var(--faint); } + +.find-chips { display: flex; flex-wrap: wrap; gap: 6px; } + +.find-chip { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 5px 11px; + border: 1px solid var(--line); + border-radius: var(--radius-pill); + background: var(--raised); + color: var(--dim); + font-size: 12px; + text-decoration: none; + max-width: 100%; +} +.find-chip .chip-value { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.find-chip .chip-x { color: var(--faint); } +.find-chip:hover { color: var(--text); border-color: var(--dim); } + +/* + The way out of a combination that returned almost nothing. Quiet on purpose: + no amber, no warning box, no sentence about the result being narrow. The + reader can see the count; this is the one thing they cannot work out. +*/ +.find-loosen { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 8px 12px; + border: 1px dashed var(--line); + border-radius: var(--radius); + color: var(--dim); + font-size: 12.5px; + text-decoration: none; +} +.find-loosen:hover { color: var(--text); border-color: var(--dim); background: var(--raised); } + +/* The one accented control on the page, and it is a link: it navigates to the + listing with this question asked, and nothing here is a form to fire. */ +.find-go { + display: block; + padding: 11px 16px; + border-radius: var(--radius); + border: 1px solid color-mix(in srgb, var(--accent) 55%, transparent); + background: color-mix(in srgb, var(--accent) 14%, transparent); + box-shadow: var(--glow); + color: var(--accent); + font-size: 14px; + font-weight: 600; + text-align: center; + text-decoration: none; +} +.find-go:hover { background: color-mix(in srgb, var(--accent) 22%, transparent); } +.find-go:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } + +.find-clear { margin: 0; font-size: 12px; } +.find-clear a { color: var(--faint); } +.find-clear a:hover { color: var(--text); } + +/* + ── the panel, narrow ───────────────────────────────────────────────────── + The drawing has no responsive treatment and cannot be followed here: at + 430px its own grid crushes the question column to 139px while the panel + keeps all 310. So the panel stacks — above the questions, because the count + is the thing the page exists to show and putting it under six questions + would mean scrolling past every one of them to find out what they returned. + It stays sticky at the top of the viewport, which is the same promise the + side column makes at a width that has room for one. +*/ +@media (max-width: 860px) { + .find-grid { grid-template-columns: minmax(0, 1fr); } + .find-questions, .find-panel { grid-column: 1; } + + /* + Below the questions in the visual order because it is below them in the + DOM, and a column that disagrees with the tab order is a worse answer + than a long page. It sticks to the *bottom* instead of the top, which + keeps the same promise from the other end: the count and the submit are + on screen while the last question is being answered, without a reader + having to find them again afterwards. + */ + .find-panel { + border-left: 0; + border-top: 1px solid var(--line-soft); + position: sticky; + bottom: 0; + } + + /* + Laid across rather than down, because a bar pinned to the bottom of a + phone spends every pixel it takes: short controls share a line, so the + whole panel costs four rows with two answers given rather than seven. + + A wrapping row *in document order*, and that part is not cosmetic. This + was a two-column bar with the call to action pinned right and spanning + three rows, which put it first to the eye and fourth to the keyboard: + tabbing ran chips, loosen, then 114px back up the screen to the button. + Flex-wrap packs the same controls in the order they are written, so the + focus ring only ever travels forwards. + */ + .find-panel-inner { + position: static; + display: flex; + /* The base rule stacks; this is the one place the direction changes, and + leaving it out gives a centred column that reads as a broken card. */ + flex-direction: row; + flex-wrap: wrap; + align-items: center; + gap: 8px 12px; + padding: 12px var(--gutter); + } + + /* + The kicker labels a column, and this is a bar. The sub-line beside the + count says the same thing in more words, and the sentence a screen reader + is given — "2 games match every answer" — is untouched, so nothing is + lost that was only being carried here. + */ + .find-panel-inner > .kicker { display: none; } + + .find-chips { flex: 0 1 auto; } + .find-go { padding: 9px 14px; } + .find-n { font-size: 28px; } + + /* + The bar is pinned over the foot of the viewport, and a browser scrolling a + newly focused option into view does not know it is there — so tabbing down + the last question lands the focus ring behind the panel. `scroll-padding` + is the scriptless way to tell it, and `:has` is what keeps the rule on the + one page that has a bar rather than on every scroll on the site. + */ + html:has(.find-page) { scroll-padding-bottom: 200px; } +} diff --git a/tests/MUI.Catalog.Tests/AdultListingTests.cs b/tests/MUI.Catalog.Tests/AdultListingTests.cs index d8b76a0..0a0ed14 100644 --- a/tests/MUI.Catalog.Tests/AdultListingTests.cs +++ b/tests/MUI.Catalog.Tests/AdultListingTests.cs @@ -33,7 +33,13 @@ private static GameFacetRow Row(string slug, string? genre = null, bool adult = Codebase: "Evennia", Family: null, Genre: genre, - IsAdult: adult); + IsAdult: adult, + + // Every game here answered and was counted. The adult switch is what this suite is + // about, and a row that was also uncounted would put a second reason in front of the + // one being asserted. + Uncounted: false, + Unreachable: false); private static readonly GameFacetRow[] Catalogue = [ diff --git a/tests/MUI.Catalog.Tests/FacetPolarityTests.cs b/tests/MUI.Catalog.Tests/FacetPolarityTests.cs index d1988ce..fe6c4a3 100644 --- a/tests/MUI.Catalog.Tests/FacetPolarityTests.cs +++ b/tests/MUI.Catalog.Tests/FacetPolarityTests.cs @@ -173,7 +173,9 @@ private static GameListing Search(GameFilter filter) => Codebase: game.Codebase, Family: null, Genre: null, - IsAdult: false); + IsAdult: false, + Uncounted: false, + Unreachable: false); private static GameSummary Game(string slug, string? codebase) => new( Guid.NewGuid(), slug, slug, null, LifecycleState.Active, IsClaimed: false, diff --git a/tests/MUI.Catalog.Tests/FacetedSearchTests.cs b/tests/MUI.Catalog.Tests/FacetedSearchTests.cs index ea5e2ed..5a26de2 100644 --- a/tests/MUI.Catalog.Tests/FacetedSearchTests.cs +++ b/tests/MUI.Catalog.Tests/FacetedSearchTests.cs @@ -24,7 +24,9 @@ private static GameFacetRow Row( string? charset = null, bool tls = false, DateTimeOffset? lastReachableAt = null, - string[]? protocols = null) + string[]? protocols = null, + bool uncounted = false, + bool unreachable = false) { var summary = new GameSummary( Guid.NewGuid(), slug, slug, Tagline: null, LifecycleState.Active, IsClaimed: false, @@ -43,7 +45,12 @@ private static GameFacetRow Row( // Adult content has its own suite (AdultListingTests). Every row here declares none, so // these counts are taken over the whole set and are not quietly shaped by that default. - IsAdult: false); + IsAdult: false, + + // Both default to false, so a row is a game we reached and counted unless a test says + // otherwise — the ordinary case, and the one every count in this file is taken over. + uncounted, + unreachable); } private static FacetGroup Group(GameListing listing, string key) => @@ -213,7 +220,12 @@ public async Task NeverReachedIsItsOwnBandAndNotTheOldestOne() null), ActivityBand.Dark, FacetedSearch.LastSeenOf(null, Now), - false, null, null, null, null, null, false), + false, null, null, null, null, null, false, + + // Never once reached, so unreachable — and NOT uncounted, because we hold no + // presence row for it at all. Naming a cause for that is what rule 2 forbids. + Uncounted: false, + Unreachable: true), ]; await Assert.That(FacetedSearch.LastSeenOf(null, Now)).IsEqualTo(LastSeenBand.Never); @@ -371,10 +383,145 @@ public async Task OneGamesCapitalisationDoesNotNameAValue() await Assert.That(codebase.Count).IsEqualTo(3); } + // ── what we could measure ──────────────────────────────────────────────────────────────── + + /// + /// The set these four rows describe, once, so every assertion below is about the same catalogue. + /// + /// + /// The first two are the whole point. zero is a game we got into and counted + /// nobody in, and unreadable is a game we got into and could not count at all. Both sit in + /// — the band cannot tell them apart, which is why these facets + /// exist — and only the second is uncounted. gone is the third state twice over: never + /// reached, and with nothing measured to be uncounted about. + /// + private static GameFacetRow[] Measured() => + [ + Row("zero", band: ActivityBand.Quiet, genre: "Fantasy"), + Row("unreadable", band: ActivityBand.Quiet, genre: "Fantasy", uncounted: true), + Row("busy", genre: "Historical"), + Row("gone", band: ActivityBand.Dark, genre: "Fantasy", + lastReachableAt: Now.AddDays(-90), unreachable: true), + ]; + + [Test] + public async Task AGameMeasuredAtZeroIsNotUncounted() + { + // The failure this facet was written to make impossible. A measured nought is a count — we + // got in and nobody was there (rule 2) — and it shares an activity band with a game whose + // every WHO was past our parser. A filter that returned both under a word meaning the second + // would publish our own parser's limits as somebody's empty game. + var rows = Measured(); + + var uncounted = FacetedSearch.Search(rows, new GameFilter { Uncounted = FacetChoice.Of(FacetTokens.Yes) }); + + await Assert.That(uncounted.Games.Select(g => g.Slug)).IsEquivalentTo(new[] { "unreadable" }); + + // And both are still in the band, which is the band being right rather than the facet being + // redundant with it. + var quiet = FacetedSearch.Search(rows, new GameFilter { Band = ActivityBand.Quiet }); + + await Assert.That(quiet.Games.Select(g => g.Slug)).IsEquivalentTo(new[] { "zero", "unreadable" }); + } + + [Test] + public async Task AGameWeHaveNotMeasuredIsNeitherUncountedNorCounted() + { + // §5.4's third state, which names no cause. `gone` has no presence rows at all, so it is not + // uncounted — the facet says "we tried and could not read", and claiming that of a game we + // never got into would be the same fabrication in the other direction. + var rows = Measured(); + + var uncounted = FacetedSearch.Search(rows, new GameFilter { Uncounted = FacetChoice.Of(FacetTokens.Yes) }); + var unreachable = FacetedSearch.Search(rows, new GameFilter { Unreachable = FacetChoice.Of(FacetTokens.Yes) }); + + await Assert.That(uncounted.Games.Any(g => g.Slug == "gone")).IsFalse(); + await Assert.That(unreachable.Games.Select(g => g.Slug)).IsEquivalentTo(new[] { "gone" }); + } + + [Test] + public async Task TheTwoSwitchesComposeWithEachOtherAndWithAnUnrelatedFacet() + { + // The reason they are two FacetChoices rather than two values of `band`: a reader narrowing + // by genre has to be able to drop both kinds of unmeasured game without spending the one + // selection `band` has. Three questions at once, and all three applied. + var rows = Measured(); + + var listing = FacetedSearch.Search(rows, new GameFilter + { + Genre = FacetChoice.Of("Fantasy"), + Uncounted = FacetChoice.Not(FacetTokens.Yes), + Unreachable = FacetChoice.Not(FacetTokens.Yes), + }); + + // Fantasy, minus the one we could not read and the one we could not reach — leaving the + // measured nought, which is a game we counted and must survive both exclusions. + await Assert.That(listing.Games.Select(g => g.Slug)).IsEquivalentTo(new[] { "zero" }); + } + + [Test] + public async Task ExcludingBothStillLeavesAListingThatExplainsItself() + { + // Hiding is a decision about the listing, so the controls that made it have to stay + // reachable — a selection whose only affordance has vanished is the defect the whole panel + // was rebuilt to remove. Both rows survive at the count their own selection returns. + var rows = Measured(); + + var listing = FacetedSearch.Search(rows, new GameFilter + { + Uncounted = FacetChoice.Not(FacetTokens.Yes), + Unreachable = FacetChoice.Not(FacetTokens.Yes), + }); + + await Assert.That(listing.Games.Select(g => g.Slug)).IsEquivalentTo(new[] { "zero", "busy" }); + + foreach (var key in new[] { FacetKeys.Uncounted, FacetKeys.Unreachable }) + { + var value = Value(listing, key, FacetTokens.Yes); + + await Assert.That(value.State).IsEqualTo(FacetState.Excluded); + await Assert.That(value.Count).IsEqualTo(1); + } + } + + [Test] + public async Task TheCountBesideEachSwitchIsWhatChoosingItReturns() + { + // The panel's own promise, made over the set that has both hard states in it. The generic + // walk above covers this too; this one names the facets, so a change that made the counts + // fall out of a wider denominator fails here with the right words on it. + var rows = Measured(); + var listing = FacetedSearch.Search(rows, new GameFilter()); + + foreach (var key in new[] { FacetKeys.Uncounted, FacetKeys.Unreachable }) + { + var value = Value(listing, key, FacetTokens.Yes); + + await Assert.That(value.Count).IsEqualTo(1); + await Assert.That(FacetedSearch.Search(rows, Choose(key, value.Token)).Games.Count) + .IsEqualTo(value.Count); + } + } + + [Test] + public async Task ASwitchNothingMatchesIsNotDrawnAtAll() + { + // A bounded facet keeps every rung of a scale, because a scale with a rung missing is not + // the same scale. This is not a scale — it is one fact, held or not — so a catalogue we + // could count and reach in full offers no control, which is the honest rendering of a + // measurement with nothing in it. + var listing = FacetedSearch.Search([Row("busy"), Row("also")], new GameFilter()); + + await Assert.That(listing.Facets.Any(f => f.Key == FacetKeys.Uncounted)).IsFalse(); + await Assert.That(listing.Facets.Any(f => f.Key == FacetKeys.Unreachable)).IsFalse(); + } + private static GameFilter Choose(string key, string token) => key switch { FacetKeys.Band => new GameFilter { Band = Band(token) }, FacetKeys.LastSeen => new GameFilter { LastSeen = Seen(token) }, + FacetKeys.Uncounted => new GameFilter { Uncounted = FacetChoice.Parse(token) }, + FacetKeys.Unreachable => new GameFilter { Unreachable = FacetChoice.Parse(token) }, FacetKeys.Protocol => new GameFilter { MeasuredProtocols = [token] }, FacetKeys.Tls => new GameFilter { Tls = true }, FacetKeys.Charset => new GameFilter { Charset = FacetChoice.Parse(token) }, diff --git a/tests/MUI.Catalog.Tests/Persistence/FacetQueriesPostgresTests.cs b/tests/MUI.Catalog.Tests/Persistence/FacetQueriesPostgresTests.cs index 3ebf947..53ea769 100644 --- a/tests/MUI.Catalog.Tests/Persistence/FacetQueriesPostgresTests.cs +++ b/tests/MUI.Catalog.Tests/Persistence/FacetQueriesPostgresTests.cs @@ -280,10 +280,116 @@ await Assert.That(games.Count) } } + [Test] + public async Task AMeasuredZeroIsNotUncountedAndTheBandCannotTellThemApart() + { + // The one this facet exists for, against the reader that actually computes it. + // + // Both games are reachable, both have no count above nought this week, and both therefore + // land in `quiet` — the band is one threshold and cannot say why. `empty` was probed and + // answered nought, which is a measurement of theirs; `unreadable` was probed and produced no + // number, which is a limit of ours. Returning the first under a word meaning the second is + // rules 2, 4 and 5 in one control, and the digest threw the distinction away until now. + await using var db = await PostgresFixture.MigratedAsync(); + var empty = await Seed.GameAsync(db, "empty", "Measured empty", lastReachableAt: Now); + var unreadable = await Seed.GameAsync(db, "unreadable", "Unreadable", lastReachableAt: Now); + var writer = new PresenceWriter(new NpgsqlPresenceStore(db.DataSource)); + + // Three probes each, over the same hours, differing only in what came back. + foreach (var hours in new[] { 1, 20, 60 }) + { + await writer.WriteAsync( + empty, PresenceReading.Counted(0, FieldSource.Who), Now.AddHours(-hours)); + await writer.WriteAsync( + unreadable, + PresenceReading.Unmeasurable(UnmeasurableReason.WhoUnparseable), + Now.AddHours(-hours)); + } + + var queries = QueriesOn(db); + + var quiet = await queries.ListAsync(new GameFilter { Band = ActivityBand.Quiet }); + await Assert.That(quiet.Select(g => g.Slug)).IsEquivalentTo(new[] { "empty", "unreadable" }); + + var uncounted = await queries.ListAsync( + new GameFilter { Uncounted = FacetChoice.Of(FacetTokens.Yes) }); + await Assert.That(uncounted.Select(g => g.Slug)).IsEquivalentTo(new[] { "unreadable" }); + + // And the count each game's row carries agrees: nought is a number, and the other is null. + var listed = await queries.ListAsync(new GameFilter()); + await Assert.That(listed.Single(g => g.Id == empty).PlayersNow).IsEqualTo(0); + await Assert.That(listed.Single(g => g.Id == unreadable).PlayersNow).IsNull(); + } + + [Test] + public async Task AGameWithNoPresenceRowsAtAllIsNotUncounted() + { + // §5.4's third state. A probe that failed writes no presence row (`PresenceWriter`), so a + // game with nothing stored is one we did not measure — and "we tried and could not read it" + // is a cause, which that state may not be given. It is unreachable instead, from the + // availability series, which can tell the two apart. + await using var db = await PostgresFixture.MigratedAsync(); + await Seed.GameAsync(db, "never", "Never answered", lastReachableAt: null); + var probed = await Seed.GameAsync(db, "probed", "Probed", lastReachableAt: Now); + var writer = new PresenceWriter(new NpgsqlPresenceStore(db.DataSource)); + + await writer.WriteAsync( + probed, PresenceReading.Unmeasurable(UnmeasurableReason.WhoLoginPrompt), Now.AddHours(-2)); + + var queries = QueriesOn(db); + + await Assert.That( + (await queries.ListAsync(new GameFilter { Uncounted = FacetChoice.Of(FacetTokens.Yes) })) + .Select(g => g.Slug)) + .IsEquivalentTo(new[] { "probed" }); + + await Assert.That( + (await queries.ListAsync(new GameFilter { Unreachable = FacetChoice.Of(FacetTokens.Yes) })) + .Select(g => g.Slug)) + .IsEquivalentTo(new[] { "never" }); + } + + [Test] + public async Task TheTwoSwitchesNarrowTogetherAndTheCountsSayWhatTheyReturn() + { + // Orthogonal, and orthogonal to the rest of the panel: excluding both leaves the games we + // measured, with the two controls still drawn so the reader can undo what they did. + await using var db = await PostgresFixture.MigratedAsync(); + var counted = await Seed.GameAsync(db, "counted", "Counted", lastReachableAt: Now); + var unreadable = await Seed.GameAsync(db, "unreadable", "Unreadable", lastReachableAt: Now); + await Seed.GameAsync(db, "gone", "Gone", lastReachableAt: Now.AddDays(-90)); + var writer = new PresenceWriter(new NpgsqlPresenceStore(db.DataSource)); + + await writer.WriteAsync(counted, PresenceReading.Counted(4, FieldSource.Who), Now.AddHours(-1)); + await writer.WriteAsync( + unreadable, PresenceReading.Unmeasurable(UnmeasurableReason.I3NoReply), Now.AddHours(-1)); + + var queries = QueriesOn(db); + + var kept = await queries.SearchAsync(new GameFilter + { + Uncounted = FacetChoice.Not(FacetTokens.Yes), + Unreachable = FacetChoice.Not(FacetTokens.Yes), + }); + + await Assert.That(kept.Games.Select(g => g.Slug)).IsEquivalentTo(new[] { "counted" }); + + foreach (var key in new[] { FacetKeys.Uncounted, FacetKeys.Unreachable }) + { + var value = Group(kept, key)!.Values.Single(v => v.Token == FacetTokens.Yes); + + await Assert.That(value.State).IsEqualTo(FacetState.Excluded); + await Assert.That((await queries.ListAsync(Choose(key, value.Token))).Count) + .IsEqualTo(value.Count); + } + } + private static GameFilter Choose(string key, string token) => key switch { FacetKeys.Band => new GameFilter { Band = Band(token) }, FacetKeys.LastSeen => new GameFilter { LastSeen = Seen(token) }, + FacetKeys.Uncounted => new GameFilter { Uncounted = FacetChoice.Parse(token) }, + FacetKeys.Unreachable => new GameFilter { Unreachable = FacetChoice.Parse(token) }, FacetKeys.Protocol => new GameFilter { MeasuredProtocols = [token] }, FacetKeys.Tls => new GameFilter { Tls = true }, FacetKeys.Charset => new GameFilter { Charset = FacetChoice.Parse(token) }, diff --git a/tests/MUI.Web.Tests/AboutPageTests.cs b/tests/MUI.Web.Tests/AboutPageTests.cs index 2f6d462..9e8b1b2 100644 --- a/tests/MUI.Web.Tests/AboutPageTests.cs +++ b/tests/MUI.Web.Tests/AboutPageTests.cs @@ -10,6 +10,7 @@ using MUI.Discovery; using MUI.Web.Api; using MUI.Web.Components; +using MUI.Web.Localization; namespace MUI.Web.Tests; @@ -34,6 +35,21 @@ public class AboutPageTests private static string Plain => PlainText.RenderAbout(Page); + /// + /// The English a message id carries, which is what this page is asserted against. + /// + /// + /// The copy lives in now, so a sentence pasted into a test would be a + /// third copy of it — and the one nothing checks. Asking the bundle asserts the fact this page + /// makes a claim about ("the archive-grace limitation is stated") rather than the spelling of + /// it, and still fails if the id stops reaching the page. Where a claim is a rule + /// rather than a sentence — the refusal to say "no automated opt-out" — the literal stays, + /// because there the wording is the rule. A rule about the site's vocabulary is the one + /// case where a literal on this page is wrong: it belongs over the whole bundle in every locale, + /// which is where puts it. + /// + private static string Says(string id) => Messages.For(Locales.SourceTag, id); + [Test] public async Task EveryDirectoryTheBackfillReadIsCreditedByNameAndByAddress() { @@ -59,13 +75,17 @@ public async Task ASourceWeChoseNotToFetchIsCreditedAndSaidToBeUnread() var mudverse = Page.Sources.Single(s => s.Name == "MudVerse"); await Assert.That(mudverse.State).IsEqualTo(ImportSourceState.Withheld); - await Assert.That(Plain).Contains("not read — awaiting permission"); + await Assert.That(Plain).Contains(Says("about.source.withheld")); + + // And the badge for one we did read is a different string, not a negation of this one: a + // reader meets the difference between "we chose not to" and "we could not" only here. + await Assert.That(Says("about.source.read")).IsNotEqualTo(Says("about.source.withheld")); } [Test] public async Task TheAttributionSaysAddressesOnlyAndSaysWhy() { - await Assert.That(Render.Words(Plain)).Contains("We take addresses. Nothing else."); + await Assert.That(Render.Words(Plain)).Contains(Says("about.sources.addresses.lead")); await Assert.That(Render.Words(Plain)).Contains("No player counts"); await Assert.That(Render.Words(Plain)).Contains("no reachability history"); } @@ -131,12 +151,13 @@ 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(Render.Words(Plain)).Contains("is a placeholder and answers nobody"); + await Assert.That(Render.Words(Plain)).Contains(Says("about.identity.placeholder.plain")); var configured = PlainText.RenderAbout(AboutPage.Build( new ProbeOptions { InfoUrl = "https://example.test/crawler" }, new DatasetLicenceOptions())); - await Assert.That(Render.Words(configured)).DoesNotContain("is a placeholder and answers nobody"); + await Assert.That(Render.Words(configured)) + .DoesNotContain(Says("about.identity.placeholder.plain")); } [Test] @@ -182,7 +203,7 @@ public async Task ThePoliteAndSecurityFactsAboutAProbeSurvive() { var text = Render.Words(Plain); - await Assert.That(text).Contains("CRAWL DELAY wins."); + await Assert.That(text).Contains(Says("about.crawler.delay.lead")); await Assert.That(text).Contains("resolved before anything is dialled"); await Assert.That(text).Contains("globally routable"); } @@ -203,27 +224,87 @@ 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(Says("about.measures.declared.lead")); 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"); } + /// + /// The page explains what reachability is, in whatever language it is being read in. + /// + /// + /// + /// This used to count the English word "uptime" and require exactly two of it. That was a + /// reasonable guard while the page was a C# string and an unreasonable one the moment it became + /// a translation: a Japanese or Chinese rendering of the same two refusals contains the token + /// zero times and would fail, and a German one contains it twice by a coincidence of loanwords + /// rather than because the rule held. Worse, the assertion made the *presence* of the forbidden + /// word the thing under test, so the page's vocabulary rule was guarded by requiring the + /// vocabulary to be broken. + /// + /// + /// The rule survives, split from the spelling. Here: the two refusal ids reach the page, asked + /// of the bundle, so this holds in every locale. And in + /// : the word appears in no other + /// message, in no locale — which is the rule itself ("reachable, never uptime", in copy), + /// checked over the whole bundle rather than inferred from one page's word count. + /// + /// [Test] - public async Task ReachableIsExplainedAndNeverCalledUptimeExceptToRefuseTheWord() + public async Task ReachableIsExplainedInWhateverLanguageThePageIsRead() { var text = Render.Words(Plain); - await Assert.That(text).Contains("Reachable, never uptime."); + await Assert.That(text).Contains(Says("about.measures.reachable.lead")); + await Assert.That(text).Contains(Says("about.measures.reachable.body")); - // 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); + // The substance of the refusal, which is what the rule is for: the measurement is of our + // socket from our host, and an unreachable game may be perfectly alive. await Assert.That(text).Contains("unreachable and perfectly alive"); await Assert.That(text).Contains("nothing here measured it"); } + /// + /// "Reachable, never uptime" — over the bundle, in every locale, rather than over one page. + /// + /// + /// The two about-page ids are the whole exemption: they are the sentences that name the word in + /// order to refuse it, and they are the only place on the site allowed to. Every other id is + /// checked in every bundle a reader can be served, so a translator who reaches for the loanword + /// in a reachability string fails this rather than shipping it — which a rendered-English word + /// count could never have caught. + /// + [Test] + public async Task NoMessageOutsideTheseTwoRefusalsUsesTheWordUptime() + { + string[] refusals = ["about.measures.reachable.lead", "about.measures.reachable.body"]; + + var tags = Locales.All + .Select(locale => locale.Tag) + .Append(Locales.SourceTag) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + await Assert.That(tags).IsNotEmpty(); + + foreach (var tag in tags) + { + foreach (var id in Messages.Ids.Except(refusals, StringComparer.Ordinal)) + { + var pattern = Messages.Pattern(tag, id) ?? string.Empty; + + await Assert.That(pattern.Contains("uptime", StringComparison.OrdinalIgnoreCase)) + .IsFalse() + .Because($"{id} says \"uptime\" in {tag}; the word here is reachable"); + } + } + + // And the exemption is real rather than vacuous: the English refusals do use the word, which + // is what makes them refusals and not silence. + await Assert.That(Says("about.measures.reachable.lead").ToLowerInvariant()).Contains("uptime"); + } + [Test] public async Task TheThingsThisSiteWillNotDoAreStatedRatherThanImplied() { @@ -231,10 +312,10 @@ public async Task TheThingsThisSiteWillNotDoAreStatedRatherThanImplied() // 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."); + await Assert.That(text).Contains(Says("about.never.votes.lead")); + await Assert.That(text).Contains(Says("about.never.forums.lead")); + await Assert.That(text).Contains(Says("about.never.names.lead")); + await Assert.That(text).Contains(Says("about.never.population.lead")); } [Test] @@ -242,7 +323,7 @@ public async Task TheDataLicenceIsPresentedAsUndecidedRatherThanAsSettled() { var text = Render.Words(Plain); - await Assert.That(text).Contains("The code is MIT."); + await Assert.That(text).Contains(Says("about.licence.code.lead")); await Assert.That(text).Contains("licence for the data is an open question"); await Assert.That(text).Contains("not yet taken"); @@ -295,6 +376,58 @@ public async Task TheGraphicalPageCarriesEverySentenceThePlainOneDoes() } } + /// + /// Every sentence on this page comes out of the bundle, and the machine voice does not. + /// + /// + /// + /// The pseudolocale accents and brackets every string that reached a reader through + /// , so anything still legible as English here is a sentence typed into a + /// page — which is what the whole of this page was until it was moved. This was the largest + /// untranslated surface on the site, and the one it would be worst to leave: it is where the + /// site explains what a measurement here proves, to a reader who by definition does not yet + /// trust it. + /// + /// + /// The directories' names and addresses go the other way. They are somebody else's name and + /// somebody else's URL, they are the credit §7.6 owes, and translating either would destroy the + /// acknowledgement rather than localize it — so they are asserted to be unchanged. + /// + /// + [Test] + public async Task EverySentenceComesFromTheBundleAndTheDirectoriesOwnNamesDoNot() + { + var page = AboutPage.Build(new ProbeOptions(), new DatasetLicenceOptions(), "qps-ploc"); + var pseudo = PlainText.RenderAbout(page, "qps-ploc"); + + await Assert.That(pseudo).Contains("⟦"); + + foreach (var section in Page.Sections) + { + await Assert.That(pseudo) + .DoesNotContain(section.Heading) + .Because($"the {section.Id} heading never went through the message pipeline"); + + foreach (var point in section.Points) + { + await Assert.That(pseudo) + .DoesNotContain(point.Lead) + .Because($"a point in {section.Id} is hard-coded English"); + } + } + + // The crawler's own name and the licence a deployment configured are machine voice too, and + // are read off the objects that own them rather than out of the bundle. + await Assert.That(pseudo).Contains(page.Sections.Single(s => s.Id == "crawler").Identity!.Name); + await Assert.That(pseudo).Contains(new DatasetLicenceOptions().LicenceName); + + foreach (var source in Page.Sources) + { + await Assert.That(pseudo).Contains(source.Name); + await Assert.That(pseudo).Contains(source.Url); + } + } + [Test] public async Task TheGraphicalPageIsReachableWithoutScriptingAndSaysHowToReadItPlainly() { diff --git a/tests/MUI.Web.Tests/AccountSurfaceTests.cs b/tests/MUI.Web.Tests/AccountSurfaceTests.cs index 173d0b8..c8df4ef 100644 --- a/tests/MUI.Web.Tests/AccountSurfaceTests.cs +++ b/tests/MUI.Web.Tests/AccountSurfaceTests.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Security.Claims; using System.Text.RegularExpressions; @@ -15,8 +16,10 @@ using MUI.Catalog.Persistence; using MUI.Web; using MUI.Web.Accounts; +using MUI.Web.Components; using MUI.Web.Components.Pages; using MUI.Web.Fixtures; +using MUI.Web.Localization; namespace MUI.Web.Tests; @@ -64,11 +67,123 @@ public async Task WithNoDatabaseThePageSaysAccountsNeedOne() var body = Render.Words(await host.Client.GetStringAsync("/account")); - await Assert.That(body).Contains("Accounts need a database"); - await Assert.That(body).DoesNotContain("Sign in"); - await Assert.That(body).DoesNotContain("give up this claim"); + // The fact, through the bundle, rather than a pasted sentence: the claim is that this page + // says the one thing and offers neither the way in nor a write surface, and it goes on + // being that claim when somebody rewords the copy. + await Assert.That(body).Contains(En("account.noDatabase")); + await Assert.That(body).DoesNotContain(En("account.signInButton")); + await Assert.That(body).DoesNotContain(En("account.resign.summary")); } + /// One message in the source language, as it reads once rendered. + private static string En(string id) => Render.Words(Messages.For(Locales.SourceTag, id)); + + /// + /// A placed sentence in the source language, with its slots filled as the page fills them. + /// + /// + /// hands back runs and markers so the markup can choose an element + /// per slot; a test wants the sentence a reader ends up with. Reassembling it here means these + /// assertions read the same bundle the page does — the alternative is a pasted English string, + /// which is exactly what this pass exists to remove. + /// + private static string EnPlaced( + string id, + IReadOnlyDictionary fills, + params (string Key, object? Value)[] args) => + Render.Words(string.Concat(Sentence + .Place(Locales.SourceTag, id, [.. fills.Keys], args) + .Select(part => part.Slot is null ? part.Text : fills[part.Slot]))); + + /// + /// Every word of the sign-in page comes out of the message bundle. + /// + /// + /// + /// Both branches, because they are two different pages: over the demo fixture it is one sentence + /// saying there is nothing to sign in to, and behind a database it is the whole passkey + /// explanation — and the second was never rendered by any test at all, so it could have drifted + /// into English without anything noticing. + /// + /// + /// The pseudolocale is the instrument: it accents and brackets anything that reached a reader + /// through , so an English sentence surviving here is one typed into the + /// markup. Asserted against the English render rather than against pasted strings, so this keeps + /// holding when the copy is edited. + /// + /// + [Test] + [Arguments(true)] + [Arguments(false)] + public async Task EveryWordOfSigningInComesFromTheBundle(bool withDatabase) + { + var english = Render.Words(await SignInAsync(Locales.SourceTag, withDatabase)); + var pseudo = Render.Words(await SignInAsync("qps-ploc", withDatabase)); + + await Assert.That(pseudo).Contains("⟦"); + + // Which page this is, asserted rather than assumed: a harness that failed to register + // identity would render the one-sentence branch twice and the loop below would pass on a + // page nobody had looked at. + await Assert.That(english.Contains( + Messages.For(Locales.SourceTag, "account.store.heading"), StringComparison.Ordinal)) + .IsEqualTo(withDatabase); + + await Assert.That(english.Contains( + Messages.For(Locales.SourceTag, "account.signIn.noDatabase"), StringComparison.Ordinal)) + .IsEqualTo(!withDatabase); + + // Every sentence of the English page, absent from the same page in another language. + // + // Argument-free ids only, and that costs this sweep nothing: the sign-in page says no + // message that takes one. The `account.` prefix now also covers the owner dashboard, whose + // sentences name a game, a date or a count — and a pattern cannot be rendered at all + // without its arguments, so including them here would throw rather than assert. + foreach (var id in Sayable("account.")) + { + var sentence = Render.Words(Messages.For(Locales.SourceTag, id)); + + if (!english.Contains(sentence, StringComparison.Ordinal)) + { + continue; + } + + await Assert.That(pseudo) + .DoesNotContain(sentence) + .Because($"{id} is rendered as English whatever language the page was asked for"); + } + } + + /// + /// The sign-in page, in one locale, with or without a database behind it. + /// + /// + /// The locale arrives the way the middleware leaves it — in HttpContext.Items — because + /// that is what the page reads. Identity is registered only for the second case, which is + /// exactly the condition the page itself branches on. + /// + private static Task SignInAsync(string tag, bool withDatabase) => + Render.ComponentAsync([], services => + { + services.AddLogging(); + services.AddSingleton(new MUI.Web.Data.CatalogueSource(IsMeasured: withDatabase)); + + var context = new DefaultHttpContext(); + context.Items[LocaleRouting.ItemKey] = + new LocaleContext(Locales.Find(tag)!, FromPath: tag != Locales.SourceTag); + services.AddCascadingValue(_ => context); + + if (!withDatabase) + { + return; + } + + services.AddHttpContextAccessor(); + services.AddAuthentication(); + services.AddSingleton>(new Accounts([], FixtureGameQueries.Now)); + services.AddIdentityCore().AddSignInManager(); + }); + /// A signed-out visitor is offered the way in and nothing else. [Test] public async Task ASignedOutVisitorIsOfferedSignInAndNoWriteSurface() @@ -77,7 +192,7 @@ public async Task ASignedOutVisitorIsOfferedSignInAndNoWriteSurface() await Assert.That(markup).Contains("/account/sign-in"); await Assert.That(markup).DoesNotContain("An account with nothing claimed is told where to start.
mirror. + // + // ── the crawler strip, whose sentence was English around a localized age ────────────── + // The age used to be the only localized part, so a German reader met "crawler live · last + // probe 4m" — one German fragment inside an English line, on the one strip whose whole job + // is to let a reader discount every number above it. The age comes in as an argument + // because the ladder that builds it already localizes, and because a language that puts + // the age first has nowhere to say so if the two are concatenated. + ["crawler.live"] = "crawler live · last probe {age}", + ["crawler.quiet"] = "crawler quiet · last probe {age}", + ["crawler.noProbe"] = "no probe has finished here yet", + + // Three counters, each agreeing with its own number. English inflects none of them and + // several languages inflect all three, which is exactly the case a concatenation cannot be + // translated out of. + ["crawler.cycle.nothingDue"] = "nothing due this cycle", + ["crawler.cycle"] = "{considered, plural, one {# due} other {# due}}" + + " · {answered, plural, one {# answered} other {# answered}}" + + " · {failed, plural, one {# failed} other {# failed}}", + ["crawler.registry"] = "{targets, plural, one {# address in the registry} other {# addresses in the registry}}" + + ", {due, plural, one {# due now} other {# due now}}", + + // ── what this site says about itself where it is not this site ──────────────────────── + // The , the meta description and the Open Graph tags. A German page advertised + // itself in English to a reader, a search engine and every link preview — the three places + // a reader has least ability to check what they were told, and the one surface the demo + // banner cannot follow a link into. + // + // The wordmark is not here. "mu*index" is the site's name, machine voice like a hostname or + // a codebase string, so it arrives as {site} rather than as text a translator could edit. + ["preview.documentTitle"] = "{page} — {site}", + ["preview.site"] = "A directory of the MU* hobby — MUSHes, MUDs, MUCKs, MOOs — where every " + + "fact carries how it was obtained and how old it is.", + ["preview.demo"] = "Demo data — nothing here was measured. {description}", + ["preview.cardAlt"] = "{site} — measured, not asserted", + ["preview.cardAlt.named"] = "{title} on {site}", + + // One id per page, title and description apart: a title is a noun phrase and a description + // is a sentence, and a language that declines the first differently from the second has + // nowhere to stand if they share an id. + ["preview.title.games"] = "Games", + ["preview.title.archive"] = "The archive", + ["preview.title.rankings"] = "Rankings", + ["preview.title.ecosystem"] = "The ecosystem", + ["preview.title.reference"] = "Reference", + ["preview.title.about"] = "About", + ["preview.title.notFound"] = "Not found", + ["preview.title.random"] = "Random game", + ["preview.title.account"] = "Your games", + ["preview.title.claim"] = "Claim {game}", + + ["preview.desc.games"] = "Every MU* we have reached, faceted on what we measured: codebase, " + + "the protocols a server offered in the handshake, TLS, charset, language, and when we " + + "last got in.", + ["preview.desc.archive"] = "The games that went dark, kept. Each keeps its page, history and " + + "URL, is still probed weekly, and returns to the listing on one successful connection.", + ["preview.desc.rankings"] = "Busiest, most reachable, longest running — computed from " + + "measurements only. No votes, stars or ratings anywhere on this site.", + ["preview.desc.ecosystem"] = "Codebase share and protocol adoption across the games we " + + "measure, with what servers offer set beside what they declare. Shares, never totals.", + ["preview.desc.reference"] = "Hand-written pages on the codebases, clients and protocols of " + + "the MU* hobby, cross-linked to counts taken from the crawl.", + ["preview.desc.about"] = "How this catalogue is built: what the crawler does, what it refuses " + + "to do, and how to make it stop.", + ["preview.desc.notFound"] = "No game at this address. Nothing here is ever deleted, so a game " + + "that once lived at this URL still does — check the spelling.", + ["preview.desc.random"] = "One game from the catalogue, chosen at random and never the same " + + "one twice.", + ["preview.desc.account"] = "The listings you have claimed, and what a claim lets you change.", + ["preview.desc.claim"] = "Prove you run this game by publishing a token where only its " + + "operator could put it.", + + // A game's own preview. The name, the host and the port stay machine voice; everything the + // site says *about* them is here. The unknown count is a sentence and never a zero (rule 4), + // and the archived plate says "still probed" because that is §7.5's promise. + ["preview.game.archived"] = "Archived — last reachable {age}, and still probed", + ["preview.game.archived.undated"] = "Archived, and still probed", + ["preview.game.countUnknown"] = "Player count unknown — the game answers, and publishes no " + + "number we can read", + ["preview.game.count"] = "{count, plural, one {# player} other {# players}}, {how} {age}", + + // ── the plain mirror's feed headings and home counts ────────────────────────────────── + // Uppercased at the call site like every other plain heading, so the id carries the words + // and not the casing — a locale whose script has no case gets the words unharmed. + // The empty states are not here: feed.nothingNew and its two siblings already exist and are + // already translated, and the graphical cards and this mirror say the same sentence. + ["feed.plain.newlyDiscovered"] = "Newly discovered", + ["feed.plain.wentDark"] = "Went dark", + ["feed.plain.cameBack"] = "Came back", + + // The four figures the front page tiles carry, as whole sentences rather than a number + // glued to a tile label — the mirror has no tiles to put a label beside. English inflects + // only the first; the other three carry both branches anyway, because a language that + // inflects them has nowhere else to say so. + ["home.plain.known"] = "{count, plural, one {# game known} other {# games known}}", + ["home.plain.connectedNow"] = "{count, plural, one {# connected now (measured)} other {# connected now (measured)}}", + ["home.plain.uncounted"] = "{count, plural, one {# answering, uncounted} other {# answering, uncounted}}", + ["home.plain.archived"] = "{count, plural, one {# archived, still probed} other {# archived, still probed}}", + + // ── THE REFERENCE SECTION'S CHROME ──────────────────────────────────────────────────── + // ═════════════════════════════════════════════════════════════════════════════════════ + // + // Appended as one marked block on purpose: another surface is appending to this file at the + // same time, and a block at the end is a merge that adds rather than one that collides. + // + // **The articles are not here.** The Markdown under content/reference is a set of documents + // somebody owns, translated as documents and by their own route; a bundle holds strings the + // site says, and an article is not one. What is here is the furniture round them — and it + // says nothing about what language an article is in, because that is the content layer's + // fact to state and it is changing. + // + // **Every acronym stays out of the bundle.** MSSP, GMCP, TTYPE, CHARSET and the codebase and + // client names arrive as arguments or sit in the markup. This is the surface most *about* + // machine voice and so the one where a locale is likeliest to reach for a word — and a + // translated TTYPE is destroyed evidence rather than a localized string. + ["reference.title"] = "Reference", + ["reference.lede"] = "What the codebases are, what the clients do, and what the protocols " + + "mean. Written by hand and kept in the repository beside the crawler — not a wiki, and " + + "there is nothing on this page to edit. Every {number} here is a different thing: it " + + "comes from the catalogue and is recomputed each time you load the page.", + + // The emphasised word, placed by the message rather than wrapped round a fragment of it: a + // language that stresses a different word in that clause has somewhere to move the emphasis + // to, and the bundle still holds no markup. Sentences.Place walks it. + ["reference.lede.number"] = "number", + ["reference.plain.lede"] = "Hand-written, single-author, and versioned in git. The prose here " + + "is ours; every number beside it was measured by the crawler and is recomputed on each " + + "request. This is not a wiki, and there is no way to edit it from this page.", + + // The four section headings, and the four kind words beside a page's title. One English word + // does both jobs for three of them — a heading over a list, and a label naming what one page + // is — and inflected languages routinely need different forms, so they are eight ids. + ["reference.section.orientation"] = "Start here", + ["reference.section.codebase"] = "Codebases", + ["reference.section.client"] = "Clients", + ["reference.section.protocol"] = "Protocols", + ["reference.kind.orientation"] = "orientation", + ["reference.kind.codebase"] = "codebase", + ["reference.kind.client"] = "client", + ["reference.kind.protocol"] = "protocol", + + // A gap in a hand-written section is work nobody has done, and the page says so rather than + // implying something was taken away — §7.5 in the one place a reader could reasonably read a + // deletion into a 404. + ["reference.notFound.title"] = "Not found", + ["reference.notFound.body"] = "No reference page here. This section is hand-written, so a gap " + + "is work nobody has done rather than something that was removed — {index}.", + ["reference.notFound.index"] = "see what there is", + + // ── a codebase page's measured half ─────────────────────────────────────────────────── + // The zero is a sentence and never a bare 0 (rule 4): none identified is a statement about + // this crawler's reach, and reads as a statement about the codebase unless it is spelled out. + ["reference.codebase.heading"] = "Games running it", + ["reference.codebase.none"] = "We have not identified any yet. That is a fact about what this " + + "crawler has measured, not about what exists — a game we have not reached, or whose " + + "codebase we could not read, is not counted here.", + ["reference.codebase.listed"] = "{count, plural, one {# listed} other {# listed}}", + ["reference.codebase.archived"] = "{count, plural, one {# archived} other {# archived}}", + + // Rule 1, in three words, and its own id in every register it appears in. It must not soften + // into "verified" or "from our data": what it says is that nothing on this line was taken + // from anybody's self-description. + ["reference.measuredNeverAsserted"] = "measured, never asserted", + ["reference.codebase.note"] = "Counted from the catalogue on this request, over the same " + + "filter the link above carries — so this number and that listing are one query and " + + "cannot drift apart.", + + // The protocol list is machine voice and arrives whole, joined by the markup. A locale + // orders the sentence round it and never touches what is inside. + ["reference.codebase.offered"] = "offered in their handshakes: {protocols}", + + // ── a protocol page's measured half ─────────────────────────────────────────────────── + ["reference.protocol.heading"] = "Measured adoption", + ["reference.protocol.none"] = "Nothing measured yet.", + ["reference.protocol.share"] = "of {listed, plural, one {# listed game} other {# listed games}} — {percent}", + + // **The remainder is not a measurement and the page has to say so.** A locale that shortened + // this to "the rest do not support it" would file our own unread handshakes as a fact about + // somebody's game, which is rule 5 exactly. The sentence names both halves of what the + // remainder mixes, and a translation has to keep them two. + ["reference.protocol.remainder"] = "The games not counted here are not games without the " + + "protocol. A game is counted when we observed its server offering the option in a " + + "handshake; the rest are servers that did not offer it to us and servers whose " + + "handshake we have not read, and we cannot tell you which.", + ["reference.protocol.caption"] = "Games observed offering {protocol} in a handshake, by the " + + "codebase we identified them as running.", + ["reference.protocol.column.codebase"] = "codebase", + ["reference.protocol.column.offered"] = "offered it", + ["reference.protocol.column.identified"] = "identified", + ["reference.seeAlso"] = "See also", + + // ── the client capability matrix, which is documentation rather than measurement ─────── + // Every cell here is somebody's documentation read by us, and the table is a sibling of the + // game pages' measured matrix rather than a copy of it. The caveat carries that difference + // and takes the unknown word as an argument, so the sentence and the cells cannot disagree + // about which word they are quoting. + ["reference.capabilities.heading"] = "Capabilities", + ["reference.capabilities.established"] = "{count, plural, one {# of {total} established from the project's own documentation} other {# of {total} established from the project's own documentation}}", + ["reference.capabilities.caveat"] = "Read off each project's own documentation, not measured " + + "by us — a client has no handshake for us to observe. \"{unknown}\" means we looked and " + + "did not establish it. It never means no.", + ["reference.capabilities.caption"] = "Client capabilities, each read off the project's own " + + "documentation. Unknown means we did not establish it, and never that the client lacks " + + "it.", + ["reference.capabilities.column.documented"] = "documented", + ["reference.capabilities.column.source"] = "source", + ["reference.capabilities.noSource"] = "we did not find one", + + // Three words for three states, and the third is the one that matters. An unknown is what we + // looked for and did not establish; a locale that rendered it as the no beside it would turn + // our reading into the project's absence. They are ids of their own rather than the game + // pages' capability words, which answer a different question — offered on a wire. + ["reference.capability.yes"] = "yes", + ["reference.capability.no"] = "no", + ["reference.capability.unknown"] = "unknown", + + // ── the plain mirror's own wording ──────────────────────────────────────────────────── + // Where the plain surface says the same sentence as the page it mirrors it shares the id + // above; these are the lines it words differently because it has no panel round them. + ["reference.plain.runsOn"] = "Runs on: {platforms}", + ["reference.plain.codebase.heading"] = "Games we have identified as running this codebase", + ["reference.plain.codebase.none"] = "None yet. That is a statement about what we have " + + "measured, not about what exists — a game we have not reached, or whose codebase we " + + "could not read, is not counted here.", + ["reference.plain.codebase.counts"] = "{listed, plural, one {# listed} other {# listed}}, {archived, plural, one {# archived} other {# archived}}", + ["reference.plain.codebase.offered"] = "Measured in their handshakes: {protocols}", + ["reference.plain.codebase.nothingOffered"] = "Nothing was offered in any handshake we have " + + "read from them.", + ["reference.plain.protocol.share"] = "{offering} of {listed, plural, one {# listed game} other {# listed games}} were observed offering it ({percent})", + ["reference.plain.protocol.byCodebase"] = "By codebase, of the games we identified", + ["reference.plain.protocol.row"] = "{offering} of {identified} offered it", + ["reference.plain.capabilities.unknown"] = "{count, plural, one {# of {total} rows is unknown} other {# of {total} rows are unknown}}: we did not find the project's own documentation saying either way. A short honest table beats a long guessed one.", + + // ══ APPENDED BLOCK: the owner dashboard and the claim flow ════════════════════════════ + // The last two page surfaces that were still English whatever language they were asked + // for. Kept as one block at the end of the dictionary so that a parallel append merges + // additively rather than interleaving. + // + // These surfaces address a game's operator in the second person — "your games", "a game + // you run" — and the English says "you" deliberately. A translator should render them in + // whatever second person their language uses for one person being addressed directly; the + // catalogue surfaces are impersonal and these are not. Do not neutralise them. + // + // Nothing here may blur the two provenances. An owner's answer is a DECLARATION stored + // beside what the crawler measured; a claim is a fact about our records and never a + // measurement of the game; an opt-out is honoured and is never a deletion. Where a + // sentence carries that distinction it is called out on the id. + + // ── the dashboard's frame ───────────────────────────────────────────────────────────── + // account.title is its own id rather than preview.title.account's: a heading is the first + // line of a document and a title is a noun phrase in a browser tab, and the languages that + // decline the two differently have nowhere to stand if they share one. + ["account.title"] = "Your games", + ["account.noDatabase"] = "Accounts need a database behind them, and this site is running on " + + "the demo fixture.", + ["account.signInButton"] = "Sign in", + ["account.signedInAs"] = "Signed in as {name}.", + ["account.signOut"] = "Sign out", + + // The empty state's sentence places its own link and its own quoted control, so the word + // order belongs to the language rather than to the markup. {claimControl} names the button + // on a game's page; it is a separate id from game.claim because that one is the control's + // own label and this one is prose quoting it — a language that capitalises or declines a + // quoted control differently has nowhere else to say so. + ["account.empty.body"] = "You have not claimed anything yet. Find your game in {listing} " + + "and press {claimControl} on its page.", + ["account.empty.listing"] = "the listing", + ["account.empty.claimControl"] = "claim this game", + + // ── the one banner a POST comes back with ───────────────────────────────────────────── + // Resigning is not deleting: §7.5 keeps the record, and §8.4 lets the same person prove + // control again. A translation that renders this as "removed" or "deleted" contradicts the + // rule the sentence exists to state. + ["account.resigned.lead"] = "Given up.", + ["account.resigned.body"] = "The record of it is kept, and you can prove control again any " + + "time by publishing a fresh token.", + ["account.saved.lead"] = "Saved.", + + // One sentence per write. The game's name is its own bytes and arrives as an argument, so + // a language that puts the subject elsewhere can move it; {game} is never translated. + // These say what WE did — stopped republishing, stopped dialling, took out of the listing — + // and never that anything about the game was measured or removed. + ["account.saved.thatGame"] = "That game", + ["account.saved.fields"] = "{game}'s page now shows it as owner-declared.", + ["account.saved.screenHidden"] = "We have stopped republishing {game}'s connect screen. The " + + "page says so plainly rather than leaving a hole.", + ["account.saved.screenShown"] = "{game}'s connect screen is on its page again.", + ["account.saved.crawlStopped"] = "We have stopped dialling {game}, on every address we have " + + "for it. Its page keeps everything measured before you asked.", + ["account.saved.crawlResumed"] = "We are dialling {game} again, from its next turn in the " + + "schedule.", + ["account.saved.unlisted"] = "{game} is out of the listing, the rankings and the daily " + + "figure. Its page and every URL it has ever had go on answering.", + ["account.saved.relisted"] = "{game} is back in the listing. One probe that answers is all " + + "it needs to be measured again.", + + // Refused out loud (§8.5). {field} is a registry field name — machine voice, an argument. + // The second sentence is the site's whole claim and may not soften: nobody edits a + // measurement, and that includes us. + ["account.refused.lead"] = "{field} was not changed.", + ["account.refused.tooLong"] = "These are one-line answers; {max} characters is the most we " + + "store.", + ["account.refused.measured"] = "That field is measured. A claim lets you add what MSSP has " + + "no room for; it never lets anybody edit what we observed, and that includes us.", + + // ── a claimed game's block ──────────────────────────────────────────────────────────── + // "verified {date}" is a fact about OUR record — the date we read this owner's token — and + // not a measurement of the game. Two ids rather than a suffix glued on, because the beacon + // note lands in a different place in most languages. + ["account.claimed.heading"] = "Claimed", + ["account.claim.verified"] = "verified {date}", + ["account.claim.verifiedAndSeen"] = "verified {date}, token last seen {seen}", + ["account.claim.mssp"] = "check your MSSP", + ["account.claim.coOwners"] = "{count, plural," + + " one {Also owned by {names} — who verified a token of their own.}" + + " other {Also owned by {names} — each having verified a token of their own.}}", + ["account.coOwner.unnamed"] = "another account", + + // The badge snippet. {unknown} and {archived} are the badge's own bytes rather than words + // to translate: a badge answers one address to everybody, so a German page promising a + // German word would be promising something the image never says. {json} is an acronym and + // machine voice for the same reason every protocol name here is. + ["account.badge.summary"] = "put your player count on your own site", + ["account.badge.carries"] = "The badge carries the count and when we measured it, because a " + + "number with no age is the thing this site exists to replace.", + ["account.badge.states"] = "It says {unknown} rather than nought when we could not count, " + + "and {archived} if the game stops answering.", + ["account.badge.json"] = "There is {json} too, if you would rather draw your own.", + + // ── the audit log ───────────────────────────────────────────────────────────────────── + // The vocabulary is ClaimEventKind's, spelled for a person. beaconMissing reads as an + // observation and never as a warning: a probe not reading the token happens for reasons + // that have nothing to do with the owner, and absence never revokes (§8.4). + ["account.history.summary"] = "history", + ["account.event.issued"] = "token issued", + ["account.event.reissued"] = "token issued again", + ["account.event.verified"] = "verified — we read your token", + ["account.event.beaconSeen"] = "token still published", + ["account.event.beaconMissing"] = "token not read this time", + ["account.event.revoked"] = "claim given up", + ["account.event.expired"] = "token expired unused", + ["account.event.counterClaimed"] = "another account proved control and took the game over", + ["account.event.checkRequested"] = "check requested", + + // Giving up a claim. {word} is the literal an operator types into the box and is never + // translated — a translated confirmation word would be one the form does not accept. + ["account.resign.summary"] = "give up this claim", + ["account.resign.confirm"] = "Type {word} to confirm. Nothing is deleted and you can prove " + + "control again by publishing a fresh token; the game stays claimed if anybody else " + + "owns it.", + ["account.resign.button"] = "Give up {game}", + + // ── waiting on a token, and the passkeys ────────────────────────────────────────────── + ["account.pending.heading"] = "Waiting on a token", + ["account.pending.dates"] = "token issued {issued}, good until {expires}", + ["account.passkeys.heading"] = "Passkeys", + ["account.passkey.unnamed"] = "unnamed", + ["account.passkey.added"] = "added {date}", + ["account.passkey.addedOneDevice"] = "added {date} · on one device only", + ["account.passkey.single"] = "This passkey lives on one device. If you lose it you can " + + "still get back in by publishing a fresh token on your game, but a second passkey is " + + "quicker.", + ["account.passkey.add"] = "Add another passkey", + + // ── claiming a game you run ─────────────────────────────────────────────────────────── + // claim.title is its own id rather than preview.title.claim's, for the reason account.title + // is: a heading and a browser-tab title are not the same noun phrase. + ["claim.noGame"] = "No such game", + ["claim.title"] = "Claim {game}", + ["claim.noDatabase"] = "Claiming needs a database behind it, and this site is running on " + + "the demo fixture.", + ["claim.needAccount"] = "You need an account first. It takes a passkey and a name.", + ["claim.signIn"] = "Sign in or create an account", + ["claim.yourGames"] = "Your games", + + // The game already has owners, and nothing in a probe can tell joining from taking over — + // both publish the identical line. So the choice is made here, in words, before the token + // exists. The owner count agrees inside the message rather than being chosen in C#. + ["claim.hasOwners"] = "{count, plural," + + " one {This game already has an owner who proved control of the server.}" + + " other {This game already has # owners who proved control of the server.}}" + + " You can prove it too — the test is the same either way — but we need to know what " + + "you mean by it, because we cannot tell from the token.", + ["claim.join.button"] = "I run it too — add me as an owner", + ["claim.join.note"] = "Everyone keeps their claim. This is two people running one game.", + ["claim.assume.button"] = "I have taken it over — transfer it to me", + ["claim.assume.note"] = "{count, plural," + + " one {When your token verifies, the existing claim is revoked and the game is yours.}" + + " other {When your token verifies, the existing claims are revoked and the game is yours.}}" + + " They will see why in their own history. Nothing is deleted, and they can prove " + + "control again the same way you are about to.", + + // Verified. Two ids and not one with the channel slotted in: "from the game's MSSP report" + // and "from the connect screen" take different prepositions and different cases in the + // languages that have them, and a single sentence with a {channel} hole has nowhere to + // say so. Both state what WE read, which is a fact about our records. + ["claim.verified.lead"] = "Verified.", + ["claim.verified.viaMssp"] = "We read your token from the game's MSSP report on {date}.", + ["claim.verified.viaScreen"] = "We read your token from the connect screen on {date}.", + ["claim.verified.leaveIt"] = "Leave the token where it is. It doubles as an identity " + + "signal, so this game stays recognisable if it moves host or changes name. Removing " + + "it will not un-claim you.", + + // Publishing the token. Every variable name, file name and prefix below is machine voice + // and arrives as an argument: a translated MSSP variable is one no crawler reads. + ["claim.publish"] = "Publish this token anywhere the game shows it to an anonymous " + + "connection. The next probe picks it up, which proves you can write to that server.", + ["claim.transfer.lead"] = "This is a transfer.", + ["claim.transfer.body"] = "{count, plural," + + " one {When we read this token, the current owner's claim on this game is revoked and it becomes yours.}" + + " other {When we read this token, the current owners' claims on this game are revoked and it becomes yours.}}", + ["claim.either.heading"] = "Either of these will do", + ["claim.mssp.heading"] = "An MSSP variable", + ["claim.mssp.note"] = "In {codebase} that is a line in {file}; every codebase with MSSP has " + + "an equivalent.", + ["claim.mssp.aliases"] = "{aliases} are accepted too.", + ["claim.screen.heading"] = "A line on the connect screen", + ["claim.screen.note"] = "Anywhere in the screen, and colour codes around it are fine.", + ["claim.then.heading"] = "Then", + ["claim.then.body"] = "We check on the ordinary crawl schedule. This token is good until " + + "{date}. Come back any time; nothing needs writing down.", + + // Asking us to look sooner moves the game to the front of the queue; the crawler still + // does the dialling on its own schedule and under CRAWL DELAY. Neither sentence may + // promise a probe, because pressing a button here is our decision and not a measurement. + ["claim.check.button"] = "Look sooner", + ["claim.check.can"] = "Brings your game to the front of the queue. We dial on our own " + + "schedule, so this is sooner rather than now.", + ["claim.check.rationed"] = "Just asked. Try again in a few minutes — it is rationed because " + + "it dials a real server sooner than we would have.", + + // ── what a claim actually grants (the owner panel, §8.5 and §11) ────────────────────── + // Everything on this panel is enrichment. An owner's answer is DECLARED: stored under its + // own field source beside whatever the crawler measured, shown with its age, and it never + // replaces, hides or silences a measurement. A translation that lets "you told us" read as + // "we measured" breaks the one rule the whole site rests on. + ["owner.declare.heading"] = "What only you can tell us about {game}", + ["owner.declare.lede"] = "These are the things MSSP has no field for. They appear on your " + + "game's page as {declared}, with the date you last confirmed them, beside what we " + + "measured — never instead of it. Nothing measured can be edited from here, by you or " + + "by us.", + ["owner.field.declared"] = "declared {age}. Empty this box to withdraw it — the record of " + + "what it said is kept either way.", + ["owner.save"] = "Save what you declared", + + ["owner.override.heading"] = "What {game} reports, and what you would rather we showed", + ["owner.override.lede"] = "Your MSSP is what every crawler reads, and we go on showing it " + + "beside anything you put here — an answer of yours does not hide one of your game's.", + ["owner.override.nothingMeasured"] = "Nothing measured can be edited from here: not a " + + "player count, not a capability, not an hour of reachability.", + ["owner.override.fixItThere"] = "If a line below is wrong in your {file}, fixing it there " + + "fixes it everywhere.", + ["owner.report.value"] = "your game reports {value}, confirmed {age}", + ["owner.report.none"] = "your game reports nothing here", + ["owner.rename.note"] = "Changing the name changes what {game} is listed as and the " + + "address of its page. The old address goes on working for ever — every URL this game " + + "has ever had redirects to its current one — and clearing the box hands the name back " + + "to whatever your MSSP says.", + + ["owner.screen.heading"] = "Your connect screen", + ["owner.screen.suppressed"] = "We are not republishing it. The page says so plainly rather " + + "than leaving a hole, and the crawler goes on reading it — it is how we recognise " + + "your game if it moves.", + ["owner.screen.show"] = "Show it again", + ["owner.screen.shown"] = "We show it because your server sends it to every anonymous " + + "connection. If you would rather we did not, say so and we stop. We will not ask why.", + ["owner.screen.stop"] = "Stop showing our connect screen", + + // §11. An opt-out is honoured, never a deletion — and the empty hours it leaves may not be + // given a cause, because "you asked us to stop" is OUR fact and not a measurement of the + // game (rule 5, rule 2). {ourFact} is placed by the message so a language can put the + // emphasised clause where it belongs. + ["owner.crawl.heading"] = "Being crawled", + ["owner.crawl.stopped"] = "We have stopped. Nothing on {game} is dialled, and the page " + + "keeps everything measured before you asked — the empty hours name no cause, because " + + "{ourFact} is our fact and not a measurement of your game.", + ["owner.crawl.ourFact"] = "you asked us to stop", + ["owner.crawl.resume"] = "Start crawling us again", + ["owner.crawl.standing"] = "This one came from your own server rather than from here — " + + "{routes}. To be crawled again, stop publishing it; we will hear that on the next " + + "cycle.", + ["owner.crawl.route.mssp"] = "your MSSP report publishes {variable}", + ["owner.crawl.route.dns"] = "a {label} TXT record asks us to stop", + ["owner.crawl.route.recorded"] = "a request we recorded", + // Neither state, and shown as neither: rounding this to "stopped" would tell an owner we + // had left them alone while we went on dialling the port that is still open. + ["owner.crawl.partial"] = "We have stopped on {stopped} and are still dialling {dialling}. " + + "That is usually a port added after the opt-out.", + ["owner.crawl.stopAll"] = "Stop on every address too", + ["owner.crawl.dialling"] = "We dial {game} on a schedule and read what any anonymous " + + "connection is shown. If you would rather we did not, say so and we stop — within " + + "one cycle, on every address we have for you, and we will not ask why.", + ["owner.crawl.selfService"] = "Nothing already measured is deleted: your page keeps its " + + "history and its URL, and one probe after you take this back starts it again. You " + + "can also say it without us, in your own config — {mssp} in MSSP, or a {dns} TXT " + + "record — and we honour those whether or not anybody has ever claimed the game here.", + ["owner.crawl.stop"] = "Stop crawling us", + + // Migration 0025's second decision, and a second one rather than a stronger version of the + // first. Nothing is deleted here either: the page answers, every URL it ever had still + // redirects to it, and it stops being somewhere a reader arrives by browsing. + ["owner.listing.heading"] = "Being listed", + ["owner.listing.unlisted"] = "{game} is out of the listing, out of the rankings and out of " + + "the daily figure. Its page and every URL it has ever had go on answering, and " + + "everything measured before you asked is still on it. Nothing was deleted; it is " + + "simply not somewhere a reader arrives by browsing.", + ["owner.listing.relist"] = "Put us back in the listing", + ["owner.listing.probeRelists"] = "One probe that answers does this too. While your opt-out " + + "stands we do not dial, so nothing will — but the day you take it back, the address " + + "comes up within a week and the probe that gets an answer puts you back. You do not " + + "have to ask us twice.", + ["owner.listing.mayUnlist"] = "We have stopped dialling you, and your page is still in the " + + "listing with what we measured before that. If you would rather it were not, say so " + + "and it comes out — of the listing, the rankings and the daily figure.", + ["owner.listing.reversible"] = "Nothing is deleted and nothing breaks: the page answers, " + + "every URL it has ever had still redirects to it, and anyone you send there sees it. " + + "It stops being somewhere a reader can arrive by browsing. Reversible from here, and " + + "by any probe that answers after you take your opt-out back.", + ["owner.listing.unlist"] = "Take us out of the listing too", + // ══ END APPENDED BLOCK ════════════════════════════════════════════════════════════════ + + // ══ WHAT WE COULD MEASURE ═════════════════════════════════════════════════════════════ + // The handoff's last panel group: two independent switches over the two reasons a listing + // row carries no number. Every string here describes *our* reach and none of them describes + // a game, which is rule 5 — the note exists because the gesture these controls offer is the + // one most likely to be read as a claim about the games it removes. + ["facet.group.measure"] = "what we could measure", + + // The design's own sentence, with its first clause made true of the control that shipped: + // the panel's rows are tri-state, so hiding is the "−" rather than an untick. The second + // clause is the load-bearing one and is carried verbatim. + ["facet.measure.note"] = "Hiding these takes them out of your listing; it does not mean the " + + "game is empty.", + + // Named for what we did and never for what the game is. "could not count" is a fact about + // our parsers meeting a dialect; "no players" would be that same fact filed in somebody + // else's public record. + ["facet.group.uncounted"] = "could not count", + ["facet.group.unreachable"] = "could not reach", + + // What the chip says when a reader has dropped one of the two. Deliberately not "not + // uncounted": a double negative reads as an assertion about the games, and this is an + // assertion about the listing. Two ids carrying one English sentence, because they are two + // different facts and a language that inflects will not spell them the same way. + ["facet.excluded.uncounted"] = "hidden from this listing", + ["facet.excluded.unreachable"] = "hidden from this listing", + + // The plain surface's own key to its left column. It drew "only these" and "anything but + // these" with one star until these two switches arrived, whose ordinary gesture is the + // second — so the surface with the least else to go on was the one that could not show the + // third state at all. + ["facet.plain.marks"] = "In the left column, * is a value this listing is filtered to and - " + + "is one it is filtered against. Both are choices in the query, not facts about a game.", + // ══ END WHAT WE COULD MEASURE ═════════════════════════════════════════════════════════ + }; + + /// + /// The bundles, by tag. + /// + /// + /// + /// Only English is complete, and that is the honest state of this work rather than a gap in + /// it. The order of work is explicit: the glossary is written and human-translated before a + /// reader is sent anywhere, and no locale is offered until it is. What ships here is the + /// machinery, exercised end to end by the two test-only bundles below. + /// + /// + /// qps-ploc is a pseudolocale — accented and expanded English. It is not a language and + /// nobody claims it is one; it exists so routing, fallback, plural selection and the nav's 1.4x + /// width budget are all exercised by something real. ru-x-canary is machine-translated, + /// never shipped, and deliberately incomplete: it is what makes a missing plural form fail a + /// build instead of reaching a reader. + /// + /// + private static readonly Dictionary> TestBundles = + new(StringComparer.OrdinalIgnoreCase) + { + // Every message, mechanically transformed. Generated rather than typed so it cannot + // fall behind the source bundle it is derived from. + ["qps-ploc"] = English.ToDictionary(e => e.Key, e => Pseudo(e.Value), StringComparer.Ordinal), + + // Three plural categories, and one message that is missing its `few` and `many` branches + // on purpose. That omission is what the completeness test turns into a build failure. + ["ru-x-canary"] = new(StringComparer.Ordinal) + { + ["facet.count"] = "{count, plural, one {# игра} few {# игры} many {# игр} other {# игры}}", + ["listing.total"] = "{count, plural, one {# игра} other {# игр}}, каждый факт измерен.", + ["provenance.count.measured"] = "измерена", + ["provenance.game.measured"] = "измерено", + ["provenance.capability.measured"] = "измерены", + ["kicker.measured"] = "ИЗМЕРЕНО", + }, + }; + + /// + /// One message, rendered for a locale — or the English, where that locale has no approved one. + /// + /// + /// The fallback is silent to the reader and loud to the build. A reader meeting one English + /// phrase inside a German sentence learns something true: this particular claim has not been + /// translated yet. What must never happen is the other thing — a smoothed-over approximation of + /// a locked string, which teaches them something false and gives them no way to tell. + /// + public static string For(string tag, string id, IReadOnlyDictionary? args = null) + { + ArgumentNullException.ThrowIfNull(tag); + ArgumentNullException.ThrowIfNull(id); + + var pattern = Pattern(tag, id) + ?? throw new KeyNotFoundException($"No message '{id}' in any bundle, including English."); + + return IcuMessage.Format(pattern, tag, args); + } + + /// + /// The same, with the arguments named inline rather than built into a dictionary first. + /// + /// + /// One helper, because there were eleven. Every component that renders more than one + /// message had grown its own private wrapper turning a tuple array into an ordinal dictionary + /// and calling — the same six lines, copied, and twice inside one file. + /// StringComparer.Ordinal is the part that mattered and the part a twelfth copy would + /// eventually get wrong: an argument name is a token in a pattern, matched exactly, and a + /// dictionary that folded case would answer a lookup the parser never asked for. + /// + public static string Say(string tag, string id, params (string Key, object? Value)[] args) + { + ArgumentNullException.ThrowIfNull(args); + + return For(tag, id, args.ToDictionary(a => a.Key, a => a.Value, StringComparer.Ordinal)); + } + + /// A count and its noun, agreeing — the commonest call by a long way. + public static string Count(string tag, int count) => + For(tag, "facet.count", new Dictionary { ["count"] = count }); + + /// The raw pattern a locale would use, English included, or null. + public static string? Pattern(string tag, string id) + { + ArgumentNullException.ThrowIfNull(tag); + ArgumentNullException.ThrowIfNull(id); + + return Own(tag, id) ?? English.GetValueOrDefault(id); + } + + /// Whether a locale carries its own text for an id, rather than falling back. + public static bool HasOwn(string tag, string id) => Own(tag, id) is not null; + + /// + /// What a locale itself says for an id — from its resx, or from a test bundle — or null. + /// + /// + /// ResourceNotFound is the load-bearing check. answers a + /// missing key with the key itself rather than with null, so a lookup that trusted the string it + /// got back would render facet.count to a reader and call it a translation. + /// + private static string? Own(string tag, string id) + { + if (TestBundles.TryGetValue(tag, out var bundle)) + { + return bundle.GetValueOrDefault(id); + } + + // The source language reads its own compiled-in copy: it is the fallback for every other + // locale, and a fallback that depends on a satellite assembly having loaded is not one. + if (string.Equals(tag, Locales.SourceTag, StringComparison.OrdinalIgnoreCase)) + { + return English.GetValueOrDefault(id); + } + + if (Culture(tag) is not { } culture) + { + return null; + } + + // The resource set for this culture *alone*, with tryParents off — which is the whole point. + // GetString walks up to the neutral resources, so it answers the English for a locale that + // has translated nothing, and a caller asking "does this locale carry its own words for this + // id" would be told yes for every id in the site. The fallback is deliberate elsewhere and + // wrong here. + // + // A ResourceManager rather than IStringLocalizer, and that is not a rejection of the pattern + // — IStringLocalizer *is* a ResourceManager with the culture read off the ambient thread. + // This lookup is static and is called from Razor markup, from the plain-text renderer and + // from headless component tests alike, and it is handed the locale rather than inferring + // one; the DI wrapper would mean it could not answer at all without a host behind it, which + // is most of where it is called from. AddMuiLocalization still registers the injected form + // for anything that wants it. + try + { + return Resources.GetResourceSet(culture, createIfNotExists: true, tryParents: false) + ?.GetString(id); + } + catch (MissingManifestResourceException) + { + return null; + } + } + + /// + /// The satellite assemblies, keyed off the marker type so the base name cannot drift. + /// + /// + /// One per culture, compiled by the SDK from Resources/Messages.<culture>.resx with + /// no <EmbeddedResource> entries in the project file. A culture with no satellite + /// answers null here rather than throwing, which is the fallback path. + /// + private static readonly ResourceManager Resources = new(typeof(Web.Resources.Messages)); + + private static CultureInfo? Culture(string tag) + { + try + { + return CultureInfo.GetCultureInfo(tag); + } + catch (CultureNotFoundException) + { + return null; + } + } + + /// The source text for an id, which is what a resx has to agree with. + public static string? Source(string id) => English.GetValueOrDefault(id); + + + + /// Every id the site says, in the order the source bundle declares them. + public static IReadOnlyList Ids { get; } = [.. English.Keys]; + + /// The ids a locale has not translated yet. + /// + /// The release checklist's own question, answerable from code rather than from a spreadsheet. + /// A locale may not be moved to while any locked id + /// is in this list — which is the rule the completeness test enforces. + /// + public static IReadOnlyList MissingFor(string tag) => + [.. Ids.Where(id => !HasOwn(tag, id))]; + + /// + /// Accented, expanded English — a language nobody speaks, which is the point. + /// + /// + /// Two jobs. The accents prove a string came through the pipeline rather than being hard-coded + /// in a template, and the padding gives every string the 1.4x width the handoff says to review + /// a locale at — German and Russian run 30–40% longer on short UI nouns, and the nav bar was + /// tightened to fit English exactly. The ICU syntax is stepped over rather than transformed: + /// mangling a branch keyword would make the message unparseable and prove nothing. + /// + private static string Pseudo(string pattern) + { + var b = new System.Text.StringBuilder(pattern.Length * 2); + var depth = 0; + + foreach (var c in pattern) + { + if (c == '{') { depth++; b.Append(c); continue; } + if (c == '}') { depth--; b.Append(c); continue; } + + // Inside braces the text is syntax — argument names and branch keywords — and accenting + // it would make the message unparseable, which proves nothing. + b.Append(depth > 0 ? c : Accent(c)); + } + + // The padding goes outside the braces so no argument name is touched. + return "⟦" + b + "⟧"; + } + + private static char Accent(char c) => c switch + { + 'a' => 'á', 'e' => 'é', 'i' => 'í', 'o' => 'ó', 'u' => 'ú', 'n' => 'ñ', 'c' => 'ç', + 'A' => 'Á', 'E' => 'É', 'I' => 'Í', 'O' => 'Ó', 'U' => 'Ú', 'N' => 'Ñ', 'C' => 'Ç', + _ => c, + }; +} diff --git a/src/MUI.Web/Localization/PluralOperands.cs b/src/MUI.Web/Localization/PluralOperands.cs new file mode 100644 index 0000000..ebaf00c --- /dev/null +++ b/src/MUI.Web/Localization/PluralOperands.cs @@ -0,0 +1,106 @@ +using System.Globalization; + +namespace MUI.Web.Localization; + +/// +/// The six values a CLDR plural rule is written in terms of. +/// +/// The absolute value of the number. +/// Its integer digits. +/// How many fraction digits are visible, trailing zeros included. +/// How many are visible with trailing zeros removed. +/// The visible fraction digits as an integer, trailing zeros included. +/// The same with trailing zeros removed. +/// The compact-decimal exponent, which is zero for everything this site formats. +/// +/// +/// Visible is the load-bearing word, and it is why this type exists rather than an int. +/// In English 1 is one and 1.0 is other — "1.0 stars" is correct and +/// "1.0 star" is not — and the two are the same quantity. A rule cannot tell them apart from the +/// value alone; it needs to know how the number was written. That is what v and +/// f carry, and it is the single most commonly missed thing in a hand-rolled plural +/// implementation. +/// +/// +/// Every count this site pluralises is an integer, so v is zero throughout and none of this +/// changes an answer today. It is here because a plural implementation that only works for integers +/// is one that silently gives the wrong form the first time somebody formats a rate or an average, +/// and because the rules below are transcribed from CLDR in the operands CLDR states them in — a +/// transcription into a different vocabulary is a transcription that cannot be checked. +/// +/// +/// These are absolute values and this type cannot print a number. CLDR takes the absolute +/// value to choose a category, never to display one, so a Format here would drop the sign of +/// every number it was handed — and it did, along with the group separator, which put "1234 games" +/// in a sentence beside "1,234" in the column. Rendering belongs to , which +/// still holds the signed value the caller passed. +/// +/// +public readonly record struct PluralOperands( + decimal N, + long I, + int V, + int W, + long F, + long T, + int E) +{ + /// The operands of an integer, where every fractional one is zero. + public static PluralOperands Of(long value) + { + var n = Math.Abs(value); + + return new PluralOperands(n, n, 0, 0, 0, 0, 0); + } + + /// + /// The operands of a number as it will actually be written. + /// + /// The quantity. + /// + /// How many fraction digits the rendered string shows, or null to read them off the value. + /// + /// + /// The parameter is what makes 1 and 1.0 different: a caller formatting to two + /// decimal places has to say so, because by the time the number reaches here as a + /// the trailing zeros it will be printed with are a fact about the format + /// string and not about the value. + /// + public static PluralOperands Of(decimal value, int? visibleFractionDigits = null) + { + var n = Math.Abs(value); + var i = (long)decimal.Truncate(n); + + // decimal keeps its own scale, so 1.50m already knows it has two fraction digits. That is + // the right default: a caller who did not say otherwise gets the digits the value carries. + var scale = (byte)((decimal.GetBits(n)[3] >> 16) & 0x7f); + var v = visibleFractionDigits ?? scale; + + var fractional = n - i; + var f = v == 0 ? 0L : (long)decimal.Round(fractional * Pow10(v), 0, MidpointRounding.AwayFromZero); + + // w and t are v and f with trailing zeros taken off: 1.50 has v=2, f=50, w=1, t=5. + var w = v; + var t = f; + + while (w > 0 && t % 10 == 0) + { + t /= 10; + w--; + } + + return new PluralOperands(n, i, v, w, f, t, E: 0); + } + + private static decimal Pow10(int power) + { + var result = 1m; + + for (var at = 0; at < power; at++) + { + result *= 10m; + } + + return result; + } +} diff --git a/src/MUI.Web/Localization/PluralRules.cs b/src/MUI.Web/Localization/PluralRules.cs new file mode 100644 index 0000000..be62fe8 --- /dev/null +++ b/src/MUI.Web/Localization/PluralRules.cs @@ -0,0 +1,402 @@ +namespace MUI.Web.Localization; + +/// The CLDR plural categories. +/// +/// Six of them exist across all languages; no single language uses more than five, and most use one +/// or two. is the one every language has and the only one a message must +/// declare. +/// +public enum PluralCategory +{ + Zero, + One, + Two, + Few, + Many, + Other, +} + +/// Cardinal counts one thing; ordinal ranks it. +/// +/// They are different rule sets and getting them from one table is a bug rather than a shortcut. In +/// English the cardinal rule has two forms — 1 game, 2 games — and the ordinal rule has +/// four — 1st, 2nd, 3rd, 4th — and neither can produce the other's answers. +/// +public enum PluralKind +{ + Cardinal, + Ordinal, +} + +/// +/// Which plural form a number takes, per locale, per kind. +/// +/// +/// +/// Transcribed from CLDR 46's plural charts, in the operands CLDR states them in. Each rule +/// below is written the way the chart writes it — i = 1 and v = 0 rather than +/// count == 1 — so it can be checked against the source line by line. A transcription into a +/// different vocabulary is a transcription nobody can verify. +/// +/// +/// Hand-written rather than taken from a library, and that is a judgement rather than a +/// preference. The .NET options are thin: ICU4N is an alpha pinned to ICU 60, whose CLDR data +/// predates several of the rules below, and the MessageFormat ports are 0.1.x forks of an abandoned +/// project. Neither is a dependency worth the credibility of a site whose entire product is being +/// right about what it knows. The cost is that this table has to be maintained against CLDR when a +/// locale is added, which is why exists and is asserted against the +/// locales the site actually commits to. +/// +/// +/// An unlisted language answers . That is correct for a +/// language with one form and safe for one whose rule is not written here — the message still +/// renders, in the form every language has. What stops that being a silent wrong answer is the test +/// that walks every offered locale and refuses one this table does not cover. +/// +/// +public static class PluralRules +{ + /// The CLDR release these rules were transcribed from. + public const string CldrVersion = "46"; + + /// + /// The languages this table states a rule for, cardinal or ordinal. + /// + /// + /// The gate on adding a locale: a tag whose language is not here falls back to other for + /// every count, which is right for Chinese and wrong for German. Asserted in the tests against + /// . + /// + public static IReadOnlyList LocalesCovered { get; } = + [ + "en", "de", "nl", "sv", "da", "no", "fi", "et", "el", "it", "es", "fr", "pt", + "ru", "uk", "be", "pl", "cs", "sk", "hi", "th", "vi", "id", "ms", "ja", "ko", "zh", + "tr", "he", "ar", "qps", + ]; + + /// The category takes. + public static PluralCategory Of(string tag, long count, PluralKind kind = PluralKind.Cardinal) => + Of(tag, PluralOperands.Of(count), kind); + + /// The category a number written a particular way takes. + public static PluralCategory Of(string tag, PluralOperands o, PluralKind kind = PluralKind.Cardinal) + { + ArgumentNullException.ThrowIfNull(tag); + + var language = Language(tag); + + return kind is PluralKind.Ordinal ? Ordinal(language, o) : Cardinal(language, o); + } + + private static PluralCategory Cardinal(string language, PluralOperands o) => language switch + { + // one: i = 1 and v = 0 + // + // The v = 0 is why this file carries operands at all: "1.0 stars" is other, not one, and + // the two quantities are equal. `qps` is the pseudolocale and is English underneath, so it + // has to select exactly what English selects or it exercises the wrong branch. + "en" or "de" or "nl" or "sv" or "fi" or "et" or "qps" => + o is { I: 1, V: 0 } ? PluralCategory.One : PluralCategory.Other, + + // one: n = 1 + // + // Not the line above, and 1.0 is the whole of the difference. Greek, Norwegian, + // Spanish and Turkish say `one` for it where English says `other`. All four had been given + // English's rule, because on the integers the two agree — and the integers are all anybody + // checks. Turkish had `n = 0..1` besides, which is a real CLDR rule belonging to Akan and + // Punjabi and puts zero in the form Turkish keeps for exactly one thing. + "el" or "no" or "tr" => o.N == 1m ? PluralCategory.One : PluralCategory.Other, + + // one: n = 1 or t != 0 and i = 0,1 + // + // Danish alone, and the only `one` in this table that reaches a quantity which is not 1: + // "0,5 stjerne" rather than "0,5 stjerner". Copied from Swedish, it loses that clause + // silently — the integers, again, agree. + "da" => o.N == 1m || (o.T != 0 && o.I is 0 or 1) + ? PluralCategory.One + : PluralCategory.Other, + + // one: i = 1 and v = 0 (it) + // n = 1 (es) + // i = 0,1 (fr, pt) + // many: e = 0 and i != 0 and i % 1000000 = 0 and v = 0 (all four) + // + // The Romance millions rule — "un millón de juegos", with the preposition the other forms + // do not take. fr and pt carried it; it and es were folded into English's rule above and so + // had no `many` at all, which is a form a translator would have been asked to write and + // never given anywhere to put. + "it" => Millions(o) ? PluralCategory.Many + : o is { I: 1, V: 0 } ? PluralCategory.One + : PluralCategory.Other, + + "es" => Millions(o) ? PluralCategory.Many + : o.N == 1m ? PluralCategory.One + : PluralCategory.Other, + + "fr" or "pt" => Millions(o) ? PluralCategory.Many + : o.I is 0 or 1 ? PluralCategory.One + : PluralCategory.Other, + + // one: v = 0 and i % 10 = 1 and i % 100 != 11 + // few: v = 0 and i % 10 = 2..4 and i % 100 != 12..14 + // many: v = 0 and (i % 10 = 0 or i % 10 = 5..9 or i % 100 = 11..14) + // other: everything with a visible fraction + // + // 11 and 12 end in 1 and 2 and take neither `one` nor `few`. A rule written from the first + // three examples anybody tries is wrong for both. + "ru" or "uk" => o.V != 0 ? PluralCategory.Other + : (o.I % 10, o.I % 100) switch + { + (1, not 11) => PluralCategory.One, + (2 or 3 or 4, not (12 or 13 or 14)) => PluralCategory.Few, + _ => PluralCategory.Many, + }, + + // one: n % 10 = 1 and n % 100 != 11 + // few: n % 10 = 2..4 and n % 100 != 12..14 + // many: n % 10 = 0 or n % 10 = 5..9 or n % 100 = 11..14 + // + // Belarusian states Russian's shape on `n` rather than on `i` with `v = 0`, so 1.0 is + // `one` here and `other` there. It had been folded in with Russian above: right for every + // integer, wrong for every number written with a decimal place. + "be" => !Whole(o) ? PluralCategory.Other + : (o.I % 10, o.I % 100) switch + { + (1, not 11) => PluralCategory.One, + (2 or 3 or 4, not (12 or 13 or 14)) => PluralCategory.Few, + _ => PluralCategory.Many, + }, + + // one: i = 1 and v = 0 + // few: v = 0 and i % 10 = 2..4 and i % 100 != 12..14 + // many: v = 0 and i != 1 and (i % 10 = 0..1 or i % 10 = 5..9 or i % 100 = 12..14) + "pl" => o.V != 0 ? PluralCategory.Other + : o.I == 1 ? PluralCategory.One + : (o.I % 10, o.I % 100) switch + { + (2 or 3 or 4, not (12 or 13 or 14)) => PluralCategory.Few, + _ => PluralCategory.Many, + }, + + // one: i = 1 and v = 0 + // few: i = 2..4 and v = 0 + // many: v != 0 + "cs" or "sk" => o.V != 0 ? PluralCategory.Many + : o.I switch + { + 1 => PluralCategory.One, + 2 or 3 or 4 => PluralCategory.Few, + _ => PluralCategory.Other, + }, + + // one: i = 0 or n = 1 + "hi" => o.I == 0 || o.N == 1m ? PluralCategory.One : PluralCategory.Other, + + // one: i = 1 and v = 0 or i = 0 and v != 0 + // two: i = 2 and v = 0 + // + // The `many` this rule used to carry for multiples of ten was withdrawn from CLDR before + // 46, and so was every one of Hebrew's ordinals. A table still stating them selects a + // branch nobody was ever asked to translate — which the `other` fallback cannot catch, + // because the branch is present and simply wrong. + "he" => o switch + { + { I: 1, V: 0 } or { I: 0, V: not 0 } => PluralCategory.One, + { I: 2, V: 0 } => PluralCategory.Two, + _ => PluralCategory.Other, + }, + + // zero: n = 0 one: n = 1 two: n = 2 + // few: n % 100 = 3..10 many: n % 100 = 11..99 + // + // Six categories, which is the count the review cites — and Arabic is render-only here, so + // this rule exists for correctness of the table rather than for a locale that ships. + "ar" => o.N switch + { + 0 => PluralCategory.Zero, + 1 => PluralCategory.One, + 2 => PluralCategory.Two, + _ when !Whole(o) => PluralCategory.Other, + _ => (o.I % 100) switch + { + >= 3 and <= 10 => PluralCategory.Few, + >= 11 and <= 99 => PluralCategory.Many, + _ => PluralCategory.Other, + }, + }, + + // No plural inflection at all. This is exactly why Chinese cannot be the locale a string + // architecture is validated against: it agrees with any shape, including a wrong one. + "zh" or "ja" or "ko" or "th" or "vi" or "id" or "ms" => PluralCategory.Other, + + _ => PluralCategory.Other, + }; + + private static PluralCategory Ordinal(string language, PluralOperands o) => language switch + { + // one: n % 10 = 1 and n % 100 != 11 (1st, 21st, but 11th) + // two: n % 10 = 2 and n % 100 != 12 (2nd, 22nd, but 12th) + // few: n % 10 = 3 and n % 100 != 13 (3rd, 23rd, but 13th) + "en" or "qps" => !Whole(o) ? PluralCategory.Other + : (o.I % 10, o.I % 100) switch + { + (1, not 11) => PluralCategory.One, + (2, not 12) => PluralCategory.Two, + (3, not 13) => PluralCategory.Few, + _ => PluralCategory.Other, + }, + + // one: n % 10 = 1,2 and n % 100 != 11,12 (1:a and 2:a, then 3:e — and 11:e, 12:e) + "sv" => !Whole(o) ? PluralCategory.Other + : (o.I % 10, o.I % 100) switch + { + (1 or 2, not (11 or 12)) => PluralCategory.One, + _ => PluralCategory.Other, + }, + + // one: n = 1 (1er / 1re, then 2e, 3e …) + "fr" => o.N == 1m ? PluralCategory.One : PluralCategory.Other, + + // few: n % 10 = 3 and n % 100 != 13 + "uk" => !Whole(o) ? PluralCategory.Other + : (o.I % 10, o.I % 100) switch + { + (3, not 13) => PluralCategory.Few, + _ => PluralCategory.Other, + }, + + // few: n % 10 = 2,3 and n % 100 != 12,13 + "be" => !Whole(o) ? PluralCategory.Other + : (o.I % 10, o.I % 100) switch + { + (2 or 3, not (12 or 13)) => PluralCategory.Few, + _ => PluralCategory.Other, + }, + + // many: n = 11,8,80,800 + "it" => Whole(o) && o.I is 11 or 8 or 80 or 800 ? PluralCategory.Many : PluralCategory.Other, + + // one: n = 1 two: n = 2,3 few: n = 4 many: n = 6 + "hi" => !Whole(o) ? PluralCategory.Other + : o.I switch + { + 1 => PluralCategory.One, + 2 or 3 => PluralCategory.Two, + 4 => PluralCategory.Few, + 6 => PluralCategory.Many, + _ => PluralCategory.Other, + }, + + // one: n = 1 + "vi" or "ms" => o.N == 1m ? PluralCategory.One : PluralCategory.Other, + + // Every other language this site knows about has one ordinal form: German, Spanish, Danish, + // Norwegian, Greek, Dutch, Finnish, Estonian, Polish, Czech, Slovak, Russian, Portuguese, + // Thai, Indonesian, Chinese, Japanese, Korean, Turkish, Arabic — and Hebrew, whose six-way + // ordinal table CLDR withdrew before 46. + _ => PluralCategory.Other, + }; + + /// The Romance millions rule, which CLDR states identically for all four languages. + /// + /// many: e = 0 and i != 0 and i % 1000000 = 0 and v = 0. e is the compact-decimal + /// exponent and is zero for everything this site formats, so the clause CLDR adds for compact + /// notation cannot be reached from here. + /// + private static bool Millions(PluralOperands o) => + o is { E: 0, V: 0, I: not 0 } && o.I % 1_000_000 == 0; + + /// Whether the number is a whole one, which is all a CLDR range can ever match. + /// + /// n % 100 = 3..10 is a range over integers: 3.5 is not in it, however its integer part + /// reads. Testing i in its place gives every fraction the category of the whole number + /// below it — the wrong form for a rate and for an average alike, and invisible in a table + /// exercised only with counts. + /// + private static bool Whole(PluralOperands o) => o.N == o.I; + + /// + /// Every category a locale can produce, which is what a message has to cover. + /// + /// + /// The assertion the Russian canary is for. A message declaring only one and other + /// is complete in English and silently wrong in Russian, where a count of two takes a form + /// neither branch supplies — and a wrong plural does not read as a typo to a native speaker, it + /// reads as illiterate. + /// + public static IReadOnlyList CategoriesOf( + string tag, PluralKind kind = PluralKind.Cardinal) + { + ArgumentNullException.ThrowIfNull(tag); + + // Derived by exercising the rule rather than by keeping a second table beside it, so the + // two cannot disagree. The probes cover every boundary the rules above test — the teens, + // the tens, the millions, and a fraction, which is the case that separates `one` from + // `other` in English. + var seen = new List(); + + foreach (var probe in Probes) + { + var category = Of(tag, probe, kind); + + if (!seen.Contains(category)) + { + seen.Add(category); + } + } + + return seen; + } + + /// Numbers that between them reach every branch of every rule above. + private static IReadOnlyList Probes { get; } = + [ + .. new long[] + { + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 20, 21, 22, 23, 24, 25, + 80, 100, 101, 102, 103, 111, 112, 113, 800, 1000, 1_000_000, 2_000_000, + }.Select(n => PluralOperands.Of(n)), + + // A visible fraction, which is `other` in English and in Russian and `many` in Czech. + PluralOperands.Of(1.0m, visibleFractionDigits: 1), + PluralOperands.Of(1.5m, visibleFractionDigits: 1), + ]; + + /// The keyword a message branch is spelled with. + public static string Keyword(PluralCategory category) => category switch + { + PluralCategory.Zero => "zero", + PluralCategory.One => "one", + PluralCategory.Two => "two", + PluralCategory.Few => "few", + PluralCategory.Many => "many", + _ => "other", + }; + + /// Whether a word is one of the six category keywords. + public static bool IsCategory(string word) => + word is "zero" or "one" or "two" or "few" or "many" or "other"; + + /// Whether this table states a rule for a tag's language at all. + public static bool Covers(string tag) + { + ArgumentNullException.ThrowIfNull(tag); + + return LocalesCovered.Contains(Language(tag), StringComparer.Ordinal); + } + + /// + /// The language subtag, which is what a plural rule is keyed on. + /// + /// + /// zh-Hans and zh-Hant pluralise identically, and so do ru and the CI + /// canary's ru-x-canary — the script and the private-use subtag change which glyphs are + /// drawn and which bundle is read, never how a number agrees. + /// + private static string Language(string tag) + { + var dash = tag.IndexOf('-', StringComparison.Ordinal); + + return (dash < 0 ? tag : tag[..dash]).ToLowerInvariant(); + } +} diff --git a/src/MUI.Web/Localization/Sentences.cs b/src/MUI.Web/Localization/Sentences.cs new file mode 100644 index 0000000..a55cf20 --- /dev/null +++ b/src/MUI.Web/Localization/Sentences.cs @@ -0,0 +1,100 @@ +namespace MUI.Web.Localization; + +/// +/// A sentence whose word order belongs to the locale and whose markup belongs to the site. +/// +/// +/// +/// Some sentences have a link or an emphasised word inside them. Gluing English round an anchor +/// gives a language that wants the link first, or a different preposition before it, nowhere to say +/// so; formatting the anchor into the string and trusting the result through a +/// MarkupString would make every bundle a place a tag could be put. So the message places a +/// marker, this walks it, and what a translator writes is text either way. +/// +/// +/// The markers are private-use code points, which no translation contains by accident and no script +/// the site offers uses. They are assigned in the order the slots are passed and never appear in +/// what a reader is shown: a marker that survives into the output is a slot the message did not +/// place, and the run for it is simply absent — which is visible rather than silent. +/// +/// +/// This was two copies before it was one file. The random-game empty state placed two links this +/// way and the reference section needed the same thing for its own two sentences, at which point the +/// marker constant, the walk and the "translator writes no markup" argument were about to exist +/// twice. +/// +/// +public static class Sentences +{ + /// The first marker. One code point per slot, upwards from here. + private const char Marker = '\uE000'; + + /// + /// The most slots one sentence may place. Well past anything readable, and a bound rather than + /// an open range so a stray private-use character in a translation cannot be read as a slot. + /// + private const int MaxSlots = 8; + + /// One piece of a sentence: plain text, or the text of a named slot. + /// What the reader sees, already in their language. + /// + /// The argument name the message placed, or null for the prose between the slots. The caller + /// switches on this to decide what markup goes round the run. + /// + public sealed record Run(string Text, string? Slot); + + /// + /// A message, split into the runs its markup renders — the slots in the order the + /// message puts them, which is the locale's order and not this call's. + /// + public static IReadOnlyList Place(string tag, string id, params (string Name, string Text)[] slots) + { + ArgumentNullException.ThrowIfNull(slots); + ArgumentOutOfRangeException.ThrowIfGreaterThan(slots.Length, MaxSlots); + + var sentence = Messages.For(tag, id, slots + .Select((slot, i) => (slot.Name, Value: (object?)((char)(Marker + i)).ToString())) + .ToDictionary(s => s.Name, s => s.Value, StringComparer.Ordinal)); + + var runs = new List(); + var run = new System.Text.StringBuilder(); + + foreach (var c in sentence) + { + var slot = c - Marker; + + if (slot < 0 || slot >= slots.Length) + { + run.Append(c); + continue; + } + + if (run.Length > 0) + { + runs.Add(new Run(run.ToString(), null)); + run.Clear(); + } + + runs.Add(new Run(slots[slot].Text, slots[slot].Name)); + } + + if (run.Length > 0) + { + runs.Add(new Run(run.ToString(), null)); + } + + return runs; + } + + /// + /// The same sentence with the slots substituted rather than marked — for the plain surface, + /// which has no markup to put round them and must still say every word the page does. + /// + public static string Flat(string tag, string id, params (string Name, string Text)[] slots) + { + ArgumentNullException.ThrowIfNull(slots); + + return Messages.For(tag, id, slots.ToDictionary( + s => s.Name, s => (object?)s.Text, StringComparer.Ordinal)); + } +} diff --git a/src/MUI.Web/MUI.Web.csproj b/src/MUI.Web/MUI.Web.csproj index 2da38cb..4320d0f 100644 --- a/src/MUI.Web/MUI.Web.csproj +++ b/src/MUI.Web/MUI.Web.csproj @@ -30,6 +30,15 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + {count, plural, one {# Spiel} other {# Spiele}} + + + {value}, {count, plural, one {# Spiel} other {# Spiele}}, nur + + + {value}, {count, plural, one {# Spiel} other {# Spiele}}, ausgeschlossen + + + {value}, {count, plural, one {# Spiel} other {# Spiele}} + + + {facet}: beliebig, {count, plural, one {# Spiel} other {# Spiele}} + + + {count, plural, =0 {Keine Spiele eingetragen.} one {# Spiel, jede Tatsache nennt ihre Herkunft.} other {# Spiele, jede Tatsache nennt ihre Herkunft.}} + + + {days, plural, one {# Tag} other {# Tage}} gemessen · {probes, plural, one {# Abfrage} other {# Abfragen}} + + + {days, plural, one {# Tag} other {# Tage}} · {count, plural, one {# Zählung} other {# Zählungen}} + + + {disagreeing, plural, =0 {Keine der {total} widerspricht.} one {# von {total} widerspricht dem, was das Spiel angibt.} other {# von {total} widersprechen dem, was das Spiel angibt.}} + + + {count, plural, =0 {Keine Spiele passen zu allen Antworten.} one {# Spiel passt zu allen Antworten.} other {# Spiele passen zu allen Antworten.}} + + + von {listed, plural, one {# eingetragenem Spiel} other {# eingetragenen Spielen}} · {answers, plural, =0 {keine Antworten gegeben} one {# Antwort gegeben} other {# Antworten gegeben}} + + + {count, plural, one {Das eine Spiel zeigen} other {Diese # Spiele zeigen}} + + + „{answer}“ entfernen — {count, plural, one {# Spiel} other {# Spiele}} + + + Antwort zurücksetzen: {question} + + + Ein Spiel finden + + + passend zu allen Antworten + + + {count, plural, one {Spiel} other {Spiele}} + + + alle Antworten zurücksetzen + + + neu beginnen + + + {count, plural, one {# weiteres} other {# weitere}} + + + gegebene Antworten + + + das gesamte Verzeichnis + + + diese Anfrage wurde abgelehnt + + + ein Name, falls Sie einen haben + + + Name oder Namensteil + + + Nach Namen suchen + + + Spielt gerade jemand? + + + Was möchten Sie spielen? + + + Welche Art von Spiel? + + + In welcher Sprache? + + + Braucht Ihr Client etwas? + + + Verstummte Spiele einbeziehen? + + + egal + + + beliebiges Genre + + + beliebige Art + + + beliebige Sprache + + + egal + + + nein, nur aktive Spiele + + + ja, auch diese zeigen + + + verstummte Spiele + + + TLS — verschlüsselt, Handshake von uns abgeschlossen + + + MSSP — Selbstauskunft des Servers + + + MCCP — komprimierte Ausgabe + + + MXP — anklickbare Links + + + GMCP — strukturierte Client-Daten + + + MSDP — strukturierte Client-Daten + + + CHARSET — Aushandlung der Kodierung + + + UTF-8 — nichtlateinischer Text wird dargestellt + + + TTYPE — Client nennt seinen Typ + + + ATCP — strukturierte Client-Daten + + + MSP — Sound-Auslöser + + + EOR — Prompt-Markierung + + + {token} — im Handshake gemessen + + + gemessen + + + gemessen + + + gemessen + + + gemessen + + + gemessen + + + angegeben + + + angegeben + + + angegeben + + + angegeben + + + abgeleitet + + + abgeleitet + + + nicht gemessen + + + ungezählt + + + nicht erreichbar + + + nicht gezählt + + + keine Zählung + + + von hier aus + + + archiviert + + + beansprucht + + + Nichts zur Auswahl + + + Kein Spiel passt zu diesem Filter. {listing} versuchen oder {archive}. + + + das gesamte Verzeichnis + + + das Archiv einbeziehen + + + Verbindungen + + + nicht beansprucht + + + vom Betreiber beansprucht + + + wird weiter abgefragt + + + typisch + + + Spitze + + + als Text lesen + + + Textfassung + + + zum Inhalt springen + + + ASCII-Banner: der Verbindungsbildschirm von {game}. + + + Kataloge + + + Diese Website und Ihr Konto + + + stöbern + + + lernen + + + diese Website + + + Menü + + + Spiele + + + finden + + + zufällig + + + Archiv + + + Referenz + + + Ökosystem + + + Ranglisten + + + über uns + + + eintragen + + + Spiel eintragen + + + anmelden + + + Ihre Spiele + + + Design + + + automatisch + + + hell + + + dunkel + + + Demodaten. + + + Es ist keine Datenbank konfiguriert, daher sind dies Beispieldaten. Nichts davon wurde gemessen. + + + alle Spiele + + + Archiv + + + vom Spiel angegeben + + + was sich geändert hat + + + Ein Verzeichnis des MU*-Hobbys + + + Jede Tatsache nennt ihre Herkunft und ihr Alter: von unserem Crawler gemessen oder vom Spiel angegeben und als solche gekennzeichnet. + + + Spiele nach Name, Thema, Codebase oder Host suchen + + + nach Name, Thema, Codebase oder Host suchen + + + suchen + + + bekannte Spiele + + + jetzt verbunden + + + antwortend, ungezählt + + + archiviert + + + neu entdeckt + + + verstummt — wird weiter abgefragt + + + wieder da + + + Nichts Neues. + + + Nichts ist verstummt. + + + Nichts ist zurückgekehrt. Wir klopfen weiter. + + + live + + + Spiele + + + sortiert nach {order} + + + zufällig + + + Verbindungen · erreicht + + + von hier aus + + + Keine Treffer. + + + Weniger Wörter versuchen oder einen Filter entfernen. + + + Filter zurücksetzen + + + über {codebase} + + + nie + + + vom Betreiber beansprucht + + + Unbekannte Codebase + + + wir konnten die Codebase dieses Spiels nicht identifizieren + + + und {count, plural, one {# weiteres} other {# weitere}}: {names} + + + Sortierung + + + Zeitraum + + + jetzt + + + typisch + + + Spitze + + + Name + + + erreicht + + + 7 Tage + + + 30 Tage + + + 90 Tage + + + Spiele suchen + + + Spiele suchen + + + Filter + + + angezeigt + + + alle zurücksetzen + + + — nicht mehr danach filtern + + + beliebig + + + weitere Filter ({count}) + + + {count, plural, one {# weiterer} other {# weitere}} + + + zusätzlich zeigen + + + Standardmäßig aus. Keines von beiden ist ein Urteil über das Spiel. + + + archiviert + + + Erwachseneninhalte + + + archivierte Spiele, {shown, select, true {angezeigt} other {verborgen}} + + + Spiele, die Erwachseneninhalte angeben, {shown, select, true {angezeigt} other {verborgen}} + + + Zahlen sind gemessene Spiele, nie Schätzungen. + + + was die Kennzeichen und die Leerstellen bedeuten + + + Eine Leerstelle ist eine Lücke in unserer Messung, kein Nein. Jede Facette benennt ihre eigene: nicht identifiziert, nicht angegeben, nichts ausgehandelt. + + + Eine gemessene Null ist eine Zählung. Eine unbekannte Zählung ist keine Null und wird nie als solche sortiert. + + + Offene Facetten listen ihre {count} häufigsten Werte. Der Rest ist über die Suche und über die URL erreichbar. + + + Nicht angehakt heißt nicht gemessen — nicht, dass es dem Spiel fehlt. + + + Aktivität + + + zuletzt gesehen + + + angebotene Protokolle + + + verschlüsselt + + + Kodierung + + + Codebase + + + Version + + + Abstammung + + + Familie + + + Genre + + + Sprache + + + jetzt verbunden + + + diese Woche aktiv + + + ruhig — nie mehr als 0 gezählt + + + verstummt — seit einem Monat nicht erreicht + + + archiviert + + + in den letzten 24 Stunden + + + in den letzten 7 Tagen + + + in den letzten 30 Tagen + + + länger her + + + nie erreicht + + + nichts ausgehandelt + + + nicht identifiziert + + + nicht angegeben + + + über TLS verbunden + + + nicht {value} + + + etwas ausgehandelt + + + überhaupt identifiziert + + + überhaupt angegeben + + + wir haben das selbst beobachtet + + + das Spiel sagt es, und wir haben es nicht geprüft + + + wir haben gruppiert, was das Spiel uns mitgeteilt hat + + + Name + + + jetzt verbunden + + + zuletzt erreicht + + + typisch verbunden · 7 Tage + + + typisch verbunden · 30 Tage + + + typisch verbunden · 90 Tage + + + meiste gleichzeitig · 7 Tage + + + meiste gleichzeitig · 30 Tage + + + meiste gleichzeitig · 90 Tage + + + jetzt in der Zeile + + + typisch + + + Spitze + + + Unbekannte Zählung + + + noch nie erreicht — nicht etwa vor langer Zeit erreicht + + + weniger als {minimum} Zählungen im Zeitraum oder gar keine — nicht etwa eine typische Zählung von null + + + nichts, was wir im Zeitraum zählen konnten — nicht etwa ein Spiel, mit dem niemand verbunden war + + + Median {value} · {days, plural, one {# Tag} other {# Tage}} · {count, plural, one {# Zählung} other {# Zählungen}} + + + meiste {value} gleichzeitig · {days, plural, one {# Tag} other {# Tage}} · {count, plural, one {# Zählung} other {# Zählungen}} + + + Verbindungsbildschirm + + + Verbindungen nach Stunde + + + Wie viele, im Zeitverlauf + + + Erreichbar + + + Was sich geändert hat + + + Fähigkeiten + + + Vom Spiel angegeben + + + Verweise + + + Nicht beansprucht — alles hier wurde gemessen. + + + Vom Betreiber beansprucht — die gemessenen Tatsachen unten sind weiterhin unsere. + + + Dieses Spiel beanspruchen + + + antwortet seit {date} + + + als Text lesen — {count, plural, one {# Zeile} other {# Zeilen}} + + + Fähigkeit + + + Alter + + + angeboten + + + stumm + + + fehlend + + + verweigert + + + behauptet + + + widerspricht + + + wo sie widersprechen ({count}) + + + {day} {time} — {count, plural, =0 {0 Spieler, gemessen} one {durchschnittlich # Spieler} other {durchschnittlich # Spieler}} + + + {day} {time} — abgefragt, keine Zählung ablesbar + + + {day} {time} — keine Messung in dieser Stunde + + + Wir haben die Aktivität dieses Spiels noch nicht gemessen. + + + Keine Stunde der Woche hat eine Zählung ergeben. + + + Jede Stunde gemessen, und in keiner war jemand verbunden. + + + {count, plural, one {Für # Stunde am {day} liegt noch keine Messung vor.} other {Für # Stunden am {day} liegt noch keine Messung vor.}} + + + {count, plural, one {Für # Stunde der Woche liegt noch keine Messung vor.} other {Für # Stunden der Woche liegt noch keine Messung vor.}} + + + {count, plural, one {# Stunde am {day} hat geantwortet, aber keine Zählung ergeben.} other {# Stunden am {day} haben geantwortet, aber keine Zählung ergeben.}} + + + {count, plural, one {# Stunde der Woche hat geantwortet, aber keine Zählung ergeben.} other {# Stunden der Woche haben geantwortet, aber keine Zählung ergeben.}} + + + Jeden Tag am belebtesten, {window}. + + + Jeden Tag am belebtesten, {part}, {window}. + + + {days} am belebtesten, {window}. + + + {days} am belebtesten, {part}, {window}. + + + Verlässlich ruhig {who}, {window}. + + + Verlässlich ruhig {who} {part}, {window}. + + + jeden Tag + + + an jedem gemessenen Tag + + + an Wochentagen + + + an {days} + + + morgens + + + nachmittags + + + abends + + + nachts + + + morgens + + + nachmittags + + + abends + + + nachts + + + {list}, {next} + + + {first} und {second} + + + noch nicht genug Messungen + + + Für keine Stunde der Woche liegt bisher eine Messung vor. + + + {count, plural, one {# Stunde hat geantwortet und keine Zählung ergeben.} other {# Stunden haben geantwortet und keine Zählung ergeben.}} + + + Das Raster erscheint, sobald für jeden Wochentag eine Messung vorliegt. + + + {days, plural, one {Bisher an # von sieben Tagen gemessen; das Raster erscheint, sobald jeder Tag eine gemessene Stunde hat.} other {Bisher an # von sieben Tagen gemessen; das Raster erscheint, sobald jeder Tag eine gemessene Stunde hat.}} + + + {count, plural, one {# Stunde gemessen, darin war niemand verbunden.} other {# Stunden gemessen, in allen war niemand verbunden.}} + + + {count, plural, one {# Stunde gemessen, die belebteste mit {peak} am {day} um {time} UTC.} other {# Stunden gemessen, die belebteste mit {peak} am {day} um {time} UTC.}} + + + {count, plural, one {# weitere Stunde hat geantwortet und keine Zählung ergeben.} other {# weitere Stunden haben geantwortet und keine Zählung ergeben.}} + + + {day} — {facts} + + + {first}, {second} + + + den ganzen Tag null gemessen + + + Spitze {count} um {time} + + + niemand verbunden {window} + + + in keiner Stunde eine Zählung + + + {count, plural, one {# Stunde nicht gemessen} other {# Stunden nicht gemessen}} + + + {count, plural, one {# Stunde abgefragt, aber nicht zählbar} other {# Stunden abgefragt, aber nicht zählbar}} + + + Tag + + + ruhigste + + + belebteste + + + um + + + keine Zählung + + + Verbundene Spieler nach Tag, in UTC. {window}. + + + Zeiten in UTC · {window} + + + {weeks, plural, one {gleitender Durchschnitt über # Woche} other {gleitender Durchschnitt über # Wochen}} + + + gezählt, auch eine gemessene Null + + + abgefragt, keine Zählung ablesbar + + + keine Messung in dieser Stunde + + + Wann Spieler verbunden sind (UTC) + + + gezählt + + + wir sind hineingekommen und haben eine Zahl abgelesen, auch eine gemessene Null + + + wir sind hineingekommen, aber keine Zahl war ablesbar + + + für diese Stunde liegt uns keine Messung vor + + + Sprache + + + Sprache wechseln + + + Über mu*index + + + Jedes Spiel hier wurde von einer Maschine gemessen, die sich mit ihm verbunden hat, und jeder Wert nennt seine Herkunft und seinen Zeitpunkt. Diese Seite behandelt, was das belegt, was wir falsch machen, wessen Verzeichnisse wir lesen und wie sich der Crawler stoppen lässt. + + + Was hier eine Tatsache ist + + + Gemessen schlägt angegeben, und beides wird gezeigt. + + + Der MSSP-Bericht eines Spiels ist das Spiel, das sich selbst beschreibt. Der Telnet-Handshake ist das, was wir es haben tun sehen. Beides steht auf seiner Seite, versehen mit dem Wie und dem Wann. Wo beides sich widerspricht, zeigen wir den Widerspruch. + + + Eine Spielerzahl nennt ihre Herkunft. + + + Entweder ein WHO oder DOING, am Verbindungsbildschirm gelesen und von uns gezählt, oder das MSSP-Feld PLAYERS des Spiels selbst, das es veröffentlicht hat. Niemals zusammengeführt. + + + Eine Antwort, die wir nicht lesen können, ist unbekannt, niemals null. + + + Server passen ihre WHO-Kopfzeilen frei an, und ab einem gewissen Punkt kann unser Parser eine solche nicht mehr lesen. Das ist unzählbar, ein eigener Zustand. Eine gemessene Null — wir sind hineingekommen, und niemand war da — ist eine Zählung und wird als solche ausgegeben. + + + Erreichbar, niemals Uptime. + + + Wir öffnen in Abständen einen Socket von einem einzigen Host aus. Ein Spiel, zu dem wir keine Route finden, ist nicht erreichbar und dennoch völlig lebendig. Nichts hier behauptet die Uptime eines Spiels, denn nichts hier hat sie gemessen. + + + Eine Stunde ist gezählt, unzählbar oder nicht gemessen. + + + Das Aktivitätsraster hat drei Zustände. Der dritte ist leer und nennt keine Ursache: eine Stunde, die wir nicht erreichen konnten, und eine Stunde, die wir nie abgefragt haben, sind dieselbe Leerstelle, und keine von beiden ist die Ausfallzeit dieses Servers. + + + Was wir falsch machen, soweit wir es wissen + + + Die Schonfrist bis zum Archiv wird ab dem Tag gemessen, an dem wir Sie gefunden haben. + + + Ein Spiel, das nicht mehr antwortet, verlässt nach seiner Schonfrist das Standardverzeichnis: ein Viertel der erreichbaren Zeit, die wir abgefragt haben, mindestens 60 und höchstens 365 Tage. Ein Spiel, das seit 1995 läuft, beginnt am Tag seiner Entdeckung beim Minimum. Wir importieren nichts, um die Jahre vor unserer Ankunft aufzufüllen. + + + MSSP CREATED wird auf diese Schonfrist nicht angerechnet. + + + Es ist eine von Hand getippte Zeile in einer Konfigurationsdatei; sie anzurechnen würde die Archivschwelle manipulierbar machen. Sie wird als Angabe gezeigt und bringt nichts ein. + + + Ein beanspruchtes Spiel erhält das Maximum. + + + Der Nachweis von Serverzugriff ist das volle Jahr Schonfrist wert, gleich wie lange wir schon zusehen. + + + Alles hier ist ein einziger Host, der in Abständen hinsieht. + + + Ein Prozentsatz erreichbarer Zeit ist ein Anteil des von uns beobachteten Zeitraums, nie eines anderen. Keine Grafik hier füllt den Rest auf. + + + Nichts wird jemals gelöscht. + + + Das Archivieren nimmt ein Spiel aus dem Standardverzeichnis, aus den Ranglisten und aus der Zahl der heute aktiven Spiele heraus, und sonst nichts. Seine Seite, seine URL, seine Geschichte und seine Adresse bleiben, es wird weiter abgefragt, und eine einzige erfolgreiche Abfrage bringt es zurück. + + + Was diese Website nicht tut + + + Keine Stimmen, Sterne, Bewertungen oder Empfehlungen. + + + Ranglisten werden ausschließlich aus gemessenen Daten berechnet. Ein Verzeichnis, das danach sortiert, wer die meisten Klicks mobilisieren kann, beschreibt den Wahlkampf und nicht das Hobby — und genau daran sind die Vorgänger zugrunde gegangen. + + + Keine Foren, Rezensionen, Wikis, Kommentare oder Spielerprofile. + + + Einführendes Material — was ein MUSH ist, welche Codebase zu gemeinsamem Rollenspiel passt — wird geschrieben, namentlich gezeichnet und versioniert wie der Rest der Website. + + + Spielernamen werden nie gespeichert. + + + Eine WHO-Antwort wird im Arbeitsspeicher auf eine Zahl und die Form der Kopfzeile hin ausgewertet. Die Namen werden nicht festgehalten; Aggregate verwenden einen gesalzenen Hash mit rotierendem Salt. + + + Es wird keine absolute Spielerzahl veröffentlicht. + + + Anteile pro Codebase und pro Protokoll werden veröffentlicht: Ein Verhältnis über die gemessene Menge übersteht die Spiele, die wir nicht zählen können. „Wie viele Menschen spielen MU*“ übersteht das nicht, denn diese Zahl würde es nicht überstehen, zitiert zu werden. + + + Der Crawler und wie er sich stoppen lässt + + + Eine Abfrage ist eine Verbindung, die sich nie anmeldet. + + + Sie öffnet einen Socket, handelt Telnet-Optionen aus, liest den Verbindungsbildschirm, fragt MSSP über Option 70 an, sendet {commands} und trennt die Verbindung. Kein Charakter, keine Anmeldung, nichts auf der Gegenseite verändert. Ein Timeout begrenzt die Sitzung, damit eine hängengebliebene Abfrage keinen Verbindungsplatz belegt. + + + CRAWL DELAY hat Vorrang. + + + Ein Spiel, das in seinem MSSP-Bericht einen bevorzugten Mindestabstand nennt, bekommt ihn — in beide Richtungen vor unserem eigenen Zeitplan: 720 Stunden heißt monatlich, nicht wöchentlich. Ein verstummtes Spiel wird im längeren Abstand für immer weiter versucht; so trägt es sich selbst wieder ein, wenn es zurückkommt. + + + Eine per Verweis genannte Adresse wird geprüft, nie geglaubt. + + + MSSP erlaubt es einem Spiel, andere Spiele zu nennen. Jeder Name wird aufgelöst, bevor irgendetwas angewählt wird, und abgelehnt, sofern nicht jede Adresse, auf die er auflöst, global routbar ist. Eine gemischte Antwort lehnt das ganze Ziel ab. Unsere Ablehnung wird als unsere verbucht und erscheint nie als Ausfallzeit im Datensatz eines Spiels. + + + Verbindungsbildschirme werden gezeigt, weil sie an alle gesendet werden. + + + Ein Server malt seinen Verbindungsbildschirm ohne Anmeldung an jede anonyme Verbindung. Wir zeigen ihn als Beleg und kennzeichnen ihn. Auf Bitte hin verschwindet er. + + + Ein Stopp genügt, und wir hören auf — auf drei Wegen. + + + Veröffentlichen Sie {variable} 1 in Ihrem MSSP-Bericht, dann ist die Abfrage, die es liest, die letzte. Oder veröffentlichen Sie einen TXT-Eintrag unter {label}.your.host mit dem Inhalt „{value}“, wofür es keine MSSP-Unterstützung und kein Konto hier braucht. Oder schreiben Sie einer Person. Alle drei werden innerhalb eines Crawl-Zyklus befolgt, mit Datum und dem Gelesenen festgehalten und auch im Eintragungsformular durchgesetzt. + + + Das MSSP-Feld stoppt diesen Listener; der TXT-Eintrag stoppt den Host. + + + MSSP wird von dem Port veröffentlicht, der geantwortet hat, und spricht daher für diesen Port — beim MU*-Hosting laufen regelmäßig unabhängige Spiele auf einer Domain, und keines darf sein Nachbarspiel zum Schweigen bringen. Ein TXT-Eintrag umfasst jeden Port, sofern er nicht einen einzelnen nennt, etwa „{value}=4201“. Alles dort, was wir nicht als Portliste lesen können, meint den ganzen Host; deshalb funktioniert auch „{value}=all“. + + + Der Weg über DNS ist der, den Sie ohne Rückfrage bei uns rückgängig machen können. + + + Ein TXT-Eintrag lässt sich lesen, ohne sich mit einem Server zu verbinden, der uns das untersagt hat; deshalb lesen wir ihn vor jeder Anwahl neu. Löschen Sie ihn, und wir wählen binnen einer Woche wieder an. Ein MSSP-Feld lässt sich nicht neu lesen, ohne genau das zu tun, was Sie uns untersagt haben; deshalb gelten ein Ausschluss per MSSP und schriftliche Bitten so lange, bis Sie etwas anderes sagen. Dieser TXT-Abruf ist alles, was eine ausgeschlossene Adresse noch bekommt: Er berührt Ihren Nameserver, nie Ihr Spiel. + + + Aufhören ist kein Löschen, und es ist keine Ausfallzeit. + + + Ein Spiel, das sich ausschließen lässt, behält seine Seite, seine Adresse und alles, was wir vor der Bitte gemessen haben. Nur neue Daten hören auf: Das Aktivitätsraster gewinnt keine Stunden mehr hinzu und nennt keine Ursache, denn unsere Entscheidung, nicht mehr anzuklopfen, ist eine Tatsache über uns. Sie wird bei dem Crawl festgehalten, der nicht stattgefunden hat, und im Register derer, die darum gebeten haben. + + + Wenn Aufhören nicht genügt, kann auch der Eintrag verschwinden. + + + Sobald wir für jede Adresse, unter der Ihr Spiel antwortet, aufgehört haben, bietet Ihre Übersicht eine weitere Möglichkeit: es aus dem Verzeichnis, aus den Ranglisten und aus der Tageszahl herauszunehmen. Die Seite und jede Adresse, die es je hatte, antworten weiterhin, und nichts wird gelöscht — es ist nur kein Ort mehr, an dem man beim Stöbern ankommt. Dafür braucht es einen bestätigten Anspruch, denn es ist eine Entscheidung über Ihr Spiel, und wir halten fest, wer sie getroffen hat. Und eine Abfrage macht sie rückgängig: Nehmen Sie Ihren Ausschluss zurück, dann bringt die nächste Anwahl, die eine Antwort erhält, Sie wieder ins Verzeichnis, ohne dass Sie uns ein zweites Mal fragen müssen. + + + Der Crawler nennt sich {name}, wenn ein Server fragt, was er ist. + + + Der Crawler ist so konfiguriert, dass er sich {name} nennt, kann das aber noch nicht mitteilen. Seine Telnet-Bibliothek bietet einem Client keine Möglichkeit, den Terminaltyp zu setzen; deshalb steht in Ihren Logs der Standardwert dieser Bibliothek, und NEW-ENVIRON wird aus der Umgebung des Crawler-Hosts beantwortet. Beides sind Lücken in der Bibliothek und von uns dort zu beheben. Bis dahin erkennen Sie eine Abfrage an ihrer Form: eine Verbindung, keine Anmeldung, ein kurzer, nur lesender Befehlssatz, weg. + + + Crawler + + + Kontakt + + + Crawler: {name} + + + Kontakt: {url} + + + — Platzhalter; diese Installation hat keine Kontaktadresse gesetzt + + + Es ist keine Kontaktadresse konfiguriert; die obige ist daher ein Platzhalter und antwortet niemandem. + + + Woher die Liste der Spiele stammt + + + Wir übernehmen Adressen. Sonst nichts. + + + Ein Backfill übernimmt einen Host und einen Port. Keine Spielerzahlen, keine Erreichbarkeitsverläufe, keine Beschreibungen, keine Felder und keinen Vermerk, von welcher Website eine Adresse stammt. + + + Bewusst weniger, als diese Websites hergeben. + + + Mehrere davon halten jahrelange, datierte Spielerzahlen bereit. Diese zu importieren würde die Aktivitätsraster genau der Spiele füllen, die schon jemand anderes beobachtet hat, und die zentrale Behauptung dieser Website auf die Abfragen eines Dritten stützen. + + + Die Herkunft eines Spiels ist keine einzelne Tatsache. + + + Jedes Spiel, das einen Eintrag wert ist, steht in mehreren dieser Verzeichnisse; „importiert aus“ würde also nur den Abruf nennen, der zuerst lief. Dass ein Spiel existiert, ist öffentlich bekannt; wo wir es gelesen haben, fügt nichts hinzu und ist der Teil fremder Arbeit, auf den wir am wenigsten Anspruch haben. + + + Die Website eines anderen zu lesen bleibt: die Website eines anderen zu lesen. + + + Wir bitten vor dem Scrapen um einen Massenexport oder einen dokumentierten Endpunkt, lesen zuerst robots.txt und drosseln Scrapes stark. Eine Quelle, die das Einverständnis ihrer Betreuung braucht, wird erst abgerufen, wenn eine Person bestätigen kann, dass gefragt wurde. + + + gelesen — nur Adressen + + + nicht gelesen — Erlaubnis ausstehend + + + Eine Seite, eine Anfrage. Veröffentlicht von einem Crawler, der sich mit jedem Spiel verbindet und ausgibt, was er gelesen hat. + + + Die MSDP-Liste desselben Crawlers. Fast eine Teilmenge ihres MSSP-Gegenstücks; gelesen wegen der wenigen Adressen, die sie erreicht und das andere nicht. + + + Veröffentlicht seinen gesamten Katalog auf einer Seite, das Lesen kostet also eine einzige Anfrage. Unsere größte Quelle für Adressen und für keinerlei Messungen. + + + Eine Übersichtsseite und je eine Seite pro Welt, also ein Scrape statt eines Exports. Am 30. Juli 2026 haben wir 143 ihrer Seiten abgerufen, im Abstand von fünfzehn Sekunden und unter Beachtung von robots.txt, aber bevor ihnen jemand geschrieben hatte. Das hätte nicht geschehen dürfen. Die Sperre verlangt nun eine Person, die bestätigt, dass die Betreuung gefragt wurde. + + + Implementiert, getestet, nie ausgeführt. Die stärkste Quelle hier in jeder Hinsicht außer der Erlaubnis; es wird nichts abgerufen, bevor ihnen jemand geschrieben hat. + + + Lizenz + + + Der Code steht unter MIT. + + + Die Website, der Crawler und die Parser sind Open Source unter der MIT-Lizenz. + + + Die Lizenz für die Daten ist eine offene Frage. + + + Eine vom Code getrennte Entscheidung, und noch nicht getroffen. Die Bedingungen unten sind die derzeitige Antwort dieser Installation, nicht die feststehende Haltung des Projekts. Dass ein konkurrierendes Verzeichnis den gesamten Katalog übernimmt, gilt hier als Erfolg; was auch immer festgelegt wird, wird einem solchen nicht im Weg stehen. + + + Code + + + Daten, so wie diese Installation sie ausliefert + + + Namensnennung als + + + Code: {licence} + + + Daten: {licence} + + + Namensnennung als: {credit} + + + (was diese Installation ausliefert. Die eigene Antwort des Projekts ist noch offen.) + + + Ein Spiel eintragen + + + Uns mitteilen, wo ein Spiel ist. Ein Host und ein Port sind das ganze Formular; alles andere auf dieser Website misst unser eigener Crawler. + + + Host + + + Port + + + mud.example.org, oder mud.example.org:4201 einfügen und den Port leer lassen + + + Eintragen + + + Das Eintragen braucht eine Datenbank, und diese Website läuft auf den Demodaten. Es gibt kein Crawl-Register zum Hineinschreiben, deshalb fehlt das Formular, statt still nichts zu tun. + + + Nicht hier + + + Was mit einer Adresse geschieht + + + Wir lösen die Adresse auf, bevor wir sie anwählen, und lehnen alles ab, was außerhalb des öffentlichen Internets auflöst. Das ist eine Entscheidung über unseren eigenen Socket, nie eine Tatsache über ein Spiel. + + + Wenn die Betreiber dieses Hosts uns gebeten haben, ihn nicht zu crawlen, nehmen wir die Adresse nicht an, gleich wer sie einträgt. Fremde können Ihr Spiel nicht zurück auf diese Website bringen. + + + Antwortet sie, lesen wir, was der Server über sich selbst sagt, und lesen es nach seinem eigenen Zeitplan für immer weiter. Eine Adresse muss nur einmal genannt werden. + + + Nichts erscheint auf der Website, bis jemand nachweist, dass er es betreibt. Ein Anspruch braucht einen Passkey und eine im Spiel selbst veröffentlichte Zeile. + + + Eine Adresse, die wir bereits haben, fällt mit dem bestehenden Eintrag zusammen. Zweimal senden erzeugt keinen zweiten Eintrag und zieht keine Abfrage vor. + + + diese Adresse + + + Im Register. + + + {address} wird im nächsten Crawl-Zyklus angewählt, danach für immer nach eigenem Zeitplan. Sobald jemand nachweist, dass er es betreibt, erscheint es hier — kommen Sie mit derselben Adresse zu diesem Formular zurück, dann bekommen Sie hier den Link dazu. + + + Wir haben es, nicht beansprucht. + + + {address} messen wir bereits. Es bleibt von der Website fern, bis jemand nachweist, dass er es betreibt. Sind Sie das, geht es hier hinein. + + + Das haben wir schon. + + + {address} ist ein Spiel, das wir bereits messen. Es wurde nichts angelegt und nichts geändert. + + + Diese Adresse haben wir schon. + + + {address} ist uns bereits bekannt. Es wurde nichts angelegt und nichts geändert. + + + Wartet bereits. + + + {address} steht im Crawl-Register und hat noch nicht geantwortet. Erneutes Senden zieht sie nicht vor: Ein Ziel behält seinen eigenen Zeitplan, damit niemand uns am Server eines anderen antreiben kann. + + + Keine Adresse, die wir anwählen können. + + + Ein Host braucht einen Punkt oder einen Doppelpunkt, und ein Port ist eine Zahl zwischen 1 und 65535. Beide Felder ausfüllen oder mud.example.org:4201 in das erste einfügen. + + + Das können wir nicht anwählen. + + + Für {address} führen drei Dinge zu dieser Antwort: Der Name löst womöglich nicht auf, er löst womöglich außerhalb des öffentlichen Internets auf, oder die Betreiber dieses Hosts haben uns gebeten, fernzubleiben. Wir sagen bewusst nicht, welches davon, denn das für Fremde zu beantworten kartiert ein Netz von außen. Über die Adresse wurde nichts festgehalten; die Entscheidung war unsere und ist als unsere verbucht. + + + Genug für jetzt. + + + Dieses Formular ist pro Absender begrenzt, und Sie haben die Grenze erreicht. Kommen Sie in einer Stunde wieder. Nichts ging verloren — was wir angenommen haben, steht bereits im Register. + + + dieses Spiel beanspruchen + + + Anmelden + + + Melden Sie sich mit einem Passkey an, um ein Spiel zu beanspruchen, das Sie betreiben. Es gibt kein Passwort, das verloren gehen, und keines, das gestohlen werden kann. + + + Das Beanspruchen braucht eine Datenbank, und diese Website läuft auf den Demodaten. Es gibt nichts, wo man sich anmelden könnte. + + + Die Anmeldung ist ein Passkey. + + + Ihr Gerät oder Ihr Passwortmanager hält den privaten Schlüssel; wir halten nur die öffentliche Hälfte. Kein Passwort, keine E-Mail. + + + Mit einem Passkey anmelden + + + Die einzige Seite hier, die JavaScript braucht. Passkeys funktionieren ohne es nicht. + + + Noch kein Konto? + + + Sie brauchen eines nur, um ein Spiel zu beanspruchen, das Sie betreiben. Wählen Sie einen Namen, unter dem Sie erscheinen — eine Beschriftung neben Ihrem Anspruch, kein echter Name. + + + Name + + + z. B. corvid-admin + + + Konto mit einem Passkey anlegen + + + Was wir speichern + + + Den Namen, den Sie gewählt haben. + + + Den öffentlichen Schlüssel jedes Passkeys, den Sie registrieren, und den Namen, den Ihr Gerät ihm gegeben hat. + + + Welche Spiele Sie beansprucht haben und wann. + + + Keine E-Mail-Adresse, kein Passwort, kein an Ihr Konto gebundenes IP-Protokoll. Verlieren Sie alle Passkeys, können Sie einen frischen Anspruchs-Token in Ihrem Spiel veröffentlichen und von vorn beginnen: Der Beweis ist das Spiel, nicht das Konto. + + + {day}. {month} {year} + + + {date} {time} UTC + + + jetzt + + + {count, plural, one {# Min} other {# Min}} + + + {count, plural, one {# Std} other {# Std}} + + + {count, plural, one {# T} other {# T}} + + + {count, plural, one {# W} other {# W}} + + + {count, plural, one {# Mon} other {# Mon}} + + + {count, plural, one {# J} other {# J}} + + + gerade eben + + + {count, plural, one {vor # Min} other {vor # Min}} + + + {count, plural, one {vor # Std} other {vor # Std}} + + + {count, plural, one {vor # T} other {vor # T}} + + + {count, plural, one {vor # W} other {vor # W}} + + + {count, plural, one {vor # Mon} other {vor # Mon}} + + + {count, plural, one {vor # J} other {vor # J}} + + + gerade eben + + + {count, plural, one {vor # Min} other {vor # Min}} + + + {count, plural, one {vor # Std} other {vor # Std}} + + + {count, plural, one {vor # T} other {vor # T}} + + + {count, plural, one {vor # W} other {vor # W}} + + + {count, plural, one {vor # Mon} other {vor # Mon}} + + + {count, plural, one {vor # J} other {vor # J}} + + + {age}, {stamp} + + + , {stamp} + + + {value} — {how}, Quelle: {source}, zuletzt bestätigt {date} + + + {value} — {how}, Quelle: {source}, zuletzt bestätigt {date} (Aktualisierung überfällig) + + + ({how}, {age}) + + + ({how}, {age}, überfällig) + + + vom Betreiber angegeben + + + Redaktion + + + der Telnet-Handshake + + + der Betreiber + + + WHO + + + I3 + + + MSSP + + + INFO + + + die I3-Mudlist + + + der Verbindungsbildschirm + + + Das Ökosystem + + + Anteile, niemals Gesamtzahlen. Wir veröffentlichen keine Zahl dazu, wie viele Menschen MU* spielen: Ein Verhältnis über die von uns gemessenen Spiele übersteht die, die wir nicht erreichen können; eine Kopfzahl nicht. + + + {count, plural, one {{value} Spiel eingetragen} other {{value} Spiele eingetragen}} + + + {count, plural, one {{value} Spiel, dessen Handshake wir abgeschlossen haben} other {{value} Spiele, deren Handshake wir abgeschlossen haben}} + + + {count, plural, one {{value} Spiel, dessen MSSP-Bericht uns vorliegt} other {{value} Spiele, deren MSSP-Bericht uns vorliegt}} + + + Ältester Handshake hier: bestätigt vor {age}. + + + {count, number} von {total, number} ({fraction, number, ::percent .0}) + + + {count, number} von {total, number} — noch nichts gemessen + + + {fraction, number, ::percent .0} + + + Codebases + + + Von {listed, plural, one {# eingetragenen Spiel} other {# eingetragenen Spielen}} haben uns {identified, number} mitgeteilt, was sie ausführen, und jeder Anteil unten bezieht sich auf diese {identified, number}. Eine Codebase, die wir nicht lesen konnten, bleibt aus dem Nenner heraus und wird nie als etwas anderes gezählt. + + + Noch kein eingetragenes Spiel hat uns seine Codebase mitgeteilt. + + + {share} führen eine Codebase aus, die kein anderes eingetragenes Spiel ausführt — je ein Spiel, was ein Name ist und kein Anteil. Sie stecken im Nenner oben und sind aus den Balken herausgefaltet, nicht entfernt: + + + Abstammungen + + + Dieselben Spiele, gruppiert nach der Tradition, von der ihr Server abstammt — unsere Lesart der Codebase, nicht etwas, das ein Spiel veröffentlicht hätte. Kein Spiel meldet „MUSH“: MSSP kennt keinen solchen Wert, und der größte Teil der MUSH-Welt veröffentlicht überhaupt kein MSSP, deshalb lässt sich die Frage nur so stellen. + + + Noch kein eingetragenes Spiel führt eine Codebase aus, die wir einer Abstammung zuordnen. + + + {count, plural, one {# dieser Spiele führt eine Codebase aus} other {# dieser Spiele führen Codebases aus}}, die wir keiner Abstammung zuordnen — mehrere sagen das selbst und veröffentlichen {family}. Sie stecken im Nenner oben und in niemandes Anteil. + + + Protokolle + + + Jede gemessene Zahl unten ist als Untergrenze zu lesen. MSSP fragen wir namentlich an, dort ist Schweigen also eine Antwort. Sonst wird hier nichts angefragt, und ein Server kann ein Protokoll unterstützen, ohne es je anzubieten. + + + {instrument} ist die einzige Zeile unten, die keine Untergrenze ist: Wir fragen jeden Server namentlich danach, die Spiele, die es nicht angeboten haben, wurden also gefragt und haben abgelehnt. Es ist zugleich die einzige ohne angegebene Zahl, denn jedes Spiel, dessen Bericht uns vorliegt, unterstützt es durch Vorführung, und eine Zählung derer, die es zusätzlich aufgeführt haben, würde daran nur eine Gewohnheit messen. + + + Uns liegen {reports, number} Berichte vor, und {offered, plural, one {# Spiel bietet} other {# Spiele bieten}} MSSP heute an: Die übrigen {gap, number} haben nach unserem Lesen aufgehört, einen zu veröffentlichen, und ein Bericht wird nicht verworfen, weil er nicht mehr neu ausgegeben wird. + + + Verbreitung der Protokolle. Gemessen ist, was ein Server in einem abgeschlossenen Handshake angeboten hat; angegeben ist, was sein MSSP behauptet. Zwei Mengen von Spielen, also zwei Nenner. + + + Protokoll + + + gemessen — von {basis} + + + angegeben — von {basis} + + + nicht gemessen — nie beobachtet + + + {share} · {declined, plural, one {# Spiel hat} other {# Spiele haben}} auf Nachfrage abgelehnt + + + {share} · bei {unobserved, plural, one {# Spiel} other {# Spielen}} weder angeboten noch nachgefragt + + + {share} · {declined, plural, one {# Spiel hat} other {# Spiele haben}} auf Nachfrage abgelehnt · bei {unobserved, plural, one {# Spiel} other {# Spielen}} weder angeboten noch nachgefragt + + + nicht nachgefragt — jeder Bericht hier ist die Antwort + + + Verbreitung im Zeitverlauf + + + Jeder Punkt ist ein Anteil an den Spielen, die wir an jenem Tag gemessen hatten; diese Linie bewegt sich also aus zwei Gründen: weil ein Spiel ändert, was es anbietet, und weil sich die Menge der messbaren Spiele darum herum ändert. Nur das Erste ist Verbreitung. Die Zahl der Wechsel unten ist der Teil, der rein aus Spielen besteht, die es sich anders überlegt haben. + + + Gemessener Anteil je Protokoll, älteste Messung zuerst + + + damals + + + jetzt + + + nicht gemessen + + + Eine Momentaufnahme, keine Kurve + + + Eine Momentaufnahme dessen, was wir jetzt messen können. Eine Verbreitungskurve zeichnet Spiele, die es sich anders überlegen; wir halten eine Änderung fest, wenn sie geschieht, die Kurve wird also zeichenbar, sobald genug davon erfasst sind. Zu zeichnen, wann wir jedes Spiel zuerst erreicht haben, würde den Crawl messen und nicht das Hobby. + + + {count, plural, one {# geänderte Fähigkeit} other {# geänderte Fähigkeiten}} bisher erfasst — das Material, aus dem eine Kurve gezeichnet wird. + + + Noch hat sich keine gemessene Fähigkeit geändert, es gibt also nichts zu zeichnen. + + + Gemessen bezieht sich auf {measured}; angegeben auf {declared}. Zwei Mengen von Spielen, also zwei Nenner. + + + {listed} · {handshakes} · {mssp}. + + + Der älteste Handshake in diesem Bild wurde zuletzt vor {age} bestätigt. + + + Dieselben Spiele, gruppiert nach der Tradition, von der ihr Server abstammt. Das ist {evidence} — {meaning} — und nicht etwas, das ein Spiel veröffentlicht hätte: Kein Spiel meldet „MUSH“, denn MSSP kennt keinen solchen Wert, und der größte Teil der MUSH-Welt veröffentlicht überhaupt kein MSSP. + + + gemessen: {value} + + + angegeben: {value} + + + Ranglisten + + + Ausschließlich aus gemessenen Daten berechnet. Keine Stimmen, Sterne oder Bewertungen, niemals. Nichts hier ordnet nach Qualität. Die haben wir nicht gemessen. + + + Am belebtesten, nach gemessenen gleichzeitigen Spielern + + + Zeitraum der Rangliste + + + {days, plural, one {# Tag} other {# Tage}} + + + Median der Spielerzahlen, die wir in {days, plural, one {dem letzten # Tag} other {den letzten # Tagen}} gemessen haben. + + + Noch kein Spiel hat die {samples, number} Zählungen über {days, plural, one {# Tag} other {# Tage}}, die ein Median braucht. + + + {eligible, plural, one {# von {listed, number} Spielen hat} other {# von {listed, number} Spielen haben}} die nötigen {samples, number} Zählungen über {days, plural, one {# Tag} other {# Tage}}. + + + Eine gemessene Null zählt; eine unlesbare Zählung nicht. + + + Eine Woche sagt, wer jetzt belebt ist; ein Quartal sagt, wer belebt gewesen ist. Das sind verschiedene Fragen, und ein Spiel kann die eine anführen und die andere nicht. Tage sind ganze Tage, in UTC. + + + Noch hat kein eingetragenes Spiel genug Zählungen für eine Rangfolge — eine Aussage darüber, wie lange wir schon messen, nicht darüber, wie belebt irgendjemand ist. + + + Spiele, geordnet nach dem Median der Spielerzahlen, die wir in {days, plural, one {dem letzten # Tag} other {den letzten # Tagen}} gemessen haben. Spiele mit demselben Median teilen sich einen Platz; nichts hier entscheidet den Gleichstand. + + + # + + + Spiel + + + Median + + + Spitze + + + Zählungen + + + gemessene Tage + + + Längste durchgehende Erreichbarkeit + + + Jede Abfrage seit dem genannten Datum hat das Spiel erreichbar vorgefunden. Erreichbar, nicht online: Wir messen einen Socket von einem einzigen Host aus, und ein Spiel, zu dem wir keine Route finden, ist dennoch völlig lebendig. Eine solche Strecke kann nicht länger sein, als wir zusehen, deshalb ist das Datum die Tatsache und die Dauer folgt daraus. + + + Zurzeit ist kein eingetragenes Spiel durchgehend erreichbar. + + + Spiele, bei denen jede Abfrage seit dem genannten Datum sie erreichbar vorgefunden hat. Spiele, die seit demselben Datum erreichbar sind, teilen sich einen Platz; nichts hier entscheidet den Gleichstand. + + + erreichbar seit + + + das sind + + + Archivierte Spiele sind aus beiden Tabellen heraus und aus sonst nichts; eine einzige erfolgreiche Abfrage bringt sie zurück. + + + Am belebtesten — Median der gemessenen Spieler, {days, plural, one {letzter # Tag} other {letzte # Tage}} + + + Zeiträume: + + + dieser + + + Median {median, number} · Spitze {peak, number} · {samples, plural, one {# Zählung} other {# Zählungen}} an {days, number} von {window, number} Tagen + + + bei jeder Abfrage seit {date} erreichbar · {duration} + + + Das Archiv + + + Spiele, die nicht mehr antworten. Nichts wurde gelöscht. Sie werden weiterhin wöchentlich abgefragt, und eine einzige erfolgreiche Abfrage bringt ein Spiel noch am selben Tag zurück ins Verzeichnis. + + + das Archiv durchsuchen + + + Archivierte Spiele suchen + + + Name, Codebase oder Beschreibung + + + zeigen + + + {count, plural, =0 {Keine archivierten Spiele} one {# archiviertes Spiel} other {# archivierte Spiele}} + + + archiviert + + + zuletzt erreichbar + + + nachweislich aktiv + + + (vor {age}) + + + Keine Treffer. + + + nie, in nichts, was wir gemessen haben + + + keine erreichbare Zeit gemessen + + + unbekannt + + + {years, number, ::.#} Jahre + + + {days, plural, one {# Tag} other {# Tage}} + + + {from} – {to} · {span} + + + {count, plural, one {# Monat} other {# Monate}} + + + {count, plural, one {# Jahr} other {# Jahre}} + + + {count, plural, one {# Spiel passt} other {# Spiele passen}} zu „{query}“ + + + {count, plural, one {# Spiel} other {# Spiele}} + + + Zuletzt erreichbar: + + + Nachweislich aktiv: + + + {value} gemessener erreichbarer Zeit + + + Laufzeit: + + + Codebase: + + + zufälliges Spiel + + + {d, date, medium} — durchschnittlich {typical}, {low}–{high} über {probes, plural, one {# Abfrage} other {# Abfragen}} + + + {d, date, medium} — {count, plural, =0 {0 Spieler} one {# Spieler} other {# Spieler}}, in jeder von {probes, plural, one {# Abfrage} other {# Abfragen}} + + + {d, date, medium} — abgefragt, keine Zählung ablesbar + + + {d, date, medium} — keine Messung + + + Typischerweise {typical} verbunden, Spitze {peak}, an {counted} von {days, plural, one {# Tag} other {# Tagen}}. + + + Über den Zeitraum hinweg gleichbleibend. + + + Vom Anfang des Zeitraums bis zum Ende um etwa {change, number, percent} gestiegen. + + + Vom Anfang des Zeitraums bis zum Ende um etwa {change, number, percent} gefallen. + + + In diesem Zeitraum abgefragt, und aus nichts davon war eine Spielerzahl ablesbar. + + + Keine Messung in diesem Zeitraum. + + + In diesem Zeitraum nichts gezählt. + + + {from, date, d MMM}–{to, date, d MMM} + + + {d, date, d MMM} + + + {span}: typischerweise {typical}, Spitze {peak}, {days, plural, one {# Tag} other {# Tage}} gezählt + + + {span}: abgefragt, keine Zählung ablesbar + + + {span}: nicht gemessen + + + {count, plural, one {# Tag ohne Zählung abgefragt} other {# Tage ohne Zählung abgefragt}} + + + {count, plural, one {# Tag nicht gemessen} other {# Tage nicht gemessen}} + + + {line}, {clause} + + + Tage in UTC · {from, date, medium} – {to, date, medium} + + + {value} ganz oben + + + {counted} von {days, plural, one {# Tag} other {# Tagen}} gezählt + + + {d, date, MMM} + + + {d, date, MMM yyyy} + + + Mittel der an dem Tag gelesenen Zählungen + + + bis zur höchsten Zählung des Tages + + + niedrigste bis höchste Zählung des Tages + + + abgefragt, keine Zählung ablesbar + + + nicht gemessen — gar kein Balken + + + nicht gemessen — eine Lücke in der Linie + + + Zeitraum des Verlaufs + + + Form des Verlaufs + + + {days, plural, one {# Tag} other {# Tage}} + + + ← früher + + + später → + + + Linie + + + Balken + + + Wie viele, im Zeitverlauf + + + {from, date, medium} – {to, date, medium}, UTC + + + früher + + + eine Woche wird über die in ihr gezählten Tage zusammengefasst; eine Woche ohne solche sagt das + + + erreichbar · {days, plural, one {letzter # Tag} other {letzte # Tage}} + + + erreichbar + + + längste Unterbrechung + + + letzte Ursache + + + keine im Zeitraum + + + nichts festgehalten + + + {days, plural, one {vor # Tag} other {vor # Tagen}} + + + heute + + + erreichbar + + + eingeschränkt — geantwortet, nicht abgeschlossen + + + nicht erreichbar + + + nicht gemessen + + + erreichbar + + + eingeschränkt + + + nicht erreichbar + + + nicht gemessen + + + {d, date, d MMM} — den ganzen Tag erreichbar + + + {d, date, d MMM} — eingeschränkt ({cause}): geantwortet, nicht abgeschlossen + + + {d, date, d MMM} — nicht erreichbar ({cause}) + + + {d, date, d MMM} — nicht gemessen; wir haben dieses Spiel damals noch nicht beobachtet + + + {days, plural, one {Für den letzten # Tag noch nicht gemessen.} other {Für die letzten # Tage noch nicht gemessen.}} + + + {days, plural, one {Erreichbar in {percent} des letzten Tages.} other {Erreichbar in {percent} der letzten # Tage.}} + + + {days, plural, one {Erreichbar in {percent} des einen Tages, den wir gemessen haben.} other {Erreichbar in {percent} der # Tage, die wir gemessen haben.}} + + + {days, plural, one {Die Erreichbarkeit des letzten Tages ist noch nicht gemessen.} other {Die Erreichbarkeit der letzten # Tage ist noch nicht gemessen.}} + + + An keinem Tag im Zeitraum war das Spiel nicht erreichbar. + + + An keinem von uns gemessenen Tag war das Spiel nicht erreichbar. + + + {count, plural, one {# Tag nicht erreichbar.} other {# Tage nicht erreichbar.}} + + + {count, plural, one {# Tag eingeschränkt — wir sind hineingekommen und konnten nicht abschließen.} other {# Tage eingeschränkt — wir sind hineingekommen und konnten nicht abschließen.}} + + + Längste Unterbrechung {duration}. + + + Längste Unterbrechung {duration} ({cause}). + + + {count, plural, one {# Tag liegt vor allem, was wir gemessen haben.} other {# Tage liegen vor allem, was wir gemessen haben.}} + + + {from, date, d MMM} – {to, date, d MMM} + + + {d, date, d MMM} + + + {range}: {count, plural, one {# Tag} other {# Tage}} {word} + + + {range}: {count, plural, one {# Tag} other {# Tage}} {word} ({cause}) + + + Erreichbar + + + {days, plural, one {letzter # Tag} other {letzte # Tage}} + + + Erreichbar: {percent} {days, plural, one {des letzten Tages} other {der letzten # Tage}} + + + Längste Unterbrechung: {duration} + + + dns hat nicht aufgelöst + + + Verbindung abgelehnt + + + tls fehlgeschlagen + + + Zeitüberschreitung + + + Handshake hängen geblieben + + + keine Ursache festgehalten + + + Der Betreiber hat uns gebeten, den Verbindungsbildschirm dieses Spiels nicht erneut zu veröffentlichen. + + + Von diesem Spiel wurde kein Verbindungsbildschirm aufgezeichnet. + + + {count, plural, one {Nur # Zeile kam zurück — zu wenig zum Anzeigen.} other {Nur # Zeilen kamen zurück — zu wenig zum Anzeigen.}} + + + so, wie der Server ihn gesendet hat + + + {columns}×{rows} + + + {columns}×{rows}, doppelte Breite + + + 16-Farben-SGR + + + keine Farbe + + + gelesen als {charset} + + + aufgezeichnet + + + eingefroren — der letzte Bildschirm, den wir gesehen haben + + + ASCII-Grafik: der Verbindungsbildschirm von {game}. Sein Text steht unten unter „als Text lesen“. + + + ASCII-Grafik: der Verbindungsbildschirm dieses Spiels. Sein Text steht unten unter „als Text lesen“. + + + {count, plural, one {Verbindungsbildschirm: # Zeile, nur Text} other {Verbindungsbildschirm: # Zeilen, nur Text}} + + + {count, plural, one {Verbindungsbildschirm: # Zeile, nur Text, gelesen als {charset}} other {Verbindungsbildschirm: # Zeilen, nur Text, gelesen als {charset}}} + + + das Spiel gibt es an, und der Server hat es in keinem Handshake je angeboten. + + + der Server bietet es an, und die eigene Angabe des Spiels sagt, dass er es nicht tut. + + + Meist ein veraltetes, von Hand getipptes Feld und keine Lüge. Gezeigt, weil ein Client sich auf das, worin beide sich widersprechen, nicht verlassen sollte. + + + Nicht gefunden + + + Kein Spiel unter dieser Adresse. Schreibweise prüfen. + + + zuletzt geantwortet + + + {count, plural, one {# ist über das Aktualisierungsfenster hinaus. Alt, nicht falsch.} other {# sind über das Aktualisierungsfenster hinaus. Alt, nicht falsch.}} + + + Die eigene Verweisliste dieses Spiels nennt: + + + Genannt von der Verweisliste von: + + + seit {date} + + + nicht mehr gelistet, zuletzt gesehen + + + Zuletzt geantwortet am {date}, vor {ago}. + + + Wird weiterhin wöchentlich abgefragt; diese Seite aktualisiert sich an dem Tag, an dem es antwortet. + + + {span} nachweislich aktiv + + + Nicht im Verzeichnis, nicht in den Ranglisten und nicht in der Tageszahl: Wir halten diese Adresse nicht für ein Spiel, das sich spielen lässt. Alles unten ist das, was sie uns mitgeteilt hat, unverändert. + + + Unser Grund: {why} + + + Nicht im Verzeichnis, nicht in den Ranglisten und nicht in der Tageszahl, auf Bitte der Leute, die es betreiben. Alles unten bleibt erhalten, wie es war, diese Seite und jede Adresse, die es je hatte, antworten weiterhin, und wir wählen es nicht an. + + + Jetzt verbunden: {count} + + + Fähigkeiten ({disagreeing} von {total} widersprechen) + + + Vom Spiel angegeben + + + Verbindungsbildschirm + + + Was sich geändert hat + + + gemessen + + + angegeben + + + ** widersprechen + + + Verbindungen + + + erreicht + + + Suche + + + archiviert + + + Erwachseneninhalte + + + einbezogen + + + keine Zählung + + + keine aktuelle Zählung, und nichts hier nennt einen Grund + + + Jetzt verbunden: keine Zählung (nichts hier nennt einen Grund) + + + Crawler aktiv · letzte Abfrage {age} + + + Crawler im Leerlauf · letzte Abfrage {age} + + + hier ist noch keine Abfrage abgeschlossen + + + in diesem Zyklus nichts fällig + + + {considered, plural, one {# fällig} other {# fällig}} · {answered, plural, one {# geantwortet} other {# geantwortet}} · {failed, plural, one {# fehlgeschlagen} other {# fehlgeschlagen}} + + + {targets, plural, one {# Adresse im Register} other {# Adressen im Register}}, {due, plural, one {# jetzt fällig} other {# jetzt fällig}} + + + {page} — {site} + + + Ein Verzeichnis des MU*-Hobbys — MUSHes, MUDs, MUCKs, MOOs — in dem jede Tatsache ihre Herkunft und ihr Alter nennt. + + + Demodaten — nichts davon wurde gemessen. {description} + + + {site} — gemessen, nicht behauptet + + + {title} auf {site} + + + Spiele + + + Das Archiv + + + Ranglisten + + + Das Ökosystem + + + Referenz + + + Über uns + + + Nicht gefunden + + + Zufälliges Spiel + + + Ihre Spiele + + + {game} beanspruchen + + + Jedes MU*, das wir erreicht haben, gefiltert nach Gemessenem: Codebase, Protokolle aus dem Handshake, TLS, Kodierung, Sprache, letzter Zugang. + + + Verstummte Spiele, aufbewahrt. Jedes behält Seite, Verlauf und URL, wird wöchentlich abgefragt und kehrt bei einer erfolgreichen Verbindung zurück. + + + Am belebtesten, am besten erreichbar, am längsten laufend — allein aus Messungen berechnet. Keine Stimmen, Sterne oder Bewertungen auf dieser Website. + + + Codebase-Anteile und Protokollverbreitung über die von uns gemessenen Spiele, das Angebotene neben dem Angegebenen. Anteile, niemals Gesamtzahlen. + + + Von Hand geschriebene Seiten zu den Codebases, Clients und Protokollen des MU*-Hobbys, verlinkt mit Zahlen aus dem Crawl. + + + Wie dieser Katalog entsteht: was der Crawler tut, was er zu tun verweigert und wie er sich stoppen lässt. + + + Kein Spiel unter dieser Adresse. Hier wird nichts gelöscht, ein Spiel, das einmal unter dieser URL lag, liegt also weiterhin dort — Schreibweise prüfen. + + + Ein Spiel aus dem Katalog, zufällig gewählt und nie zweimal dasselbe. + + + Die Einträge, die Sie beansprucht haben, und was ein Anspruch Ihnen zu ändern erlaubt. + + + Weisen Sie nach, dass Sie dieses Spiel betreiben, indem Sie einen Token dort veröffentlichen, wo nur sein Betreiber ihn hinstellen könnte. + + + Archiviert — zuletzt erreichbar {age}, wird weiter abgefragt + + + Archiviert, wird weiter abgefragt + + + Spielerzahl unbekannt — das Spiel antwortet und veröffentlicht keine Zahl, die wir lesen können + + + {count, plural, one {# Spieler} other {# Spieler}}, {how} {age} + + + Neu entdeckt + + + Verstummt + + + Wieder da + + + {count, plural, one {# Spiel bekannt} other {# Spiele bekannt}} + + + {count, plural, one {# jetzt verbunden (gemessen)} other {# jetzt verbunden (gemessen)}} + + + {count, plural, one {# antwortend, ungezählt} other {# antwortend, ungezählt}} + + + {count, plural, one {# archiviert, wird weiter abgefragt} other {# archiviert, werden weiter abgefragt}} + + + Referenz + + + Was die Codebases sind, was die Clients tun und was die Protokolle bedeuten. Von Hand geschrieben und im Repository neben dem Crawler abgelegt — kein Wiki, und auf dieser Seite gibt es nichts zu bearbeiten. Jede {number} hier ist etwas anderes: Sie stammt aus dem Katalog und wird bei jedem Aufruf der Seite neu berechnet. + + + Zahl + + + Von Hand geschrieben, namentlich gezeichnet und in git versioniert. Der Text hier ist unserer; jede Zahl daneben wurde vom Crawler gemessen und wird bei jeder Anfrage neu berechnet. Das ist kein Wiki, und von dieser Seite aus lässt sich nichts bearbeiten. + + + Hier anfangen + + + Codebases + + + Clients + + + Protokolle + + + Orientierung + + + Codebase + + + Client + + + Protokoll + + + Nicht gefunden + + + Hier gibt es keine Referenzseite. Dieser Abschnitt ist von Hand geschrieben; eine Lücke ist also Arbeit, die niemand gemacht hat, und nichts, was entfernt worden wäre — {index}. + + + sehen, was es gibt + + + Spiele, die sie ausführen + + + Wir haben noch keines identifiziert. Das ist eine Tatsache darüber, was dieser Crawler gemessen hat, und keine darüber, was es gibt — ein Spiel, das wir nicht erreicht haben oder dessen Codebase wir nicht lesen konnten, wird hier nicht mitgezählt. + + + {count, plural, one {# eingetragen} other {# eingetragen}} + + + {count, plural, one {# archiviert} other {# archiviert}} + + + gemessen, nie behauptet + + + Bei dieser Anfrage aus dem Katalog gezählt, über denselben Filter, den der Link oben trägt — diese Zahl und jenes Verzeichnis stammen also aus einer einzigen Abfrage und können nicht auseinanderlaufen. + + + in ihren Handshakes angeboten: {protocols} + + + Gemessene Verbreitung + + + Noch nichts gemessen. + + + von {listed, plural, one {# eingetragenen Spiel} other {# eingetragenen Spielen}} — {percent} + + + Die Spiele, die hier nicht mitgezählt sind, sind keine Spiele ohne das Protokoll. Ein Spiel zählt hier, wenn sein Server die Option in einem von uns beobachteten Handshake angeboten hat; der Rest sind Server, die sie uns nicht angeboten haben, und Server, deren Handshake wir nicht gelesen haben, und welches davon zutrifft, können wir Ihnen nicht sagen. + + + Spiele, die {protocol} in einem von uns beobachteten Handshake angeboten haben, nach der Codebase, die wir bei ihnen identifiziert haben. + + + Codebase + + + angeboten + + + identifiziert + + + Siehe auch + + + Fähigkeiten + + + {count, plural, one {# von {total} aus der Dokumentation des Projekts selbst festgestellt} other {# von {total} aus der Dokumentation des Projekts selbst festgestellt}} + + + Aus der Dokumentation des jeweiligen Projekts abgelesen, nicht von uns gemessen — ein Client hat keinen Handshake, den wir beobachten könnten. „{unknown}“ heißt, dass wir nachgesehen und es nicht feststellen konnten. Es heißt nie nein. + + + Fähigkeiten der Clients, jede aus der Dokumentation des Projekts selbst abgelesen. Unbekannt heißt, dass wir sie nicht feststellen konnten, und nie, dass sie dem Client fehlt. + + + dokumentiert + + + Quelle + + + wir haben keine gefunden + + + ja + + + nein + + + unbekannt + + + Läuft auf: {platforms} + + + Spiele, bei denen wir diese Codebase identifiziert haben + + + Noch keines. Das ist eine Aussage darüber, was wir gemessen haben, und keine darüber, was es gibt — ein Spiel, das wir nicht erreicht haben oder dessen Codebase wir nicht lesen konnten, wird hier nicht mitgezählt. + + + {listed, plural, one {# eingetragen} other {# eingetragen}}, {archived, plural, one {# archiviert} other {# archiviert}} + + + In ihren Handshakes gemessen: {protocols} + + + In keinem Handshake, den wir von ihnen gelesen haben, wurde etwas angeboten. + + + {offering} von {listed, plural, one {# eingetragenen Spiel} other {# eingetragenen Spielen}} haben es in einem von uns beobachteten Handshake angeboten ({percent}) + + + Nach Codebase, unter den Spielen, die wir identifiziert haben + + + {offering} von {identified} haben es angeboten + + + {count, plural, one {# von {total} Zeilen ist unbekannt} other {# von {total} Zeilen sind unbekannt}}: Wir haben in der Dokumentation des Projekts selbst nichts gefunden, was das eine oder das andere sagt. Eine kurze ehrliche Tabelle ist besser als eine lange geratene. + + + Ihre Spiele + + + Konten brauchen eine Datenbank, und diese Website läuft auf den Demodaten. + + + Anmelden + + + Angemeldet als {name}. + + + Abmelden + + + Sie haben noch nichts beansprucht. Suchen Sie Ihr Spiel in {listing} und drücken Sie auf seiner Seite {claimControl}. + + + dem Verzeichnis + + + Dieses Spiel beanspruchen + + + Aufgegeben. + + + Der Vermerk darüber bleibt erhalten, und Sie können jederzeit erneut nachweisen, dass Sie das Spiel betreiben, indem Sie einen frischen Token veröffentlichen. + + + Gespeichert. + + + Dieses Spiel + + + Die Seite von {game} zeigt es jetzt als vom Betreiber angegeben. + + + Wir veröffentlichen den Verbindungsbildschirm von {game} nicht mehr erneut. Die Seite sagt das deutlich, statt eine Leerstelle zu lassen. + + + Der Verbindungsbildschirm von {game} steht wieder auf seiner Seite. + + + Wir wählen {game} nicht mehr an, unter keiner Adresse, die wir dafür haben. Seine Seite behält alles, was vor Ihrer Bitte gemessen wurde. + + + Wir wählen {game} wieder an, ab seinem nächsten Termin im Zeitplan. + + + {game} ist aus dem Verzeichnis, aus den Ranglisten und aus der Tageszahl heraus. Seine Seite und jede URL, die es je hatte, antworten weiterhin. + + + {game} steht wieder im Verzeichnis. Eine einzige Abfrage, die eine Antwort erhält, genügt, damit es wieder gemessen wird. + + + {field} wurde nicht geändert. + + + Das sind einzeilige Angaben; mehr als {max} Zeichen speichern wir nicht. + + + Dieses Feld ist gemessen. Ein Anspruch erlaubt Ihnen hinzuzufügen, wofür MSSP keinen Platz hat; er erlaubt niemandem, das zu ändern, was wir beobachtet haben, uns eingeschlossen. + + + Beansprucht + + + bestätigt {date} + + + bestätigt {date}, Token zuletzt gesehen {seen} + + + Ihr MSSP prüfen + + + {count, plural, one {Außerdem beansprucht von {names} — mit einem eigenen bestätigten Token.} other {Außerdem beansprucht von {names} — jeweils mit einem eigenen bestätigten Token.}} + + + ein anderes Konto + + + Ihre Spielerzahl auf Ihrer eigenen Website zeigen + + + Das Abzeichen trägt die Zählung und den Zeitpunkt, an dem wir sie gemessen haben, denn eine Zahl ohne Alter ist genau das, was diese Website ersetzen soll. + + + Es sagt {unknown} statt null, wenn wir nicht zählen konnten, und {archived}, wenn das Spiel nicht mehr antwortet. + + + Es gibt auch {json}, falls Sie lieber selbst zeichnen möchten. + + + Verlauf + + + Token ausgegeben + + + Token erneut ausgegeben + + + bestätigt — wir haben Ihren Token gelesen + + + Token weiterhin veröffentlicht + + + Token diesmal nicht gelesen + + + Anspruch aufgegeben + + + Token ungenutzt abgelaufen + + + ein anderes Konto hat nachgewiesen, dass es das Spiel betreibt, und es übernommen + + + Prüfung angefragt + + + diesen Anspruch aufgeben + + + Tippen Sie {word} zur Bestätigung. Nichts wird gelöscht, und Sie können erneut nachweisen, dass Sie das Spiel betreiben, indem Sie einen frischen Token veröffentlichen; das Spiel bleibt beansprucht, solange es noch jemand anderes beansprucht. + + + {game} aufgeben + + + Warten auf einen Token + + + Token ausgegeben {issued}, gültig bis {expires} + + + Passkeys + + + ohne Namen + + + hinzugefügt {date} + + + hinzugefügt {date} · nur auf einem Gerät + + + Dieser Passkey liegt auf einem einzigen Gerät. Verlieren Sie ihn, kommen Sie weiterhin hinein, indem Sie einen frischen Token in Ihrem Spiel veröffentlichen, aber ein zweiter Passkey geht schneller. + + + Einen weiteren Passkey hinzufügen + + + Kein solches Spiel + + + {game} beanspruchen + + + Das Beanspruchen braucht eine Datenbank, und diese Website läuft auf den Demodaten. + + + Dafür brauchen Sie zuerst ein Konto. Es braucht einen Passkey und einen Namen. + + + Anmelden oder ein Konto anlegen + + + Ihre Spiele + + + {count, plural, one {Dieses Spiel hat bereits einen Betreiber, der den Zugriff auf den Server nachgewiesen hat.} other {Dieses Spiel hat bereits # Betreiber, die den Zugriff auf den Server nachgewiesen haben.}} Sie können ihn ebenfalls nachweisen — die Prüfung ist in beiden Fällen dieselbe —, aber wir müssen wissen, was Sie damit meinen, denn am Token ist es nicht zu erkennen. + + + Ich betreibe es auch — mich als Betreiber eintragen + + + Alle behalten ihren Anspruch. Das sind zwei Menschen, die ein Spiel betreiben. + + + Ich habe es übernommen — auf mich übertragen + + + {count, plural, one {Sobald Ihr Token bestätigt ist, wird der bestehende Anspruch widerrufen und das Spiel gehört Ihnen.} other {Sobald Ihr Token bestätigt ist, werden die bestehenden Ansprüche widerrufen und das Spiel gehört Ihnen.}} In ihrem eigenen Verlauf steht, warum. Nichts wird gelöscht, und sie können erneut nachweisen, dass sie es betreiben, auf demselben Weg, den Sie gerade gehen. + + + Bestätigt. + + + Wir haben Ihren Token am {date} aus dem MSSP-Bericht des Spiels gelesen. + + + Wir haben Ihren Token am {date} vom Verbindungsbildschirm gelesen. + + + Lassen Sie den Token, wo er ist. Er dient zugleich als Erkennungsmerkmal, damit dieses Spiel wiedererkennbar bleibt, wenn es den Host wechselt oder den Namen ändert. Ihn wegzunehmen hebt Ihren Anspruch nicht auf. + + + Veröffentlichen Sie diesen Token irgendwo, wo das Spiel ihn einer anonymen Verbindung zeigt. Die nächste Abfrage liest ihn auf, und damit ist nachgewiesen, dass Sie auf diesen Server schreiben können. + + + Das ist eine Übertragung. + + + {count, plural, one {Sobald wir diesen Token lesen, wird der Anspruch des bisherigen Betreibers auf dieses Spiel widerrufen und es gehört Ihnen.} other {Sobald wir diesen Token lesen, werden die Ansprüche der bisherigen Betreiber auf dieses Spiel widerrufen und es gehört Ihnen.}} + + + Eines von beidem genügt + + + Eine MSSP-Variable + + + In {codebase} ist das eine Zeile in {file}; jede Codebase mit MSSP hat eine Entsprechung. + + + {aliases} werden ebenfalls angenommen. + + + Eine Zeile auf dem Verbindungsbildschirm + + + Irgendwo im Bildschirm, und Farbcodes darum herum sind kein Problem. + + + Dann + + + Wir prüfen im gewöhnlichen Crawl-Zeitplan. Dieser Token ist gültig bis {date}. Kommen Sie jederzeit wieder; nichts davon muss notiert werden. + + + Früher nachsehen + + + Bringt Ihr Spiel an den Anfang der Warteschlange. Wir wählen nach unserem eigenen Zeitplan an, das heißt also früher und nicht jetzt. + + + Gerade eben schon angefragt. Versuchen Sie es in ein paar Minuten wieder — es ist begrenzt, weil es einen echten Server früher anwählt, als wir es getan hätten. + + + Was nur Sie uns über {game} sagen können + + + Das sind die Dinge, für die MSSP kein Feld hat. Sie erscheinen auf der Seite Ihres Spiels als {declared}, mit dem Datum, an dem Sie sie zuletzt bestätigt haben, neben dem, was wir gemessen haben — nie an dessen Stelle. Nichts Gemessenes lässt sich von hier aus ändern, weder von Ihnen noch von uns. + + + angegeben {age}. Leeren Sie dieses Feld, um die Angabe zurückzuziehen — was darin stand, bleibt so oder so festgehalten. + + + Ihre Angaben speichern + + + Was {game} meldet und was Sie stattdessen gezeigt sehen möchten + + + Ihr MSSP ist das, was jeder Crawler liest, und wir zeigen es weiterhin neben allem, was Sie hier eintragen — eine Angabe von Ihnen verdeckt keine Ihres Spiels. + + + Nichts Gemessenes lässt sich von hier aus ändern: keine Spielerzahl, keine Fähigkeit, keine Stunde Erreichbarkeit. + + + Ist eine Zeile unten in Ihrer {file} falsch, dann genügt es, sie dort zu berichtigen — damit stimmt sie überall. + + + Ihr Spiel meldet {value}, bestätigt {age} + + + Ihr Spiel meldet hier nichts + + + Den Namen zu ändern ändert, unter welchem Namen {game} eingetragen ist, und die Adresse seiner Seite. Die alte Adresse funktioniert für immer weiter — jede URL, die dieses Spiel je hatte, leitet auf die aktuelle weiter —, und wenn Sie das Feld leeren, gilt wieder der Name, den Ihr MSSP nennt. + + + Ihr Verbindungsbildschirm + + + Wir veröffentlichen ihn nicht erneut. Die Seite sagt das deutlich, statt eine Leerstelle zu lassen, und der Crawler liest ihn weiterhin — daran erkennen wir Ihr Spiel wieder, wenn es umzieht. + + + Wieder zeigen + + + Wir zeigen ihn, weil Ihr Server ihn an jede anonyme Verbindung sendet. Ist Ihnen das nicht recht, sagen Sie es, und wir hören auf. Wir fragen nicht nach dem Grund. + + + Unseren Verbindungsbildschirm nicht mehr zeigen + + + Gecrawlt werden + + + Wir haben aufgehört. Nichts an {game} wird angewählt, und die Seite behält alles, was vor Ihrer Bitte gemessen wurde — die leeren Stunden nennen keine Ursache, denn {ourFact} ist eine Tatsache über uns und keine Messung an Ihrem Spiel. + + + dass Sie uns um den Stopp gebeten haben + + + Uns wieder crawlen + + + Dieser Ausschluss kam von Ihrem eigenen Server und nicht von hier — {routes}. Damit wir wieder crawlen, hören Sie auf, ihn zu veröffentlichen; wir bekommen das im nächsten Zyklus mit. + + + Ihr MSSP-Bericht veröffentlicht {variable} + + + ein TXT-Eintrag unter {label} bittet uns aufzuhören + + + eine von uns festgehaltene Bitte + + + Bei {stopped} haben wir aufgehört, und {dialling} wählen wir weiterhin an. Das ist meist ein Port, der nach dem Ausschluss hinzugekommen ist. + + + Auch unter jeder Adresse aufhören + + + Wir wählen {game} nach einem Zeitplan an und lesen, was jeder anonymen Verbindung gezeigt wird. Ist Ihnen das nicht recht, sagen Sie es, und wir hören auf — innerhalb eines Zyklus, unter jeder Adresse, die wir von Ihnen haben, und wir fragen nicht nach dem Grund. + + + Nichts bereits Gemessenes wird gelöscht: Ihre Seite behält ihre Geschichte und ihre URL, und eine einzige Abfrage, nachdem Sie das zurückgenommen haben, setzt es wieder in Gang. Sie können es auch ohne uns sagen, in Ihrer eigenen Konfiguration — {mssp} in MSSP oder ein TXT-Eintrag unter {dns} —, und wir befolgen das, ob dieses Spiel hier jemals beansprucht wurde oder nicht. + + + Uns nicht mehr crawlen + + + Eingetragen sein + + + {game} ist aus dem Verzeichnis, aus den Ranglisten und aus der Tageszahl heraus. Seine Seite und jede URL, die es je hatte, antworten weiterhin, und alles, was vor Ihrer Bitte gemessen wurde, steht weiterhin darauf. Es wurde nichts gelöscht; es ist nur kein Ort mehr, an dem man beim Stöbern ankommt. + + + Uns wieder ins Verzeichnis aufnehmen + + + Eine Abfrage, die eine Antwort erhält, tut das ebenfalls. Solange Ihr Ausschluss gilt, wählen wir nicht an, es wird also keine geben — aber an dem Tag, an dem Sie ihn zurücknehmen, kommt die Adresse binnen einer Woche an die Reihe, und die Abfrage, die eine Antwort erhält, bringt Sie zurück. Sie müssen uns kein zweites Mal fragen. + + + Wir wählen Sie nicht mehr an, und Ihre Seite steht weiterhin im Verzeichnis, mit dem, was wir davor gemessen haben. Ist Ihnen das nicht recht, sagen Sie es, und sie kommt heraus — aus dem Verzeichnis, aus den Ranglisten und aus der Tageszahl. + + + Nichts wird gelöscht und nichts geht kaputt: Die Seite antwortet, jede URL, die sie je hatte, leitet weiterhin auf sie, und wer von Ihnen dorthin geschickt wird, sieht sie. Sie ist nur kein Ort mehr, an dem ein Lesender beim Stöbern ankommt. Von hier aus rückgängig zu machen, und ebenso durch jede Abfrage, die nach der Rücknahme Ihres Ausschlusses eine Antwort erhält. + + + Uns auch aus dem Verzeichnis nehmen + + + was wir messen konnten + + + Diese auszublenden nimmt sie aus der Auflistung heraus; es heißt nicht, dass das Spiel leer ist. + + + ungezählt + + + nicht erreichbar + + + aus dieser Auflistung ausgeblendet + + + aus dieser Auflistung ausgeblendet + + + In der linken Spalte steht * für einen Wert, nach dem diese Auflistung gefiltert ist, und - für einen, gegen den gefiltert wird. Beides sind Entscheidungen in der Suche, keine Tatsachen über ein Spiel. + + diff --git a/src/MUI.Web/Resources/Messages.ja.resx b/src/MUI.Web/Resources/Messages.ja.resx new file mode 100644 index 0000000..84f452f --- /dev/null +++ b/src/MUI.Web/Resources/Messages.ja.resx @@ -0,0 +1,2877 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + {count, plural, other {#件のゲーム}} + + + {value}、{count, plural, other {#件のゲーム}}、のみ + + + {value}、{count, plural, other {#件のゲーム}}、除外 + + + {value}、{count, plural, other {#件のゲーム}} + + + {facet}を問わない、{count, plural, other {#件のゲーム}} + + + {count, plural, =0 {ここに掲載しているゲームはなし。} other {#件のゲーム。どの事実にも、その出所が記されている。}} + + + {days, plural, other {#日間実測}} · {probes, plural, other {#回の探査}} + + + {days, plural, other {#日}} · {count, plural, other {#件の計数}} + + + {disagreeing, plural, =0 {{total}件のうち不一致はなし。} other {{total}件のうち#件がゲームの自己申告と不一致。}} + + + {count, plural, =0 {すべての回答に一致するゲームはなし。} other {すべての回答に一致するゲームは#件。}} + + + {listed, plural, other {掲載#件のゲーム}}のうち · {answers, plural, =0 {回答なし} other {回答#件}} + + + {count, plural, other {この#件のゲームを表示}} + + + 「{answer}」を解除 — {count, plural, other {#件のゲーム}} + + + 回答を解除:{question} + + + ゲームを探す + + + すべての回答に一致 + + + {count, plural, other {ゲーム}} + + + 回答をすべて解除 + + + やり直す + + + {count, plural, other {他#件}} + + + 回答済み + + + 一覧全体 + + + そのクエリは拒否された + + + 名前(分かっていれば) + + + 名前、または名前の一部 + + + 名前で検索 + + + 今プレイしている人はいる? + + + 何をプレイしたい? + + + どんな種類のゲーム? + + + どの言語? + + + クライアントに必要なものは? + + + 消息不明のゲームも含める? + + + 問わない + + + ジャンルを問わない + + + 種類を問わない + + + 言語を問わない + + + 問わない + + + いいえ、ライブのゲームのみ + + + はい、それらも表示 + + + 消息不明のゲーム + + + TLS — 暗号化、ハンドシェイクは当サイトで完了 + + + MSSP — サーバーの自己申告 + + + MCCP — 出力の圧縮 + + + MXP — クリック可能なリンク + + + GMCP — 構造化クライアントデータ + + + MSDP — 構造化クライアントデータ + + + CHARSET — エンコーディングのネゴシエーション + + + UTF-8 — 非ラテン文字が表示される + + + TTYPE — クライアントが自分の種類を伝える + + + ATCP — 構造化クライアントデータ + + + MSP — サウンドのトリガー + + + EOR — プロンプトの区切り + + + {token} — ハンドシェイクで実測 + + + 実測 + + + 実測 + + + 実測 + + + 実測 + + + 実測 + + + 自己申告 + + + 自己申告 + + + 自己申告 + + + 自己申告 + + + 導出 + + + 導出 + + + 未計測 + + + 接続数読み取り不可 + + + 到達不可 + + + 読み取り不可 + + + 計数なし + + + ここから + + + アーカイブ済 + + + 申請済 + + + 選べるゲームがない + + + そのフィルターに一致するゲームはない。{listing}か、{archive}。 + + + 一覧全体 + + + アーカイブも含める + + + 接続数 + + + 所有者未申請 + + + 所有者により申請済み + + + 探査は継続中 + + + 通常 + + + ピーク + + + テキストで読む + + + プレーンテキスト + + + 本文へスキップ + + + ASCIIバナー:{game}の接続画面。 + + + カタログ + + + このサイトとアカウント + + + 閲覧 + + + 学ぶ + + + このサイト + + + メニュー + + + ゲーム + + + 探す + + + ランダム + + + アーカイブ + + + リファレンス + + + エコシステム + + + ランキング + + + 概要 + + + 登録 + + + ゲームを登録 + + + サインイン + + + 自分のゲーム + + + テーマ + + + 自動 + + + ライト + + + ダーク + + + デモデータ。 + + + データベースが未設定のため、これはダミーデータ。ここにある値は実測ではない。 + + + すべてのゲーム + + + アーカイブ + + + ゲームの自己申告 + + + 変更点 + + + MU*ホビーのディレクトリ + + + どの事実にも、その出所といつのものかが記されている:当サイトのクローラーが実測したものか、ゲームが自己申告し、そう明記したものか。 + + + 名前・テーマ・コードベース・ホストでゲームを検索 + + + 名前・テーマ・コードベース・ホストで検索 + + + 検索 + + + 既知のゲーム + + + 現在の接続数 + + + 応答あり・読み取り不可 + + + アーカイブ済み + + + 新規発見 + + + 消息不明 — 探査は継続中 + + + 復帰 + + + 新規なし。 + + + 消息不明になったゲームはなし。 + + + 復帰したゲームはなし。探査は続ける。 + + + ライブ + + + ゲーム + + + {order}順 + + + ランダム + + + 接続数 · 到達 + + + ここから + + + 該当なし。 + + + 語を減らすか、フィルターを外す。 + + + フィルターを解除 + + + {codebase}について + + + 到達なし + + + 所有者により申請済み + + + コードベース不明 + + + このゲームのコードベースは特定できなかった + + + ほか{count, plural, other {#件}}:{names} + + + 並び順 + + + 期間 + + + 現在 + + + 通常 + + + ピーク + + + 名前 + + + 到達 + + + 7日 + + + 30日 + + + 90日 + + + ゲームを検索 + + + ゲームを検索 + + + フィルター + + + 表示中 + + + すべて解除 + + + — この絞り込みを解除 + + + 問わない + + + 他のフィルター({count}) + + + {count, plural, other {他#件}} + + + 追加で表示 + + + 既定ではオフ。どちらもゲームへの評価ではない。 + + + アーカイブ済み + + + アダルト + + + アーカイブ済みのゲーム、{shown, select, true {表示} other {非表示}} + + + アダルト指定を自己申告したゲーム、{shown, select, true {表示} other {非表示}} + + + 件数は実測したゲーム数であり、推定値ではない。 + + + バッジと空欄の意味 + + + 空欄は実測の欠落であり、否定ではない。ファセットごとに表記が異なる:特定できず、申告なし、ネゴシエーションなし。 + + + 実測のゼロは計数である。接続数不明はゼロではなく、ゼロとして並べ替えられることもない。 + + + 値が定まらないファセットは最頻の{count}件の値のみを挙げる。残りは検索とURLから到達可能。 + + + 未チェックは未計測の意味 — ゲームに備わっていないという意味ではない。 + + + 活動 + + + 最終確認 + + + 提供プロトコル + + + 暗号化 + + + エンコーディング + + + コードベース + + + バージョン + + + 系統 + + + ファミリー + + + ジャンル + + + 言語 + + + 現在接続あり + + + 今週アクティブ + + + 閑散 — 計数は0を超えず + + + 消息不明 — 1か月間到達なし + + + アーカイブ済み + + + 24時間以内 + + + 7日以内 + + + 30日以内 + + + それ以前 + + + 一度も到達なし + + + ネゴシエーションなし + + + 特定できず + + + 申告なし + + + TLSで接続 + + + {value}以外 + + + ネゴシエーションあり + + + 特定済み + + + 申告あり + + + 実際に観測した + + + ゲームの申告であり、検証はしていない + + + ゲームの申告を分類したもの + + + 名前 + + + 現在の接続数 + + + 最終到達 + + + 通常の接続数 · 7日 + + + 通常の接続数 · 30日 + + + 通常の接続数 · 90日 + + + 同時接続ピーク · 7日 + + + 同時接続ピーク · 30日 + + + 同時接続ピーク · 90日 + + + 行の現在値 + + + 通常 + + + ピーク + + + 接続数不明 + + + 一度も到達なし — 到達が古いのではない + + + 期間内の計数が{minimum}件未満、または皆無 — 通常の接続数がゼロなのではない + + + 期間内に計数を読み取れず — 誰も接続していなかったのではない + + + 中央値 {value} · {days, plural, other {#日}} · {count, plural, other {#件の計数}} + + + 同時最大 {value} · {days, plural, other {#日}} · {count, plural, other {#件の計数}} + + + 接続画面 + + + 時間帯別の接続数 + + + 接続数の推移 + + + 到達可能 + + + 変更点 + + + 機能 + + + ゲームの自己申告 + + + リファラル + + + 所有者未申請 — ここにある情報はすべて実測。 + + + 所有者により申請済み — 以下の実測値は引き続き当サイトのもの。 + + + このゲームを申請する + + + {date}から応答 + + + テキストで読む — {count, plural, other {#行}} + + + 機能 + + + 経過 + + + 提供 + + + 無応答 + + + なし + + + 拒否 + + + 自己申告 + + + 不一致 + + + 不一致の箇所({count}) + + + {day} {time} — {count, plural, =0 {接続数0、実測} other {平均#人}} + + + {day} {time} — 探査したが接続数は読み取れず + + + {day} {time} — この時間は未計測 + + + このゲームの活動はまだ実測していない。 + + + 週のどの時間にも接続数は得られていない。 + + + すべての時間を実測し、どの時間も接続はなかった。 + + + {count, plural, other {{day}の#時間はまだ未計測。}} + + + {count, plural, other {週全体で#時間がまだ未計測。}} + + + {count, plural, other {{day}の#時間は応答したが接続数は読み取れず。}} + + + {count, plural, other {週全体で#時間が応答したが接続数は読み取れず。}} + + + 毎日、{window}が最も多い。 + + + 毎日、{part}の{window}が最も多い。 + + + {days}は{window}が最も多い。 + + + {days}は{part}、{window}が最も多い。 + + + {who}の{window}は常に接続が少ない。 + + + {who}の{part}、{window}は常に接続が少ない。 + + + 毎日 + + + 実測できたすべての日 + + + 平日 + + + {days} + + + 午前 + + + 午後 + + + 夜 + + + 深夜 + + + 午前 + + + 午後 + + + 夜 + + + 深夜 + + + {list}、{next} + + + {first}と{second} + + + 実測がまだ足りない + + + 週のどの時間もまだ未計測。 + + + {count, plural, other {#時間は応答したが接続数は読み取れず。}} + + + 週のすべての曜日に実測が入るとグリッドが表示される。 + + + {days, plural, other {これまでに7日のうち#日を実測。すべての曜日に1時間でも入るとグリッドが表示される。}} + + + {count, plural, other {#時間を実測、いずれも接続なし。}} + + + {count, plural, other {#時間を実測、最多は{day} {time} UTCの{peak}。}} + + + {count, plural, other {他に#時間が応答したが接続数は読み取れず。}} + + + {day} — {facts} + + + {first}、{second} + + + 終日ゼロを実測 + + + {time}にピーク{count} + + + {window}は接続なし + + + どの時間も接続数を読み取れず + + + {count, plural, other {#時間が未計測}} + + + {count, plural, other {#時間は探査したが読み取り不可}} + + + 曜日 + + + 最少 + + + 最多 + + + 時刻 + + + 読み取り不可 + + + 曜日別の接続数(UTC)。{window}。 + + + 時刻はUTC · {window} + + + {weeks, plural, other {#週の移動平均}} + + + 計数あり(実測のゼロを含む) + + + 探査したが接続数は読み取れず + + + その時間は未計測 + + + 人がいる時間帯(UTC) + + + 計数あり + + + 接続でき、数値を読み取れた(実測のゼロを含む) + + + 接続できたが、数値は読み取れなかった + + + その時間の実測がない + + + 言語 + + + 言語を変更 + + + mu*indexについて + + + ここにあるゲームはすべて、接続した機械が実測したもの。どの値にも、出所といつのものかが記されている。このページでは、それが何を証明するのか、何を誤ると分かっているのか、誰のディレクトリを読んでいるのか、そしてクローラーを止める方法を扱う。 + + + ここでの事実とは何か + + + 実測は自己申告に優先し、どちらも表示する。 + + + ゲームのMSSPレポートは、そのゲームが自分自身を説明したもの。telnetハンドシェイクは、当サイトがそのゲームの振る舞いを実際に見たもの。どちらもそのゲームのページに、方法と時期を添えて表示する。両者が食い違う場合は、その不一致を示す。 + + + 接続数は、その出所を明示する。 + + + 接続画面で読み取ったWHOかDOING(当サイトが数えたもの)、あるいはゲーム自身が公開したMSSPのPLAYERSフィールドのいずれか。両者を混ぜることはない。 + + + 読み取れなかった応答は不明であって、ゼロではない。 + + + サーバーはWHOのヘッダーを自由に書き換えるので、ある程度を超えると当サイトのパーサーは読み取れない。それは接続数読み取り不可という、それ自体で一つの状態。実測のゼロ — 接続でき、誰もいなかった — は計数であり、計数として表示する。 + + + 到達可能であって、稼働率ではない。 + + + 当サイトは1台のホストから一定の間隔でソケットを開く。経路が通らないゲームは到達不可であり、それでいて健在。実測していない以上、ここではどのゲームの稼働率も主張しない。 + + + 1時間は、計数あり、読み取り不可、未計測のいずれか。 + + + 活動グリッドには3つの状態がある。3つ目は空欄であり、原因を名指ししない:到達できなかった時間も、一度も探査しなかった時間も、同じ欠落であって、どちらもそのサーバーの停止時間ではない。 + + + 誤ると分かっていること + + + アーカイブまでの猶予は、こちらが見つけた日から数える。 + + + 応答しなくなったゲームは、猶予期間を過ぎると既定の一覧から外れる:猶予は探査した到達可能時間の4分の1で、下限60日、上限365日。1995年から動いているゲームでも、発見した日には下限から始まる。当サイトが来る前の年月を埋めるためのインポートは一切しない。 + + + MSSPのCREATEDはその猶予に算入しない。 + + + 設定ファイルに手で書かれた1行にすぎず、算入すればアーカイブのしきい値を操作できてしまう。自己申告として表示するが、それで得られるものはない。 + + + ゲームを申請すれば上限が得られる。 + + + サーバーへのアクセスを証明すれば、観測してきた期間の長さにかかわらず、猶予は満1年になる。 + + + ここにあるものはすべて、1台のホストが一定間隔で見た結果。 + + + 到達可能時間の割合は、観測した期間に対する割合であって、観測しなかった期間に対するものではない。ここにあるどの図も、残りを埋めることはしない。 + + + 削除は一切しない。 + + + アーカイブはゲームを既定の一覧、ランキング、本日アクティブの数値から外すだけで、それ以上のことはしない。ページ、URL、履歴、アドレスはそのまま残り、探査は続き、1回の探査が成功すれば元に戻る。 + + + このサイトがしないこと + + + 投票も、星も、評価も、おすすめもない。 + + + ランキングは実測データのみから算出する。最も多くクリックを動員できた者で順位が決まるディレクトリが表すのは、その動員活動であってこのホビーではない。先行したディレクトリを滅ぼしたのはそれだ。 + + + フォーラムも、レビューも、ウィキも、コメントも、プレイヤープロフィールもない。 + + + 入門的な解説 — MUSHとは何か、共同ロールプレイにはどのコードベースが向くか — は、サイトの他の部分と同じく、執筆され、署名され、バージョン管理される。 + + + プレイヤー名は一切保存しない。 + + + WHOの応答は、人数とヘッダーの形を得るためにメモリ上で解析する。名前は書き出さない。集計には、ローテーションするソルトを用いたソルト付きハッシュを使う。 + + + 人口の絶対数は公表しない。 + + + コードベース別・プロトコル別のシェアは公開する:実測した集合に対する比率は、計数できないゲームがあっても意味を保つ。「MU*を遊んでいる人は何人か」は保たない。その数字は引用に耐えないから。 + + + クローラーと、その止め方 + + + 探査とは、ログインしない1本の接続のこと。 + + + ソケットを開き、telnetオプションをネゴシエートし、接続画面を読み、オプション70のネゴシエーションでMSSPを要求し、{commands}を送り、切断する。キャラクターも作らず、ログインもせず、相手側では何も変わらない。セッションにはタイムアウトを設けてあり、詰まった探査が接続枠を占有し続けることはない。 + + + CRAWL DELAYが優先する。 + + + MSSPレポートで希望する最小間隔を示したゲームには、そのとおりにする。当サイト自身のスケジュールより双方向に優先する:720時間なら週次ではなく月次。消息不明のゲームも、その長い間隔で永久に試し続ける。復帰したときに自力で一覧に戻るのは、そのおかげ。 + + + 紹介されたアドレスは検証する。信用はしない。 + + + MSSPでは、ゲームが他のゲームを名指しできる。名前はダイヤルする前にすべて解決し、解決先のアドレスがすべてグローバルに到達可能でなければ拒否する。混在した応答は、その対象全体を拒否する。この拒否は当サイトの判断として記録し、ゲームの記録に停止時間として現れることはない。 + + + 接続画面を表示するのは、それが誰にでも送られるものだから。 + + + サーバーは接続画面を、認証なしで、すべての匿名接続に描き出す。当サイトはそれを証拠として表示し、ラベルを付ける。求められれば取り下げる。 + + + 止めろと言われれば止める — 方法は3つ。 + + + MSSPレポートに{variable} 1を公開すれば、それを読んだ探査が最後になる。または{label}.your.hostに「{value}」と書いたTXTレコードを公開する。MSSPへの対応も当サイトのアカウントも要らない。あるいは人に連絡する。3つとも1回のクロールサイクル以内に反映し、日付と読み取った内容とともに記録し、登録フォームにも適用する。 + + + MSSPフィールドはそのリスナーを止め、レコードはホストを止める。 + + + MSSPは応答したポートが公開するものなので、そのポートについてしか語らない — MU*のホスティングでは無関係なゲームが1つのドメインで動いているのが普通であり、一方が隣を黙らせてはならない。TXTレコードは、「{value}=4201」のようにポートを名指ししない限り、すべてのポートを対象とする。そこにポート一覧として読めないものが書かれていればホスト全体を意味するので、「{value}=all」でも通る。 + + + DNSによる方法は、こちらに頼まずに自分で取り消せる唯一の方法。 + + + TXTレコードは、来るなと言ったサーバーに接続しなくても読めるので、ダイヤルのたびに読み直す。削除すれば1週間以内にまたダイヤルする。MSSPフィールドは、止めてくれと言われたその行為をしなければ読み直せない。そのためMSSPによる離脱と書面での要請は、撤回の連絡があるまで有効なまま。離脱したアドレスに対して行うのはそのTXT参照だけ:触れるのはネームサーバーであって、ゲームではない。 + + + 停止は削除ではないし、停止時間でもない。 + + + 離脱したゲームも、ページ、アドレス、そして要請より前に実測したものはすべて残る。止まるのは新しいデータだけ:活動グリッドは時間が増えなくなり、原因は名指ししない。ノックをやめるという判断は当サイトについての事実だから。それは行われなかったクロールの記録として、また誰が要請したかの台帳に記録する。 + + + 停止だけで足りなければ、掲載も取り下げられる。 + + + そのゲームが応答するすべてのアドレスで探査を止めたあと、ダッシュボードにもう一つの選択肢が出る:一覧、ランキング、日次の数値から取り下げること。ページも、これまで持っていたすべてのアドレスも応答し続け、何も削除されない — 閲覧してたどり着ける場所ではなくなるだけ。これには検証済みの申請が要る。あなたのゲームについての判断であり、誰が下したかを記録するから。そして探査がそれを取り消す:離脱を撤回すれば、次に応答が返ったダイヤルで、二度頼まなくても一覧に戻る。 + + + サーバーから何者かと尋ねられたとき、クローラーは自らを{name}と名乗る。 + + + クローラーは自らを{name}と名乗るよう設定されているが、まだそう伝えられない。使っているtelnetライブラリには、クライアント側から端末種別を設定する手段がなく、そちらのログにはそのライブラリの既定値が見える。NEW-ENVIRONもクローラーホストの環境から応答される。どちらもライブラリ側の欠落であり、そこを直すのは当方の仕事。それまでは、探査はその形で見分けられる:1本の接続、ログインなし、読み取り専用の短いコマンド列、そして退出。 + + + クローラー + + + 連絡先 + + + クローラー:{name} + + + 連絡先:{url} + + + — プレースホルダー。このデプロイでは連絡先が設定されていない + + + 連絡先が設定されていないため、上のものはプレースホルダーであり、誰にもつながらない。 + + + ゲーム一覧の出どころ + + + 受け取るのはアドレス。それ以外は何も。 + + + バックフィルが受け取るのはホストとポート。接続数も、到達可能性の履歴も、説明も、各種フィールドも、アドレスがどのサイト由来かの記録も取らない。 + + + それらのサイトが渡せるものより、意図的に少なく。 + + + いくつかは何年分もの日付付き接続数を持っている。それを取り込めば、他人がすでに観測していたゲームのヒートマップが埋まり、このサイトの中心的な主張が他者の探査機に寄りかかることになる。 + + + ゲームの出どころは一つの事実ではない。 + + + 掲載する価値のあるゲームは、これらのディレクトリのいくつもに載っている。だから「インポート元」は、たまたま先に走った取得を指すだけになる。ゲームが存在することは公開情報。どこで読んだかは何も足さず、他人の仕事のうち当サイトのものだと最も言いにくい部分でもある。 + + + 他人のサイトを読むことは、やはり他人のサイトを読むこと。 + + + スクレイプの前に一括エクスポートか文書化されたエンドポイントを求め、まずrobots.txtを読み、スクレイプには厳しくレート制限をかける。管理者の了承が要る情報源は、依頼した旨を人が明言できるまで取得しない。 + + + 読み取り済み — アドレスのみ + + + 未読 — 許諾待ち + + + 1ページ、1リクエスト。各ゲームに接続して読み取った内容を出力するクローラーが公開している。 + + + 同じクローラーによるMSDPの一覧。ほぼMSSP版の部分集合で、そちらでは届かない少数のアドレスのために読んでいる。 + + + カタログ全体を1ページで公開しているので、読むのに要するリクエストは1回。最大のアドレス供給源であり、実測値は一つも取っていない。 + + + インデックスが1ページと、ワールドごとに1ページなので、エクスポートではなくスクレイプになる。2026年7月30日、robots.txtを守り15秒間隔で、先方の143ページを取得した。ただし誰も先方に連絡していなかった。あれは起きてはならなかった。現在の関門は、管理者に依頼したと明言する用意のある人を要求する。 + + + 実装済み、テスト済み、実行はしていない。許諾を除くあらゆる軸でここでは最も優れた情報源だが、誰かが先方に連絡するまで何も取得しない。 + + + ライセンス + + + コードはMIT。 + + + サイトもクローラーもパーサーも、MITライセンスのオープンソース。 + + + データのライセンスは未解決の問題。 + + + コードとは別の判断であり、まだ下していない。以下の条件は、このデプロイの現時点での答えであって、プロジェクトとして定まった立場ではない。競合するディレクトリがカタログ全体を持っていくことはここでは成功条件なので、どう決まるにせよそれを妨げることはない。 + + + コード + + + データ、このデプロイが提供する形での + + + クレジット表記 + + + コード:{licence} + + + データ:{licence} + + + クレジット表記:{credit} + + + (このデプロイが提供する内容。プロジェクト自身の答えはまだ未定。) + + + ゲームを登録 + + + ゲームの所在を教えてほしい。フォームはホストとポートだけ。このサイトのそれ以外はすべて、当サイト自身のクローラーが実測する。 + + + ホスト + + + ポート + + + mud.example.org、または mud.example.org:4201 を貼り付けてポートは空のまま + + + 登録 + + + 登録にはデータベースが必要だが、このサイトはデモ用のダミーデータで動いている。書き込む先のクロール登録簿がないので、黙って何もしないフォームを出すのではなく、フォーム自体を置いていない。 + + + ここにはない + + + アドレスに何が起きるか + + + ダイヤルの前にアドレスを解決し、公開インターネットの外に解決するものは拒否する。それは当サイト自身のソケットについての判断であり、ゲームについての事実ではない。 + + + そのホストを運用している人からクロールしないよう要請されている場合、誰が登録しようとそのアドレスは受け取らない。他人があなたのゲームをこのサイトに戻すことはできない。 + + + 応答があれば、サーバーが自分について語る内容を読み、そのゲーム自身のスケジュールで永久に読み続ける。アドレスを渡すのは一度でいい。 + + + 運用者だと誰かが証明するまで、サイトには何も出ない。申請にはパスキーと、ゲーム自体に公開する1行が要る。 + + + すでに持っているアドレスは既存の項目にまとめられる。二度送っても掲載が二つになることはなく、探査が早まることもない。 + + + そのアドレス + + + 登録簿に入った。 + + + {address}は次のクロールサイクルでダイヤルし、以降はそのゲーム自身のスケジュールで永久に続ける。運用者だと誰かが証明した時点でここに現れる — 同じアドレスでこのフォームに戻れば、そのリンクを渡す。 + + + こちらにはある、所有者未申請。 + + + {address}はすでに実測しているもの。運用者だと誰かが証明するまで、サイトには出ないまま。それがあなたなら、ここが入口。 + + + それはすでにある。 + + + {address}はすでに実測しているゲーム。何も作られず、何も変わっていない。 + + + そのアドレスはすでにある。 + + + {address}はすでに把握している。何も作られず、何も変わっていない。 + + + すでに待機中。 + + + {address}はクロール登録簿にあり、まだ応答がない。もう一度送っても順番は早まらない:対象はそれぞれ自分のスケジュールを保つので、他人のサーバーに対して当サイトを急かすことは誰にもできない。 + + + ダイヤルできるアドレスではない。 + + + ホストにはドットかコロンが要り、ポートは1から65535までの数値。両方の欄を埋めるか、最初の欄に mud.example.org:4201 を貼り付ける。 + + + それにはダイヤルできない。 + + + {address}にこの答えが出る理由は3つ:名前が解決しない、公開インターネットの外に解決する、あるいはそのホストを運用している人から近寄らないよう要請されている。どれなのかは意図的に言わない。見知らぬ相手にそれを答えることは、ネットワークを外側から地図にすることだから。アドレスについては何も記録していない。この判断は当サイトのものであり、当サイトのものとして記録する。 + + + 今はここまで。 + + + このフォームは送信者ごとにレート制限があり、その上限に達した。1時間後にまた来てほしい。失われたものはない — 受け取ったものはすでに登録簿にある。 + + + このゲームを申請する + + + サインイン + + + 自分が運用するゲームを申請するには、パスキーでサインインする。失くすパスワードも、盗まれるパスワードもない。 + + + 申請にはデータベースが必要だが、このサイトはデモ用のダミーデータで動いている。サインインする先がない。 + + + サインインはパスキーで行う。 + + + 秘密鍵はあなたの端末かパスワードマネージャーが持ち、当サイトが持つのは公開鍵の側だけ。パスワードもメールアドレスもない。 + + + パスキーでサインイン + + + このサイトでJavaScriptを必要とする唯一のページ。パスキーはそれなしでは動かない。 + + + アカウントがまだない場合 + + + アカウントが要るのは、自分が運用するゲームを申請するときだけ。名乗る名前を選ぶ — 申請の横に並ぶラベルであって、本名ではない。 + + + 名前 + + + 例:corvid-admin + + + パスキーでアカウントを作成 + + + 保存するもの + + + 選んだ名前。 + + + 登録した各パスキーの公開鍵と、端末が付けた名称。 + + + 申請したゲームと、その時期。 + + + アカウントに紐づくメールアドレスも、パスワードも、IPの記録もない。パスキーをすべて失っても、ゲームに新しい申請トークンを公開すればやり直せる:証明になるのはゲームであって、アカウントではない。 + + + {year}年{month}{day}日 + + + {date} {time} UTC + + + 今 + + + {count, plural, other {#分}} + + + {count, plural, other {#時間}} + + + {count, plural, other {#日}} + + + {count, plural, other {#週}} + + + {count, plural, other {#か月}} + + + {count, plural, other {#年}} + + + たった今 + + + {count, plural, other {#分前}} + + + {count, plural, other {#時間前}} + + + {count, plural, other {#日前}} + + + {count, plural, other {#週前}} + + + {count, plural, other {#か月前}} + + + {count, plural, other {#年前}} + + + たった今 + + + {count, plural, other {#分前}} + + + {count, plural, other {#時間前}} + + + {count, plural, other {#日前}} + + + {count, plural, other {#週前}} + + + {count, plural, other {#か月前}} + + + {count, plural, other {#年前}} + + + {age}、{stamp} + + + 、{stamp} + + + {value} — {source}による{how}、最終確認{date} + + + {value} — {source}による{how}、最終確認{date}(想定の更新期間を過ぎている) + + + ({how}、{age}) + + + ({how}、{age}、更新期間超過) + + + 所有者申告 + + + スタッフ + + + telnetハンドシェイク + + + 所有者 + + + WHO + + + I3 + + + MSSP + + + INFO + + + I3のmudlist + + + 接続画面 + + + エコシステム + + + 示すのはシェアであって、総数ではない。MU*を遊んでいる人数の数値は公表しない:実測したゲームに対する比率は、到達できないゲームがあっても意味を保つが、人数の総計は保たない。 + + + {count, plural, other {掲載ゲーム{value}件}} + + + {count, plural, other {ハンドシェイクを完了したゲーム{value}件}} + + + {count, plural, other {MSSPレポートを保有するゲーム{value}件}} + + + ここで最も古いハンドシェイク:{age}前に確認。 + + + {total, number}件中{count, number}件({fraction, number, ::percent .0}) + + + {total, number}件中{count, number}件 — 実測はまだなし + + + {fraction, number, ::percent .0} + + + コードベース + + + 掲載している{listed, plural, other {#件のゲーム}}のうち、{identified, number}件が動かしているものを伝えてきた。以下のシェアはすべてその{identified, number}件に対するもの。読み取れなかったコードベースは分母から外し、別の何かとして数えることはしない。 + + + コードベースを伝えてきた掲載ゲームはまだない。 + + + {share}は、他のどの掲載ゲームも使っていないコードベースを動かしている — それぞれ1件ずつで、シェアというより名前。上の分母には含まれており、棒グラフからまとめて外してあるだけで、除外はしていない: + + + 系統 + + + 同じゲームを、サーバーが受け継いだ伝統ごとにまとめたもの — コードベースについての当サイトの読み取りであって、ゲームが公開した内容ではない。「MUSH」と報告するゲームはない:MSSPにその値はなく、MUSH界の大半はそもそもMSSPを公開していないので、この問いを立てる方法はこれしかない。 + + + 系統に位置づけているコードベースを動かしている掲載ゲームは、まだない。 + + + {count, plural, other {そのうち#件は、どの系統にも位置づけていないコードベースを動かしている}} — {family}を公開して、そう自ら述べているものもいくつかある。上の分母には含まれており、どのシェアにも入っていない。 + + + プロトコル + + + 以下の実測値はいずれも下限として読むこと。MSSPは名指しで要求するので、そこでの沈黙は一つの答え。それ以外は要求していないので、サーバーが一度も提供しないまま対応していることもありうる。 + + + {instrument}は、以下で唯一、下限ではない行:すべてのサーバーに名指しで要求しているので、提供しなかったゲームは訊かれたうえで断ったことになる。自己申告の数値がない唯一の行でもある。レポートを保有しているゲームは実演によって対応を示しており、そのうえで自ら列挙したゲームを数えても、測れるのは習慣のほうだから。 + + + 保有しているレポートは{reports, number}件で、現在MSSPを提供しているのは{offered, plural, other {#件のゲーム}}:残る{gap, number}件は、こちらが読んだあとに公開をやめたもの。再発行されなくなったからといって、レポートを捨てることはしない。 + + + プロトコルの採用状況。実測は完了したハンドシェイクでサーバーが提供したもの、自己申告はそのMSSPが主張するもの。ゲームの集合が2つなので、分母も2つ。 + + + プロトコル + + + 実測 — {basis}中 + + + 自己申告 — {basis}中 + + + 未計測 — 一度も観測なし + + + {share} · {declined, plural, other {#件のゲーム}}が要求に対して辞退 + + + {share} · {unobserved, plural, other {#件のゲーム}}は提供も要求もなし + + + {share} · {declined, plural, other {#件のゲーム}}が要求に対して辞退 · {unobserved, plural, other {#件のゲーム}}は提供も要求もなし + + + 要求せず — ここにあるレポートのすべてが答え + + + 採用状況の推移 + + + 各点は、その日までに実測していたゲームに対するシェアなので、この線が動く理由は2つある:ゲームが提供内容を変えること、そして実測できるゲームの集合が周りで変わること。採用と言えるのは前者だけ。以下の変化件数は、純粋にゲームが考えを変えた分。 + + + プロトコルごとの実測シェア、古い読み取りから順に + + + 当時 + + + 現在 + + + 未計測 + + + 曲線ではなくスナップショット + + + 現在実測できるもののスナップショット。採用曲線が描くのはゲームが考えを変えた点であり、当サイトは変化が起きたときにそれを記録しているので、十分な数がたまれば曲線を描けるようになる。各ゲームに最初に到達した時点を描いても、測れるのはクロールであってホビーではない。 + + + {count, plural, other {これまでに記録した機能の変化は#件}} — 曲線を描くための材料。 + + + 実測した機能はまだ一つも変化していないので、描くものがない。 + + + 実測は{measured}に対するもの、自己申告は{declared}に対するもの。ゲームの集合が2つなので、分母も2つ。 + + + {listed} · {handshakes} · {mssp}。 + + + この図で最も古いハンドシェイクの最終確認は{age}前。 + + + 同じゲームを、サーバーが受け継いだ伝統ごとにまとめたもの。これは{evidence} — {meaning} — であって、ゲームが公開した内容ではない:「MUSH」と報告するゲームはない。MSSPにその値はなく、MUSH界の大半はそもそもMSSPを公開していないから。 + + + 実測:{value} + + + 自己申告:{value} + + + ランキング + + + 実測データのみから算出。投票も、星も、評価も一切ない。ここでは品質の順位づけはしない。それは実測していない。 + + + 最も賑わうゲーム、実測の同時接続数順 + + + ランキングの期間 + + + {days, plural, other {#日}} + + + 直近{days, plural, other {#日}}に実測した接続数の中央値。 + + + 中央値に必要な、{days, plural, other {#日}}にわたる{samples, number}件のサンプルを満たすゲームは、まだない。 + + + 掲載{listed, number}件のうち{eligible, plural, other {#件のゲーム}}が、必要な、{days, plural, other {#日}}にわたる{samples, number}件のサンプルを満たしている。 + + + 実測のゼロは数に入る。読み取れなかった計数は入らない。 + + + 1週間は今誰が賑わっているかを、四半期はこれまで誰が賑わってきたかを示す。別々の問いであり、一方で首位のゲームが他方でもそうとは限らない。日は暦日、UTC。 + + + 順位づけに足るだけの計数サンプルがある掲載ゲームは、まだない — これは実測を続けてきた期間についての言明であって、誰かの賑わいについてのものではない。 + + + 直近{days, plural, other {#日}}に実測した接続数の中央値でゲームを順位づけたもの。中央値が同じゲームは同順位で、ここでは同点を崩さない。 + + + # + + + ゲーム + + + 中央値 + + + ピーク + + + 計数サンプル + + + 実測日数 + + + 到達可能が途切れなかった最長期間 + + + 示した日付以降、すべての探査でそのゲームは到達可能だった。稼働ではなく到達可能:1台のホストからソケットを実測しているだけで、経路が通らないゲームも健在。この期間が観測してきた期間より長くなることはないので、事実は日付のほうであり、長さはそこから導かれる。 + + + 現在、到達可能が途切れずに続いている掲載ゲームはない。 + + + 示した日付以降のすべての探査で到達可能だったゲーム。到達可能の起点が同じゲームは同順位で、ここでは同点を崩さない。 + + + 到達可能の起点 + + + その長さ + + + アーカイブ済みのゲームは両方の表から外れるだけで、それ以外は何も変わらない。1回の探査が成功すれば戻る。 + + + 最も賑わうゲーム — 実測接続数の中央値、直近{days, plural, other {#日}} + + + 期間: + + + これ + + + 中央値{median, number} · ピーク{peak, number} · {window, number}日のうち{days, number}日で{samples, plural, other {計数サンプル#件}} + + + {date}以降のすべての探査で到達可能 · {duration} + + + アーカイブ + + + 応答しなくなったゲーム。削除はしていない。毎週の探査は続いており、1回の探査が成功すれば、その日のうちに一覧へ戻る。 + + + アーカイブを検索 + + + アーカイブ済みのゲームを検索 + + + 名前・コードベース・説明 + + + 表示 + + + {count, plural, =0 {アーカイブ済みのゲームなし} other {アーカイブ済みのゲーム#件}} + + + アーカイブ済み + + + 最終到達 + + + 稼働確認期間 + + + ({age}前) + + + 該当なし。 + + + 実測した範囲では一度もなし + + + 到達可能時間の実測なし + + + 不明 + + + {years, number, ::.#}年 + + + {days, plural, other {#日}} + + + {from} – {to} · {span} + + + {count, plural, other {#か月}} + + + {count, plural, other {#年}} + + + 「{query}」に一致する{count, plural, other {#件のゲーム}} + + + {count, plural, other {#件のゲーム}} + + + 最終到達: + + + 稼働確認期間: + + + 実測した到達可能時間のうち{value} + + + 稼働期間: + + + コードベース: + + + ランダムなゲーム + + + {d, date, medium} — 平均{typical}、{probes, plural, other {#回の探査}}で{low}〜{high} + + + {d, date, medium} — {count, plural, =0 {接続数0} other {#人}}、{probes, plural, other {#回の探査}}のいずれでも同じ + + + {d, date, medium} — 探査したが接続数は読み取れず + + + {d, date, medium} — 実測なし + + + {days, plural, other {#日}}のうち{counted}日で、通常{typical}、ピークは{peak}。 + + + この範囲を通じて横ばい。 + + + 範囲の始めから終わりまでで約{change, number, percent}の増加。 + + + 範囲の始めから終わりまでで約{change, number, percent}の減少。 + + + この範囲では探査したが、そのどこからも接続数は読み取れず。 + + + この範囲に実測なし。 + + + この範囲に計数なし。 + + + {from, date, d MMM}〜{to, date, d MMM} + + + {d, date, d MMM} + + + {span}:通常{typical}、ピーク{peak}、{days, plural, other {#日}}を計数 + + + {span}:探査したが接続数は読み取れず + + + {span}:未計測 + + + {count, plural, other {#日は探査したが計数なし}} + + + {count, plural, other {#日は未計測}} + + + {line}、{clause} + + + 日付はUTC · {from, date, medium} – {to, date, medium} + + + 上端は{value} + + + {days, plural, other {#日}}のうち{counted}日を計数 + + + {d, date, MMM} + + + {d, date, MMM yyyy} + + + その日に読み取った計数の平均 + + + その日の最多の計数まで + + + その日の最少から最多までの計数 + + + 探査したが接続数は読み取れず + + + 未計測 — 棒そのものなし + + + 未計測 — 線が途切れる + + + 推移の範囲 + + + 推移の形 + + + {days, plural, other {#日}} + + + ← 前の期間 + + + 後の期間 → + + + 折れ線 + + + 棒 + + + 接続数の推移 + + + {from, date, medium} – {to, date, medium}、UTC + + + 前の期間 + + + 週は、その週のうち計数できた日をまとめたもの。1日もない週はそう記す + + + 到達可能 · 直近{days, plural, other {#日}} + + + 到達可能 + + + 最長の途絶 + + + 直近の原因 + + + 期間内になし + + + 記録なし + + + {days, plural, other {#日前}} + + + 今日 + + + 到達可能 + + + 劣化 — 応答はあったが完了せず + + + 到達不可 + + + 未計測 + + + 到達可能 + + + 劣化 + + + 到達不可 + + + 未計測 + + + {d, date, d MMM} — 終日到達可能 + + + {d, date, d MMM} — 劣化({cause}):応答はあったが完了せず + + + {d, date, d MMM} — 到達不可({cause}) + + + {d, date, d MMM} — 未計測。このゲームはまだ観測していなかった + + + {days, plural, other {直近#日はまだ未計測。}} + + + 直近{days, plural, other {#日}}のうち{percent}が到達可能。 + + + 実測した{days, plural, other {#日}}のうち{percent}が到達可能。 + + + {days, plural, other {直近#日の到達可能性は、まだ未計測。}} + + + 期間内に到達不可の日はなし。 + + + 実測した日に到達不可はなし。 + + + {count, plural, other {#日が到達不可。}} + + + {count, plural, other {#日が劣化 — 接続はできたが完了できず。}} + + + 最長の途絶は{duration}。 + + + 最長の途絶は{duration}({cause})。 + + + {count, plural, other {#日は、実測が始まる前。}} + + + {from, date, d MMM} – {to, date, d MMM} + + + {d, date, d MMM} + + + {range}:{count, plural, other {#日}}{word} + + + {range}:{count, plural, other {#日}}{word}({cause}) + + + 到達可能 + + + {days, plural, other {直近#日}} + + + 到達可能:{days, plural, other {直近#日}}のうち{percent} + + + 最長の途絶:{duration} + + + dnsが解決しなかった + + + 接続を拒否された + + + tlsが失敗した + + + タイムアウトした + + + ハンドシェイクが止まった + + + 原因の記録なし + + + このゲームの接続画面を再公開しないよう、所有者から要請があった。 + + + このゲームから接続画面は取得できていない。 + + + {count, plural, other {返ってきたのは#行だけ — 表示するには少なすぎる。}} + + + サーバーが送ったまま + + + {columns}×{rows} + + + {columns}×{rows}、倍幅 + + + 16色SGR + + + 色なし + + + {charset}として読み取り + + + 取得 + + + 凍結 — 最後に見た画面 + + + ASCIIアート:{game}の接続画面。その文字は下の「テキストで読む」にある。 + + + ASCIIアート:このゲームの接続画面。その文字は下の「テキストで読む」にある。 + + + {count, plural, other {接続画面:#行、テキストのみ}} + + + {count, plural, other {接続画面:#行、テキストのみ、{charset}として読み取り}} + + + ゲームは申告しているが、サーバーはハンドシェイクで一度も提供していない。 + + + サーバーは提供しているが、ゲーム自身の記載では非対応となっている。 + + + たいていは手書きのフィールドが古いだけで、嘘ではない。両者が食い違う点にクライアントが依存すべきではないので表示している。 + + + 見つからない + + + このアドレスにゲームはない。綴りを確認。 + + + 最終応答 + + + {count, plural, other {#件が更新期間を過ぎている。古いだけで、誤りではない。}} + + + このゲーム自身のリファラル一覧が挙げているもの: + + + このゲームを挙げているリファラル一覧: + + + {date}から + + + 掲載終了、最終確認 + + + 最終応答は{date}、{ago}前。 + + + 毎週の探査は継続中。応答があれば、その日のうちにこのページが更新される。 + + + 稼働確認期間{span} + + + 一覧、ランキング、日次の数値には含まれない:このアドレスが誰かの遊べるゲームだとは考えていない。以下はすべて、このアドレスが伝えてきた内容そのまま。 + + + 当サイトの理由:{why} + + + 運用している人たちの要請により、一覧、ランキング、日次の数値には含まれない。以下はすべてそのまま保存されており、このページとこれまで持っていたすべてのアドレスは応答し続け、当サイトはダイヤルしていない。 + + + 現在の接続数:{count} + + + 機能({total}件のうち{disagreeing}件が不一致) + + + ゲームの自己申告 + + + 接続画面 + + + 変更点 + + + 実測 + + + 自己申告 + + + ** 不一致 + + + 接続数 + + + 到達 + + + 検索 + + + アーカイブ済み + + + アダルト + + + 含む + + + 計数なし + + + 現在の計数なし、理由もここには記されていない + + + 現在の接続数:計数なし(理由もここには記されていない) + + + クローラー実行中 · 最終探査{age} + + + クローラー待機中 · 最終探査{age} + + + ここではまだ探査が一度も完了していない + + + このサイクルに探査予定なし + + + {considered, plural, other {#件が探査予定}} · {answered, plural, other {#件が応答}} · {failed, plural, other {#件が失敗}} + + + 登録簿に{targets, plural, other {#件のアドレス}}、{due, plural, other {うち#件が現在探査予定}} + + + {page} — {site} + + + MU*ホビーのディレクトリ — MUSH、MUD、MUCK、MOO — どの事実にも、その出所といつのものかが記されている。 + + + デモデータ — ここにある値は実測ではない。{description} + + + {site} — 実測であって、申告ではない + + + {site}の{title} + + + ゲーム + + + アーカイブ + + + ランキング + + + エコシステム + + + リファレンス + + + 概要 + + + 見つからない + + + ランダムなゲーム + + + 自分のゲーム + + + {game}を申請 + + + 到達したすべてのMU*を、実測した内容で絞り込む:コードベース、サーバーがハンドシェイクで提供したプロトコル、TLS、エンコーディング、言語、そして最後に到達した時期。 + + + 消息不明になったゲームを、そのまま保持している。ページも履歴もURLも残り、毎週の探査は続き、1回接続できれば一覧に戻る。 + + + 最も賑わう、最も到達可能、最も長く続く — 実測データのみから算出。投票も、星も、評価も、このサイトのどこにもない。 + + + 実測しているゲーム全体でのコードベースのシェアとプロトコルの採用状況。サーバーが提供するものを、自己申告の内容と並べて示す。示すのはシェアであって、総数ではない。 + + + MU*ホビーのコードベース、クライアント、プロトコルについて、人の手で書いたページ。クロールから得た件数と相互にリンクしている。 + + + このカタログの作り方:クローラーが何をするか、何を拒むか、そしてどう止めるか。 + + + このアドレスにゲームはない。ここでは何も削除しないので、かつてこのURLにあったゲームは今もある — 綴りを確認。 + + + カタログから1件、ランダムに選んだゲーム。同じものが二度出ることはない。 + + + 申請した掲載と、申請によって何を変えられるか。 + + + 運用者にしか置けない場所にトークンを公開して、このゲームを運用していることを証明する。 + + + アーカイブ済み — 最終到達{age}、探査は継続中 + + + アーカイブ済み、探査は継続中 + + + 接続数不明 — ゲームは応答するが、読み取れる数値は公開していない + + + {count, plural, other {#人}}、{how}、{age} + + + 新規発見 + + + 消息不明 + + + 復帰 + + + {count, plural, other {既知のゲーム#件}} + + + {count, plural, other {現在接続中#人(実測)}} + + + {count, plural, other {応答あり・読み取り不可#件}} + + + {count, plural, other {アーカイブ済み#件、探査は継続中}} + + + リファレンス + + + コードベースとは何か、クライアントは何をするか、プロトコルは何を意味するか。人の手で書き、クローラーと同じリポジトリに置いている — ウィキではなく、このページに編集できるものはない。ここにある{number}はどれも別物:カタログから取り、ページを読み込むたびに再計算している。 + + + 数値 + + + 人の手で書き、著者は一人、gitでバージョン管理している。ここにある文章は当サイトのもの。その横にある数値はすべてクローラーが実測し、リクエストのたびに再計算している。ウィキではなく、このページから編集する方法もない。 + + + はじめに + + + コードベース + + + クライアント + + + プロトコル + + + 入門 + + + コードベース + + + クライアント + + + プロトコル + + + 見つからない + + + ここにリファレンスのページはない。この節は人の手で書いているので、空白は誰も手をつけていない仕事であって、削除されたものではない — {index}。 + + + 何があるかを見る + + + これを動かしているゲーム + + + まだ一つも特定していない。これはこのクローラーが実測した内容についての事実であって、存在するものについてのものではない — 到達していないゲームや、コードベースを読み取れなかったゲームは、ここには数えていない。 + + + {count, plural, other {掲載#件}} + + + {count, plural, other {アーカイブ済み#件}} + + + 実測であって、申告ではない + + + このリクエストの時点でカタログから数えたもので、上のリンクが持つのと同じフィルターによる — この数値とその一覧は一つのクエリであり、食い違うことはない。 + + + ハンドシェイクで提供していたもの:{protocols} + + + 実測した採用状況 + + + まだ実測なし。 + + + {listed, plural, other {掲載#件のゲーム}}のうち — {percent} + + + ここに数えていないゲームは、そのプロトコルを持たないゲームということではない。数えるのは、そのサーバーがハンドシェイクでそのオプションを提供するのを観測したとき。残りは、当サイトに提供しなかったサーバーと、ハンドシェイクをまだ読んでいないサーバーであり、どちらなのかは言えない。 + + + ハンドシェイクで{protocol}を提供するのを観測したゲームを、動かしていると特定したコードベース別に示したもの。 + + + コードベース + + + 提供あり + + + 特定済み + + + 関連項目 + + + 機能 + + + {count, plural, other {{total}件のうち#件はプロジェクト自身のドキュメントで確認}} + + + 各プロジェクト自身のドキュメントから読み取ったものであり、当サイトの実測ではない — クライアントには観測できるハンドシェイクがない。「{unknown}」は、調べたが確認できなかったという意味。「いいえ」の意味では決してない。 + + + クライアントの機能。いずれも各プロジェクト自身のドキュメントから読み取ったもの。不明は確認できなかったという意味であって、そのクライアントに備わっていないという意味ではない。 + + + 文書化 + + + 典拠 + + + 見つけられなかった + + + はい + + + いいえ + + + 不明 + + + 動作環境:{platforms} + + + このコードベースを動かしていると特定したゲーム + + + まだない。これは当サイトが実測した内容についての言明であって、存在するものについてのものではない — 到達していないゲームや、コードベースを読み取れなかったゲームは、ここには数えていない。 + + + {listed, plural, other {掲載#件}}、{archived, plural, other {アーカイブ済み#件}} + + + ハンドシェイクで実測:{protocols} + + + これまで読んだどのハンドシェイクでも、何も提供されなかった。 + + + {listed, plural, other {掲載#件のゲーム}}のうち{offering}件で提供を観測({percent}) + + + 特定できたゲームの、コードベース別 + + + {identified}件のうち{offering}件が提供 + + + {count, plural, other {{total}行のうち#行が不明}}:どちらとも述べたプロジェクト自身のドキュメントを見つけられなかった。短くて正直な表のほうが、長くて当て推量の表よりよい。 + + + 自分のゲーム + + + アカウントにはデータベースが必要だが、このサイトはデモ用のダミーデータで動いている。 + + + サインイン + + + {name}としてサインイン中。 + + + サインアウト + + + まだ何も申請していない。{listing}で自分のゲームを見つけ、そのページで{claimControl}を押す。 + + + 一覧 + + + このゲームを申請する + + + 手放した。 + + + その記録は残る。新しいトークンを公開すれば、いつでもまた管理していることを証明できる。 + + + 保存した。 + + + そのゲーム + + + {game}のページに、所有者申告として表示されるようになった。 + + + {game}の接続画面の再公開をやめた。ページには穴を残さず、そのことを明記する。 + + + {game}の接続画面を、またそのページに表示している。 + + + 把握しているすべてのアドレスで、{game}へのダイヤルをやめた。そのページには、要請より前に実測したものがすべて残る。 + + + スケジュールで次の順番が来たときから、{game}へのダイヤルを再開する。 + + + {game}は一覧、ランキング、日次の数値から外れた。そのページと、これまで持っていたすべてのURLは応答し続ける。 + + + {game}は一覧に戻った。また実測されるのに要るのは、応答が返る探査1回だけ。 + + + {field}は変更していない。 + + + ここは1行で答える欄で、保存するのは最大{max}文字まで。 + + + その項目は実測。申請で足せるのはMSSPに収まらない内容であって、観測した内容は誰にも編集できない。当サイト自身にも。 + + + 申請済み + + + {date}に検証 + + + {date}に検証、トークンの最終確認{seen} + + + MSSPを確認する + + + {count, plural, other {{names}も所有者 — それぞれ自分のトークンを検証している。}} + + + 別のアカウント + + + 自分のサイトに接続数を載せる + + + バッジは接続数と、それを実測した時期を併せて示す。古さの分からない数値こそ、このサイトが置き換えるために存在するものだから。 + + + 数えられなかったときはゼロではなく{unknown}と表示し、ゲームが応答しなくなれば{archived}と表示する。 + + + 自分で描きたければ{json}もある。 + + + 履歴 + + + トークン発行 + + + トークン再発行 + + + 検証 — トークンを読み取った + + + トークンは引き続き公開中 + + + 今回はトークンを読み取れず + + + 申請を手放した + + + トークンが未使用のまま期限切れ + + + 別のアカウントが管理していることを証明し、ゲームを引き継いだ + + + 確認を要請 + + + この申請を手放す + + + 確認のため{word}と入力する。何も削除されず、新しいトークンを公開すればまた管理していることを証明できる。他に所有者がいれば、ゲームは申請済みのまま。 + + + {game}を手放す + + + トークン待ち + + + トークン発行{issued}、有効期限{expires} + + + パスキー + + + 名称なし + + + 追加{date} + + + 追加{date} · 端末1台のみ + + + このパスキーは1台の端末にしかない。失くしても、ゲームに新しいトークンを公開すれば入り直せるが、2つ目のパスキーがあるほうが早い。 + + + パスキーをもう1つ追加 + + + そのゲームはない + + + {game}を申請 + + + 申請にはデータベースが必要だが、このサイトはデモ用のダミーデータで動いている。 + + + 先にアカウントが要る。要るのはパスキーと名前だけ。 + + + サインイン、またはアカウントを作成 + + + 自分のゲーム + + + {count, plural, other {このゲームには、サーバーを管理していることを証明した所有者がすでに#人いる。}}こちらも証明できる — 確かめ方はどちらでも同じ — が、それが何を意味するのかは伝えてもらう必要がある。トークンからは判別できないから。 + + + こちらも運用している — 所有者に加えてほしい + + + 全員が自分の申請を保つ。1つのゲームを2人で運用している状態。 + + + 引き継いだ — こちらに移してほしい + + + {count, plural, other {トークンが検証された時点で、既存の申請は取り消され、ゲームはこちらのものになる。}}相手には自分の履歴でその理由が見える。何も削除されず、相手はこれから行うのと同じ方法で、また管理していることを証明できる。 + + + 検証できた。 + + + {date}、そのゲームのMSSPレポートからトークンを読み取った。 + + + {date}、接続画面からトークンを読み取った。 + + + トークンはそのままにしておく。同一性の手がかりも兼ねるので、ホストを移しても名前を変えても、このゲームは見分けがつく。外しても申請が解けるわけではない。 + + + 匿名の接続に見える場所であれば、どこにこのトークンを公開してもよい。次の探査がそれを拾い、それがそのサーバーに書き込めることの証明になる。 + + + これは移管。 + + + {count, plural, other {このトークンを読み取った時点で、このゲームに対する現所有者の申請は取り消され、こちらのものになる。}} + + + どちらか一方でよい + + + MSSPの変数 + + + {codebase}では{file}の1行。MSSPを持つコードベースには、どれも同じものがある。 + + + {aliases}も受け付ける。 + + + 接続画面の1行 + + + 画面内のどこでもよく、周りに色コードがあっても構わない。 + + + そのあと + + + 確認は通常のクロールスケジュールで行う。このトークンの有効期限は{date}。いつ戻ってきてもよく、書き留めておくものは何もない。 + + + 早めに見に行く + + + 自分のゲームを待ち行列の先頭に入れる。ダイヤルは当サイト自身のスケジュールで行うので、今すぐではなく、早まるだけ。 + + + 今要請したばかり。数分おいてからもう一度 — 本来より早く実際のサーバーにダイヤルすることになるので、回数を制限している。 + + + {game}について、運用者にしか伝えられないこと + + + ここにあるのは、MSSPに対応する欄がないもの。自分のゲームのページには、最後に確認した日付を添えて{declared}として、実測した内容と並べて表示する — 実測に代えて表示することは決してない。実測したものは、ここからは誰にも編集できない。運用者にも当サイトにも。 + + + {age}に自己申告。取り下げるにはこの欄を空にする — どちらにせよ、何と書かれていたかの記録は残る。 + + + 自己申告した内容を保存 + + + {game}が報告している内容と、代わりに表示してほしい内容 + + + MSSPはどのクローラーも読むものであり、ここに入れた内容の横に、当サイトはそれを表示し続ける — 運用者の回答が、ゲーム自身の回答を隠すことはない。 + + + 実測したものは、ここからは何も編集できない:接続数も、機能も、到達可能だった1時間も。 + + + 下の行が{file}で誤っているなら、そこで直せばすべての場所で直る。 + + + ゲームの報告は{value}、確認は{age} + + + ゲームはここで何も報告していない + + + 名前を変えると、{game}の掲載名と、そのページのアドレスが変わる。古いアドレスは永久に動き続け — このゲームがこれまで持っていたすべてのURLは現在のURLへ転送される — 欄を空にすれば、名前はMSSPの内容に戻る。 + + + 自分の接続画面 + + + 再公開はしていない。ページには穴を残さず、そのことを明記しており、クローラーは読み続けている — ゲームが移ったときに見分ける手がかりがそれだから。 + + + また表示する + + + 表示しているのは、サーバーがそれをすべての匿名接続に送っているから。表示してほしくなければ、そう言えばやめる。理由は訊かない。 + + + 接続画面の表示をやめてほしい + + + クロールされること + + + 停止した。{game}へは一切ダイヤルしておらず、ページには要請より前に実測したものがすべて残る — 空いた時間は原因を名指ししない。{ourFact}というのは当サイトについての事実であって、そのゲームについての実測ではないから。 + + + 停止を要請された + + + またクロールしてほしい + + + これはここからではなく、サーバー自身から来たもの — {routes}。またクロールされるには、その公開をやめる。次のサイクルでそれを受け取る。 + + + MSSPレポートが{variable}を公開している + + + {label}のTXTレコードが停止を求めている + + + 記録済みの要請 + + + {stopped}では停止し、{dialling}には引き続きダイヤルしている。たいていは離脱のあとに追加されたポート。 + + + すべてのアドレスでも停止してほしい + + + {game}へは一定のスケジュールでダイヤルし、匿名の接続に見えるものを読んでいる。やめてほしければ、そう言えばやめる — 1回のサイクル以内に、把握しているすべてのアドレスで。理由は訊かない。 + + + すでに実測したものは何も削除されない:ページは履歴とURLを保ち、これを撤回したあとの探査1回で再開する。当サイトを通さず、自分の設定で伝えることもできる — MSSPの{mssp}か、{dns}のTXTレコード — このゲームをここで誰かが申請しているかどうかにかかわらず、それに従う。 + + + クロールをやめてほしい + + + 掲載されること + + + {game}は一覧からも、ランキングからも、日次の数値からも外れている。ページと、これまで持っていたすべてのURLは応答し続け、要請より前に実測したものはすべてそこに残っている。何も削除していない。閲覧してたどり着ける場所ではなくなるだけ。 + + + 一覧に戻してほしい + + + 応答が返る探査1回でも同じことが起きる。離脱が有効な間はダイヤルしないので、そうなることはない — ただし撤回した日から1週間以内にそのアドレスの順番が来て、応答が返った探査が一覧に戻す。二度頼む必要はない。 + + + ダイヤルは停止しており、ページはそれ以前に実測した内容とともに一覧に残っている。残したくなければ、そう言えば外す — 一覧からも、ランキングからも、日次の数値からも。 + + + 何も削除されず、何も壊れない:ページは応答し、これまで持っていたすべてのURLは今もそこへ転送され、案内した相手には見える。閲覧してたどり着ける場所ではなくなるだけ。ここから元に戻せるし、離脱を撤回したあとに応答が返った探査でも元に戻る。 + + + 一覧からも外してほしい + + + 実測できたもの + + + 非表示にすると一覧から外れる — そのゲームが無人だという意味ではない。 + + + 接続数読み取り不可 + + + 到達不可 + + + この一覧では非表示 + + + この一覧では非表示 + + + 左の列で、* はこの一覧が絞り込みに使っている値、- は除外に使っている値。どちらもクエリでの選択であって、ゲームについての事実ではない。 + + diff --git a/src/MUI.Web/Resources/Messages.nl.resx b/src/MUI.Web/Resources/Messages.nl.resx new file mode 100644 index 0000000..56549b8 --- /dev/null +++ b/src/MUI.Web/Resources/Messages.nl.resx @@ -0,0 +1,2877 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + {count, plural, one {# spel} other {# spellen}} + + + {value}, {count, plural, one {# spel} other {# spellen}}, alleen + + + {value}, {count, plural, one {# spel} other {# spellen}}, uitgesloten + + + {value}, {count, plural, one {# spel} other {# spellen}} + + + ongeacht {facet}, {count, plural, one {# spel} other {# spellen}} + + + {count, plural, =0 {Hier staan geen spellen.} one {# spel, waarvan elk feit vermeldt hoe het verkregen is.} other {# spellen, waarvan elk feit vermeldt hoe het verkregen is.}} + + + {days, plural, one {# dag} other {# dagen}} gemeten · {probes, plural, one {# peiling} other {# peilingen}} + + + {days, plural, one {#d} other {#d}} · {count, plural, one {# telling} other {# tellingen}} + + + {disagreeing, plural, =0 {Geen van de {total} wijkt af.} one {# van de {total} wijkt af van wat het spel opgeeft.} other {# van de {total} wijken af van wat het spel opgeeft.}} + + + {count, plural, =0 {Geen spel past bij elk antwoord.} one {# spel past bij elk antwoord.} other {# spellen passen bij elk antwoord.}} + + + van {listed, plural, one {# vermeld spel} other {# vermelde spellen}} · {answers, plural, =0 {geen antwoorden gegeven} one {# antwoord gegeven} other {# antwoorden gegeven}} + + + {count, plural, one {Toon het ene spel} other {Toon deze # spellen}} + + + "{answer}" laten vallen — {count, plural, one {# spel} other {# spellen}} + + + antwoord wissen op: {question} + + + Een spel zoeken + + + passend bij alle antwoorden + + + {count, plural, one {spel} other {spellen}} + + + alle antwoorden wissen + + + opnieuw beginnen + + + {count, plural, one {# meer} other {# meer}} + + + gegeven antwoorden + + + de hele lijst + + + die zoekopdracht is geweigerd + + + een naam, als je er een hebt + + + naam, of een deel ervan + + + Zoeken op naam + + + Speelt er nu iemand? + + + Wat wil je spelen? + + + Wat voor spel? + + + In welke taal? + + + Heeft je client iets nodig? + + + Ook stilgevallen spellen meenemen? + + + maakt niet uit + + + elk genre + + + elke soort + + + elke taal + + + maakt niet uit + + + nee, alleen live spellen + + + ja, toon die ook + + + stilgevallen spellen + + + TLS — versleuteld, handshake door ons voltooid + + + MSSP — zelfbeschrijving van de server + + + MCCP — gecomprimeerde uitvoer + + + MXP — klikbare links + + + GMCP — gestructureerde clientgegevens + + + MSDP — gestructureerde clientgegevens + + + CHARSET — onderhandeling over codering + + + UTF-8 — niet-Latijnse tekst wordt weergegeven + + + TTYPE — client meldt zijn type + + + ATCP — gestructureerde clientgegevens + + + MSP — geluidstriggers + + + EOR — promptmarkering + + + {token} — gemeten in de handshake + + + gemeten + + + gemeten + + + gemeten + + + gemeten + + + gemeten + + + opgegeven + + + opgegeven + + + opgegeven + + + opgegeven + + + afgeleid + + + afgeleid + + + niet gemeten + + + ongeteld + + + onbereikbaar + + + niet geteld + + + geen telling + + + van hieruit + + + gearchiveerd + + + geclaimd + + + Niets om uit te kiezen + + + Geen spel past bij dat filter. Probeer {listing}, of {archive}. + + + de hele lijst + + + het archief meenemen + + + verbonden + + + niet geclaimd + + + geclaimd door de eigenaar + + + wordt nog gepeild + + + typisch + + + piek + + + als tekst lezen + + + platte tekst + + + naar de inhoud + + + ASCII-banner: het verbindingsscherm van {game}. + + + Catalogi + + + Deze site en je account + + + bladeren + + + uitleg + + + deze site + + + menu + + + spellen + + + zoeken + + + willekeurig + + + archief + + + naslag + + + ecosysteem + + + ranglijsten + + + over + + + aanmelden + + + een spel aanmelden + + + inloggen + + + je spellen + + + thema + + + automatisch + + + licht + + + donker + + + Demogegevens. + + + Er is geen database geconfigureerd, dus dit zijn testgegevens. Niets hiervan is gemeten. + + + alle spellen + + + archief + + + opgegeven door het spel + + + wat er veranderd is + + + Een gids voor de MU*-hobby + + + Elk feit vermeldt hoe het verkregen is en hoe oud het is: gemeten door onze crawler, of opgegeven door het spel en als zodanig gemarkeerd. + + + Spellen zoeken op naam, thema, codebase of host + + + zoeken op naam, thema, codebase of host + + + zoeken + + + bekende spellen + + + nu verbonden + + + antwoordt, ongeteld + + + gearchiveerd + + + nieuw ontdekt + + + stilgevallen — wordt nog gepeild + + + weer terug + + + Niets nieuws. + + + Er is niets stilgevallen. + + + Er is niets teruggekomen. We blijven kloppen. + + + live + + + Spellen + + + gesorteerd op {order} + + + willekeurig + + + verbonden · bereikt + + + van hieruit + + + Geen treffers. + + + Probeer minder woorden, of laat een filter vallen. + + + filters wissen + + + over {codebase} + + + nooit + + + geclaimd door de eigenaar + + + Onbekende codebase + + + we konden niet vaststellen op welke codebase dit spel draait + + + en {count, plural, one {# meer} other {# meer}}: {names} + + + Volgorde + + + Periode + + + nu + + + typisch + + + piek + + + naam + + + bereikt + + + 7 dagen + + + 30 dagen + + + 90 dagen + + + Spellen zoeken + + + spellen zoeken + + + filters + + + getoond + + + alles wissen + + + — hier niet meer op filteren + + + alle + + + meer filters ({count}) + + + {count, plural, one {# meer} other {# meer}} + + + ook tonen + + + Standaard uit. Geen van beide is een oordeel over het spel. + + + gearchiveerd + + + voor volwassenen + + + gearchiveerde spellen, {shown, select, true {getoond} other {verborgen}} + + + spellen die inhoud voor volwassenen opgeven, {shown, select, true {getoond} other {verborgen}} + + + Aantallen zijn spellen die we gemeten hebben, nooit schattingen. + + + wat de badges en de lege plekken betekenen + + + Een lege plek is een gat in onze meting, geen nee. Elk facet zegt het op zijn eigen manier: niet vastgesteld, niet opgegeven, niets onderhandeld. + + + Een gemeten nul is een telling. Een onbekende telling is geen nul en wordt ook nooit als nul gesorteerd. + + + Open facetten tonen hun {count} meest voorkomende waarden. De rest is bereikbaar via zoeken en via URL. + + + Niet aangevinkt betekent niet gemeten — niet dat het spel het mist. + + + activiteit + + + laatst gezien + + + aangeboden protocollen + + + versleuteld + + + codering + + + codebase + + + versie + + + afstamming + + + familie + + + genre + + + taal + + + nu verbonden + + + actief deze week + + + rustig — nooit boven 0 geteld + + + stil — een maand niet bereikt + + + gearchiveerd + + + in de laatste 24 uur + + + in de laatste 7 dagen + + + in de laatste 30 dagen + + + langer geleden + + + nooit bereikt + + + niets onderhandeld + + + niet vastgesteld + + + niet opgegeven + + + verbonden via TLS + + + niet {value} + + + iets onderhandeld + + + wel vastgesteld + + + wel opgegeven + + + we hebben dit zien gebeuren + + + het spel zegt het, en wij hebben het niet gecontroleerd + + + we hebben gegroepeerd wat het spel ons vertelde + + + naam + + + nu verbonden + + + laatst bereikt + + + typisch verbonden · 7 dagen + + + typisch verbonden · 30 dagen + + + typisch verbonden · 90 dagen + + + meeste tegelijk verbonden · 7 dagen + + + meeste tegelijk verbonden · 30 dagen + + + meeste tegelijk verbonden · 90 dagen + + + nu op de rij + + + typisch + + + piek + + + Onbekende telling + + + nog nooit bereikt — niet dat het lang geleden bereikt is + + + minder dan {minimum} tellingen in de periode, of helemaal geen — geen typische telling van nul + + + niets dat we in de periode konden tellen — niet dat er niemand verbonden was + + + mediaan {value} · {days, plural, one {#d} other {#d}} · {count, plural, one {# telling} other {# tellingen}} + + + meeste {value} tegelijk · {days, plural, one {#d} other {#d}} · {count, plural, one {# telling} other {# tellingen}} + + + Verbindingsscherm + + + Verbindingen per uur + + + Hoeveel, in de loop van de tijd + + + Bereikbaar + + + Wat er veranderd is + + + Mogelijkheden + + + Opgegeven door het spel + + + Verwijzingen + + + Niet geclaimd — alles hier is gemeten. + + + Geclaimd door de eigenaar — de gemeten feiten hieronder blijven van ons. + + + Dit spel claimen + + + antwoordt sinds {date} + + + als tekst lezen — {count, plural, one {# rij} other {# rijen}} + + + mogelijkheid + + + leeftijd + + + aangeboden + + + stil + + + afwezig + + + geweigerd + + + geclaimd + + + wijkt af + + + waar ze afwijken ({count}) + + + {day} {time} — {count, plural, =0 {0 spelers, gemeten} one {gemiddeld # speler} other {gemiddeld # spelers}} + + + {day} {time} — gepeild, geen telling kon worden gelezen + + + {day} {time} — geen meting in dit uur + + + We hebben de activiteit van dit spel nog niet gemeten. + + + Geen enkel uur van de week heeft een spelerstelling opgeleverd. + + + Elk uur gemeten en in geen enkel uur was er iemand verbonden. + + + {count, plural, one {# uur op {day} heeft nog geen meting.} other {# uren op {day} hebben nog geen meting.}} + + + {count, plural, one {# uur in de week heeft nog geen meting.} other {# uren in de week hebben nog geen meting.}} + + + {count, plural, one {# uur op {day} antwoordde maar leverde geen telling op.} other {# uren op {day} antwoordden maar leverden geen telling op.}} + + + {count, plural, one {# uur in de week antwoordde maar leverde geen telling op.} other {# uren in de week antwoordden maar leverden geen telling op.}} + + + Elke dag het drukst, {window}. + + + Elke dag het drukst in de {part}, {window}. + + + Het drukst op {days}, {window}. + + + Het drukst op {days} in de {part}, {window}. + + + Steevast rustig {who}, {window}. + + + Steevast rustig {who} in de {part}, {window}. + + + elke dag + + + elke dag die we konden meten + + + op werkdagen + + + op {days} + + + ochtend + + + middag + + + avond + + + nachtelijke uren + + + ochtenden + + + middagen + + + avonden + + + nachtelijke uren + + + {list}, {next} + + + {first} en {second} + + + nog te weinig metingen + + + Nog geen enkel uur van de week is gemeten. + + + {count, plural, one {# uur antwoordde en leverde geen telling op.} other {# uren antwoordden en leverden geen telling op.}} + + + Het raster verschijnt zodra elke dag van de week er een heeft. + + + {days, plural, one {Tot nu toe op # van de zeven dagen gemeten; het raster verschijnt zodra elke dag een uur bevat.} other {Tot nu toe op # van de zeven dagen gemeten; het raster verschijnt zodra elke dag een uur bevat.}} + + + {count, plural, one {# uur gemeten, met niemand verbonden.} other {# uren gemeten, allemaal met niemand verbonden.}} + + + {count, plural, one {# uur gemeten, het drukst met {peak} op {day} om {time} UTC.} other {# uren gemeten, het drukst met {peak} op {day} om {time} UTC.}} + + + {count, plural, one {Nog # uur antwoordde en leverde geen telling op.} other {Nog # uren antwoordden en leverden geen telling op.}} + + + {day} — {facts} + + + {first}, {second} + + + de hele dag op nul gemeten + + + piek {count} om {time} + + + niemand verbonden {window} + + + in geen enkel uur een telling + + + {count, plural, one {# uur niet gemeten} other {# uren niet gemeten}} + + + {count, plural, one {# uur gepeild maar niet telbaar} other {# uren gepeild maar niet telbaar}} + + + dag + + + rustigst + + + drukst + + + om + + + geen telling + + + Spelers verbonden per dag, in UTC. {window}. + + + tijden in UTC · {window} + + + {weeks, plural, one {voortschrijdend gemiddelde over # week} other {voortschrijdend gemiddelde over # weken}} + + + geteld, inclusief een gemeten nul + + + gepeild, geen telling kon worden gelezen + + + geen meting in dat uur + + + Wanneer er spelers verbonden zijn (UTC) + + + geteld + + + we kwamen binnen en lazen een getal, inclusief een gemeten nul + + + we kwamen binnen en er was geen getal te lezen + + + we hebben geen meting voor dat uur + + + taal + + + taal wijzigen + + + Over mu*index + + + Elk spel hier is gemeten door een machine die er verbinding mee maakte, en elke waarde vermeldt waar hij vandaan komt en wanneer. Deze pagina behandelt wat dat bewijst, wat we fout doen, wiens gidsen we lezen, en hoe de crawler te stoppen is. + + + Wat een feit hier is + + + Gemeten wint van opgegeven, en beide worden getoond. + + + Het MSSP-rapport van een spel is het spel dat zichzelf beschrijft. De telnet-handshake is wat we het hebben zien doen. Beide staan op zijn pagina, gelabeld met hoe en wanneer. Waar ze van elkaar afwijken, tonen we de afwijking. + + + Een spelerstelling vermeldt waar hij vandaan komt. + + + Ofwel een WHO of DOING die bij het verbindingsscherm gelezen is en door ons geteld, ofwel het eigen MSSP PLAYERS-veld van het spel, dat het zelf publiceerde. Nooit samengevoegd. + + + Een antwoord dat we niet kunnen lezen is onbekend, nooit nul. + + + Servers passen hun WHO-koppen naar believen aan, en voorbij een zeker punt kan onze parser er geen meer lezen. Dat is niet telbaar, een toestand op zichzelf. Een gemeten nul — we kwamen binnen, er was niemand — is een telling, en wordt ook als telling weergegeven. + + + Bereikbaar, nooit uptime. + + + We openen met tussenpozen een socket vanaf één host. Een spel waarheen we geen route hebben is onbereikbaar en volkomen springlevend. Niets hier beweert iets over de uptime van een spel, omdat niets hier die gemeten heeft. + + + Een uur is geteld, niet telbaar, of niet gemeten. + + + Het activiteitenraster heeft drie toestanden. De derde is leeg en noemt geen oorzaak: een uur dat we niet konden bereiken en een uur dat we nooit gepeild hebben zijn dezelfde afwezigheid, en geen van beide is downtime van die server. + + + Wat we weten dat we fout doen + + + Het archiefrespijt wordt gemeten vanaf de dag dat we je vonden. + + + Een spel dat stopt met antwoorden verlaat de standaardlijst na zijn respijtperiode: een kwart van de bereikbare tijd die we gepeild hebben, met een ondergrens van 60 dagen en een bovengrens van 365. Een spel dat sinds 1995 draait, begint op de ondergrens op de dag dat we het ontdekken. We importeren niets om de jaren vóór onze komst in te vullen. + + + We laten MSSP CREATED niet meetellen voor dat respijt. + + + Het is één met de hand ingetypte regel in een configuratiebestand, dus meetellen zou de archiefdrempel manipuleerbaar maken. Het wordt als opgave getoond en koopt niets. + + + Een spel claimen levert de bovengrens op. + + + Aantonen dat er servertoegang is, is het volle jaar respijt waard, hoe lang we ook al meekijken. + + + Alles hier is één host die met tussenpozen kijkt. + + + Een percentage bereikbare tijd is een deel van de periode die we waarnamen, nooit van een periode die we niet waarnamen. Geen enkele grafiek hier vult de rest in. + + + Er wordt nooit iets verwijderd. + + + Archiveren haalt een spel uit de standaardlijst, de ranglijsten en het cijfer van vandaag actief, en verder niets. Zijn pagina, URL, geschiedenis en adres blijven bestaan, het wordt nog gepeild, en één geslaagde peiling zet het terug. + + + Wat deze site niet zal doen + + + Geen stemmen, sterren, beoordelingen of aanbevelingen. + + + Ranglijsten worden alleen uit gemeten gegevens berekend. Een gids die gerangschikt is naar wie de meeste klikken kan mobiliseren, beschrijft de campagne en niet de hobby, en daaraan zijn de gevestigde gidsen ten onder gegaan. + + + Geen forums, recensies, wiki’s, reacties of spelersprofielen. + + + Oriënterend materiaal — wat een MUSH is, welke codebase past bij gezamenlijk rollenspel — wordt geschreven, ondertekend en van versies voorzien zoals de rest van de site. + + + Spelersnamen worden nooit bewaard. + + + Een WHO-antwoord wordt in het geheugen ontleed op een telling en de vorm van de kop. De namen worden niet vastgelegd; aggregaten gebruiken een gezouten hash met een roulerende salt. + + + Er wordt geen absoluut spelersaantal gepubliceerd. + + + Aandelen per codebase en per protocol gaan wel de deur uit: een verhouding over de gemeten verzameling overleeft de spellen die we niet kunnen tellen. ‘Hoeveel mensen spelen MU*’ overleeft dat niet, want dat getal zou het niet houden zodra het geciteerd werd. + + + De crawler, en hoe die te stoppen is + + + Een peiling is één verbinding die nooit inlogt. + + + Die opent een socket, onderhandelt over telnet-opties, leest het verbindingsscherm, vraagt om MSSP door optie 70 te onderhandelen, stuurt {commands} en verbreekt de verbinding. Geen personage, geen login, niets veranderd aan de andere kant. Een time-out begrenst de sessie, zodat een vastgelopen peiling geen verbindingsplek bezet kan houden. + + + CRAWL DELAY wint. + + + Een spel dat in zijn MSSP-rapport een gewenste minimale tussenpoos opgeeft, krijgt die, boven ons eigen schema in beide richtingen: 720 uur betekent maandelijks, niet wekelijks. Bij een stilgevallen spel wordt voor altijd op de langere tussenpoos aangeklopt, en zo zet het zichzelf weer in de lijst wanneer het terugkomt. + + + Een doorverwezen adres wordt geverifieerd, nooit vertrouwd. + + + Met MSSP kan een spel andere spellen noemen. Elke naam wordt opgelost voordat er ergens wordt aangeklopt, en geweigerd tenzij elk adres waarnaar hij oplost wereldwijd routeerbaar is. Een gemengd antwoord weigert het hele doel. Onze weigering wordt als de onze vastgelegd en verschijnt nooit als downtime in het dossier van een spel. + + + Verbindingsschermen worden getoond omdat ze naar iedereen gestuurd worden. + + + Een server tekent zijn verbindingsscherm, zonder authenticatie, naar elke anonieme verbinding. We tonen het als bewijs en labelen het. Eén verzoek en het gaat eraf. + + + Zeg stop, en we stoppen — op drie manieren. + + + Publiceer {variable} 1 in je MSSP-rapport, en de peiling die dat leest is de laatste. Of publiceer een TXT-record op {label}.jouw.host met de inhoud ‘{value}’, waarvoor geen MSSP-ondersteuning en geen account hier nodig is. Of schrijf een mens aan. Alle drie worden binnen één crawlcyclus gehonoreerd, vastgelegd met de datum en met wat we lazen, en ook op het aanmeldformulier afgedwongen. + + + Het MSSP-veld stopt die luisterpoort; het record stopt de host. + + + MSSP wordt gepubliceerd door de poort die antwoordde, dus spreekt het voor die poort — bij MU*-hosting draaien routinematig ongerelateerde spellen op één domein, en het ene mag het andere niet het zwijgen opleggen. Een TXT-record geldt voor elke poort tenzij het er een noemt, als ‘{value}=4201’. Alles wat daar staat en wat we niet als poortenlijst kunnen lezen betekent de hele host, dus ‘{value}=all’ werkt. + + + De DNS-route is de enige die je ongedaan kunt maken zonder het ons te vragen. + + + Een TXT-record is leesbaar zonder verbinding te maken met een server die dat verboden heeft, dus we lezen het opnieuw voordat we aankloppen. Verwijder het en we kloppen binnen een week weer aan. Een MSSP-veld kan niet opnieuw gelezen worden zonder precies te doen wat je ons gevraagd hebt te laten, dus afmeldingen via MSSP en schriftelijke verzoeken blijven staan tot je het tegendeel zegt. Die TXT-opzoeking is alles wat een afgemeld adres krijgt: ze raakt je nameserver, nooit je spel. + + + Stoppen is niet verwijderen, en het is geen downtime. + + + Een spel dat zich afmeldt, houdt zijn pagina, zijn adres en alles wat we gemeten hebben voordat het erom vroeg. Alleen nieuwe gegevens stoppen: het activiteitenraster krijgt er geen uren meer bij en noemt geen oorzaak, want ons besluit om te stoppen met kloppen is een feit over ons. Het wordt vastgelegd bij de crawl die niet plaatsvond, en in het register van wie erom vroeg. + + + Als stoppen niet genoeg is, kan de vermelding er ook af. + + + Zodra we gestopt zijn op elk adres waarop je spel antwoordt, biedt je dashboard nog één ding: het uit de lijst, de ranglijsten en het dagcijfer halen. De pagina en elk adres dat het ooit gehad heeft blijven antwoorden, en er wordt niets verwijderd — het houdt op een plek te zijn waar een lezer al bladerend aankomt. Er is een geverifieerde claim voor nodig, want het is een besluit over je spel en we leggen vast wie het genomen heeft. En een peiling maakt het ongedaan: trek je afmelding in, en de eerstvolgende keer dat er aangeklopt wordt en er antwoord komt, sta je weer in de lijst zonder dat je het ons nog eens hoeft te vragen. + + + De crawler noemt zichzelf {name} wanneer een server vraagt wat hij is. + + + De crawler is ingesteld om zichzelf {name} te noemen, maar kan dat nog niet zeggen. Zijn telnet-bibliotheek biedt een client geen manier om het terminaltype in te stellen, dus je logboeken zien de standaardwaarde van die bibliotheek, en NEW-ENVIRON wordt beantwoord vanuit de omgeving van de crawlerhost. Beide zijn gaten in de bibliotheek en aan ons om ze daar te dichten. Tot die tijd: herken een peiling aan de vorm ervan — één verbinding, geen login, een korte set alleen-lezen-commando’s, weg. + + + crawler + + + contact + + + Crawler: {name} + + + Contact: {url} + + + — tijdelijke aanduiding; deze installatie heeft geen contactadres ingesteld + + + Er is geen contactadres ingesteld, dus het adres hierboven is een tijdelijke aanduiding en beantwoordt niemand. + + + Waar de lijst met spellen vandaan komt + + + We nemen adressen over. Verder niets. + + + Een backfill haalt een host en een poort op. Geen spelerstellingen, geen bereikbaarheidsgeschiedenis, geen beschrijvingen, geen velden, en geen aantekening van welke site een adres kwam. + + + Bewust minder dan die sites kunnen geven. + + + Verschillende bewaren jaren aan gedateerde spelerstellingen. Dat importeren zou de heatmaps vullen van de spellen die iemand anders al in de gaten hield, en de centrale claim van deze site laten steunen op de peilingen van een ander. + + + De herkomst van een spel is niet één feit. + + + Elk spel dat het vermelden waard is, staat in meerdere van deze gidsen, dus ‘geïmporteerd uit’ zou noemen welke ophaalronde toevallig het eerst liep. Dat een spel bestaat is openbare informatie; waar we het gelezen hebben voegt niets toe en is het deel van andermans werk waarop wij de minste aanspraak hebben. + + + Andermans site lezen blijft andermans site lezen. + + + We vragen om een bulkexport of een gedocumenteerd endpoint voordat we scrapen, lezen eerst robots.txt, en beperken de snelheid van scrapes streng. Een bron waarvoor toestemming van de beheerder nodig is, wordt niet opgehaald tot een mens kan verklaren dat het gevraagd is. + + + gelezen — alleen adressen + + + niet gelezen — wacht op toestemming + + + Eén pagina, één verzoek. Gepubliceerd door een crawler die met elk spel verbinding maakt en afdrukt wat hij las. + + + De MSDP-lijst van dezelfde crawler. Bijna een deelverzameling van zijn MSSP-tegenhanger, gelezen om de paar adressen die hij wel bereikt en de andere niet. + + + Publiceert zijn hele catalogus op één pagina, dus het lezen ervan kost één verzoek. Onze grootste bron van adressen, en van geen enkele meting. + + + Eén indexpagina en één pagina per wereld, dus een scrape in plaats van een export. Op 30 juli 2026 hebben we 143 van hun pagina’s opgehaald, met vijftien seconden ertussen en met inachtneming van robots.txt, maar voordat iemand hun geschreven had. Dat had niet mogen gebeuren. De poort vraagt nu om een mens die wil verklaren dat de beheerder gevraagd is. + + + Geïmplementeerd, getest, nooit uitgevoerd. De sterkste bron hier op elke as behalve toestemming, en er wordt niets opgehaald tot iemand hun geschreven heeft. + + + Licentie + + + De code is MIT. + + + De site, de crawler en de parsers zijn opensource onder de MIT-licentie. + + + De licentie voor de gegevens is een open vraag. + + + Een besluit los van dat over de code, en nog niet genomen. Lees de voorwaarden hieronder als het huidige antwoord van deze installatie, niet als het vastgelegde standpunt van het project. Dat een concurrerende gids de hele catalogus overneemt geldt hier als succes, dus wat er ook wordt vastgelegd, het zal zo’n gids niet in de weg staan. + + + code + + + gegevens, zoals deze installatie ze aanbiedt + + + vermelden als + + + Code: {licence} + + + Gegevens: {licence} + + + Vermelden als: {credit} + + + (wat deze installatie aanbiedt. Het antwoord van het project zelf ligt nog open.) + + + Een spel aanmelden + + + Vertel ons waar een spel staat. Een host en een poort zijn het hele formulier; al het andere op deze site wordt door onze eigen crawler gemeten. + + + Host + + + Poort + + + mud.example.org, of plak mud.example.org:4201 en laat de poort leeg + + + Aanmelden + + + Aanmelden vereist een database, en deze site draait op de demofixture. Er is geen crawlregister om in te schrijven, dus het formulier ontbreekt in plaats van stilletjes niets te doen. + + + Niet hier + + + Wat er met een adres gebeurt + + + We lossen het adres op voordat we aankloppen, en weigeren alles wat buiten het publieke internet uitkomt. Dat is een besluit over onze eigen socket, nooit een feit over een spel. + + + Als degene die die host draait ons gevraagd heeft er niet te crawlen, nemen we het adres niet aan, door wie het ook wordt aangemeld. Een vreemde kan je spel niet terugzetten op deze site. + + + Als het antwoordt, lezen we wat de server over zichzelf zegt en blijven we dat lezen op zijn eigen schema, voor altijd. Een adres hoeft maar één keer gegeven te worden. + + + Er verschijnt niets op de site voordat iemand bewezen heeft het te draaien. Claimen vereist een passkey en één regel die op het spel zelf gepubliceerd wordt. + + + Een adres dat we al hebben valt samen met de bestaande vermelding. Het twee keer sturen levert geen tweede vermelding op en haalt geen peiling naar voren. + + + dat adres + + + In het register. + + + Bij {address} wordt in de volgende crawlcyclus aangeklopt, daarna op zijn eigen schema, voor altijd. Het verschijnt hier zodra iemand bewijst het te draaien — kom met hetzelfde adres terug naar dit formulier en het geeft je de link. + + + We hebben het, niet geclaimd. + + + {address} is er een die we al meten. Het blijft van de site af tot iemand bewijst het te draaien. Ben jij dat, dan is dit de weg naar binnen. + + + Die hebben we al. + + + {address} is een spel dat we al meten. Er is niets aangemaakt en niets gewijzigd. + + + Dat adres hebben we al. + + + {address} is bij ons al bekend. Er is niets aangemaakt en niets gewijzigd. + + + Staat al te wachten. + + + {address} staat in het crawlregister en heeft nog niet geantwoord. Het opnieuw sturen haalt het niet naar voren: een doel houdt zijn eigen schema aan, dus niemand kan ons opjagen bij andermans server. + + + Geen adres waar we kunnen aankloppen. + + + Een host heeft een punt of een dubbele punt nodig, en een poort is een getal tussen 1 en 65535. Vul beide velden in, of plak mud.example.org:4201 in het eerste. + + + Daar kunnen we niet aankloppen. + + + Drie dingen leveren dit antwoord op voor {address}: de naam wordt misschien niet opgelost, hij komt misschien buiten het publieke internet uit, of degene die die host draait heeft ons misschien gevraagd weg te blijven. We zeggen bewust niet welke, want dat voor een vreemde beantwoorden brengt een netwerk van buitenaf in kaart. Er is niets over het adres vastgelegd; het besluit was het onze en het is als het onze vastgelegd. + + + Genoeg voor nu. + + + Dit formulier is per afzender in snelheid beperkt, en je hebt de grens bereikt. Kom over een uur terug. Er is niets verloren gegaan — alles wat we aangenomen hebben staat al in het register. + + + dit spel claimen + + + Inloggen + + + Log in met een passkey om een spel te claimen dat je zelf draait. Er is geen wachtwoord om kwijt te raken en geen om te stelen. + + + Claimen vereist een database, en deze site draait op de demofixture. Er is niets om op in te loggen. + + + Inloggen gaat met een passkey. + + + Je apparaat of wachtwoordmanager houdt de privésleutel; wij houden alleen de publieke helft. Geen wachtwoord, geen e-mail. + + + Inloggen met een passkey + + + De enige pagina hier die JavaScript nodig heeft. Passkeys werken er niet zonder. + + + Nog geen account? + + + Je hebt er alleen een nodig om een spel te claimen dat je zelf draait. Kies een naam om onder bekend te staan — een label naast je claim, geen echte naam. + + + Naam + + + bijv. corvid-admin + + + Een account aanmaken met een passkey + + + Wat we opslaan + + + De naam die je gekozen hebt. + + + De publieke sleutel van elke passkey die je registreert, en hoe je apparaat die noemde. + + + Welke spellen je geclaimd hebt, en wanneer. + + + Geen e-mailadres, geen wachtwoord, geen IP-log gekoppeld aan je account. Raak je elke passkey kwijt, dan kun je een nieuw claimtoken op je spel publiceren en opnieuw beginnen: het spel is het bewijs, niet het account. + + + {day} {month} {year} + + + {date} {time} UTC + + + nu + + + {count, plural, one {#m} other {#m}} + + + {count, plural, one {#u} other {#u}} + + + {count, plural, one {#d} other {#d}} + + + {count, plural, one {#w} other {#w}} + + + {count, plural, one {#mnd} other {#mnd}} + + + {count, plural, one {#j} other {#j}} + + + zojuist + + + {count, plural, one {#m geleden} other {#m geleden}} + + + {count, plural, one {#u geleden} other {#u geleden}} + + + {count, plural, one {#d geleden} other {#d geleden}} + + + {count, plural, one {#w geleden} other {#w geleden}} + + + {count, plural, one {#mnd geleden} other {#mnd geleden}} + + + {count, plural, one {#j geleden} other {#j geleden}} + + + zojuist + + + {count, plural, one {#m geleden} other {#m geleden}} + + + {count, plural, one {#u geleden} other {#u geleden}} + + + {count, plural, one {#d geleden} other {#d geleden}} + + + {count, plural, one {#w geleden} other {#w geleden}} + + + {count, plural, one {#mnd geleden} other {#mnd geleden}} + + + {count, plural, one {#j geleden} other {#j geleden}} + + + {age}, {stamp} + + + , {stamp} + + + {value} — {how} via {source}, laatst bevestigd {date} + + + {value} — {how} via {source}, laatst bevestigd {date} (voorbij de verwachte verversing) + + + ({how}, {age}) + + + ({how}, {age}, verouderd) + + + opgegeven door de eigenaar + + + de redactie + + + de telnet-handshake + + + de eigenaar + + + WHO + + + I3 + + + MSSP + + + INFO + + + de I3-mudlist + + + het verbindingsscherm + + + Het ecosysteem + + + Aandelen, nooit totalen. We publiceren geen cijfer voor hoeveel mensen MU* spelen: een verhouding over de spellen die we gemeten hebben overleeft de spellen die we niet kunnen bereiken, en een koppentelling niet. + + + {count, plural, one {{value} spel vermeld} other {{value} spellen vermeld}} + + + {count, plural, one {{value} spel waarvan we de handshake voltooiden} other {{value} spellen waarvan we de handshake voltooiden}} + + + {count, plural, one {{value} spel waarvan we het MSSP-rapport hebben} other {{value} spellen waarvan we het MSSP-rapport hebben}} + + + Oudste handshake hier: {age} geleden bevestigd. + + + {count, number} van {total, number} ({fraction, number, ::percent .0}) + + + {count, number} van {total, number} — nog niets gemeten + + + {fraction, number, ::percent .0} + + + Codebases + + + Van de {listed, plural, one {# vermelde spel} other {# vermelde spellen}} hebben er {identified, number} ons verteld waarop ze draaien, en elk aandeel hieronder gaat over die {identified, number}. Een codebase die we niet konden lezen valt buiten de noemer, en wordt nooit als iets anders geteld. + + + Nog geen vermeld spel heeft ons zijn codebase verteld. + + + {share} draait op een codebase die geen enkel ander vermeld spel draait — één spel per stuk, wat een naam is en geen aandeel. Ze vallen binnen de noemer hierboven en zijn uit de balken gevouwen, niet weggelaten: + + + Afstammingen + + + Dezelfde spellen, gegroepeerd naar de traditie waar hun server van afstamt — onze lezing van de codebase, niet iets wat een spel gepubliceerd heeft. Geen enkel spel meldt ‘MUSH’: MSSP kent die waarde niet, en het grootste deel van de MUSH-wereld publiceert helemaal geen MSSP, dus alleen zo kan de vraag gesteld worden. + + + Nog geen vermeld spel draait op een codebase die we in een afstamming plaatsen. + + + {count, plural, one {# van die spellen draait op een codebase} other {# van die spellen draaien op codebases}} die we in geen enkele afstamming plaatsen — verschillende zeggen dat zelf ook, door {family} te publiceren. Ze vallen binnen de noemer hierboven en in niemands aandeel. + + + Protocollen + + + Lees elk gemeten cijfer hieronder als een ondergrens. We vragen MSSP met name aan, dus stilte daar is een antwoord. Om de rest hier wordt niet gevraagd, en een server kan een protocol ondersteunen zonder het ooit aan te bieden. + + + {instrument} is de enige rij hieronder die geen ondergrens is: we vragen elke server er met name om, dus de spellen die het niet aanboden zijn gevraagd en hebben geweigerd. Het is ook de enige zonder opgegeven cijfer, want elk spel waarvan we het rapport hebben ondersteunt het bij wijze van bewijs, en een telling van de spellen die het ook vermeldden zou daartegen een gewoonte meten. + + + We hebben {reports, number} rapporten en {offered, plural, one {# spel biedt} other {# spellen bieden}} MSSP vandaag aan: de andere {gap, number} zijn gestopt met publiceren nadat we het gelezen hadden, en een rapport wordt niet weggegooid omdat het niet meer opnieuw uitgegeven wordt. + + + Protocoladoptie. Gemeten is wat een server aanbood in een voltooide handshake; opgegeven is wat zijn MSSP beweert. Twee verzamelingen spellen, dus twee noemers. + + + protocol + + + gemeten — van {basis} + + + opgegeven — van {basis} + + + niet gemeten — nooit waargenomen + + + {share} · {declined, plural, one {# spel weigerde toen we het vroegen} other {# spellen weigerden toen we het vroegen}} + + + {share} · {unobserved, plural, one {bij # spel niet aangeboden en niet gevraagd} other {bij # spellen niet aangeboden en niet gevraagd}} + + + {share} · {declined, plural, one {# spel weigerde toen we het vroegen} other {# spellen weigerden toen we het vroegen}} · {unobserved, plural, one {bij # spel niet aangeboden en niet gevraagd} other {bij # spellen niet aangeboden en niet gevraagd}} + + + niet gevraagd — elk rapport hier is het antwoord + + + Adoptie, in de loop van de tijd + + + Elk punt is een aandeel over de spellen die we die dag gemeten hadden, dus deze lijn beweegt om twee redenen: doordat een spel verandert wat het aanbiedt, en doordat de verzameling spellen die we kunnen meten eromheen verandert. Alleen het eerste is adoptie. De telling van veranderingen hieronder is het deel dat puur spellen zijn die van gedachten veranderen. + + + Gemeten aandeel van elk protocol, oudste meting eerst + + + toen + + + nu + + + niet gemeten + + + Een momentopname, geen curve + + + Een momentopname van wat we nu kunnen meten. Een adoptiecurve zet spellen uit die van gedachten veranderen, en we leggen een verandering vast wanneer die gebeurt, dus de curve wordt tekenbaar zodra er genoeg vastgelegd zijn. Uitzetten wanneer we elk spel voor het eerst bereikten zou de crawl meten, niet de hobby. + + + {count, plural, one {# wijziging in mogelijkheden} other {# wijzigingen in mogelijkheden}} tot nu toe vastgelegd — het materiaal waaruit een curve getekend wordt. + + + Er is nog geen gemeten mogelijkheid veranderd, dus er valt niets uit te zetten. + + + Gemeten is van {measured}; opgegeven is van {declared}. Twee verzamelingen spellen, dus twee noemers. + + + {listed} · {handshakes} · {mssp}. + + + De oudste handshake in dit beeld is {age} geleden voor het laatst bevestigd. + + + Dezelfde spellen, gegroepeerd naar de traditie waar hun server van afstamt. Dit is {evidence} — {meaning} — en niet iets wat een spel gepubliceerd heeft: geen enkel spel meldt ‘MUSH’, want MSSP kent die waarde niet en het grootste deel van de MUSH-wereld publiceert helemaal geen MSSP. + + + gemeten: {value} + + + opgegeven: {value} + + + Ranglijsten + + + Alleen berekend uit gemeten gegevens. Nooit stemmen, sterren of beoordelingen. Niets hier rangschikt kwaliteit. Die hebben we niet gemeten. + + + Drukst, naar gemeten gelijktijdige spelers + + + Ranglijstperiode + + + {days, plural, one {# dag} other {# dagen}} + + + Mediaan van de spelerstellingen die we over de laatste {days, plural, one {# dag} other {# dagen}} gemeten hebben. + + + Nog geen spel heeft de {samples, number} tellingen over {days, plural, one {# dag} other {# dagen}} die een mediaan nodig heeft. + + + Van de {listed, number} spellen {eligible, plural, one {heeft # spel} other {hebben # spellen}} de {samples, number} tellingen over {days, plural, one {# dag} other {# dagen}} die daarvoor nodig zijn. + + + Een gemeten nul telt mee; een onleesbare telling niet. + + + Een week zegt wie er nu druk is; een kwartaal zegt wie druk is geweest. Dat zijn verschillende vragen, en een spel kan de ene aanvoeren en de andere niet. Dagen zijn hele dagen, UTC. + + + Nog geen vermeld spel heeft genoeg tellingen om te rangschikken — een uitspraak over hoe lang we al meten, niet over hoe druk het ergens is. + + + Spellen gerangschikt naar de mediaan van de spelerstellingen die we over de laatste {days, plural, one {# dag} other {# dagen}} gemeten hebben. Spellen met dezelfde mediaan delen een plaats; niets hier breekt de gelijkstand. + + + # + + + spel + + + mediaan + + + piek + + + tellingen + + + gemeten dagen + + + Langste ononderbroken bereikbaarheid + + + Elke peiling sinds de genoemde datum trof het spel bereikbaar aan. Bereikbaar, niet ‘up’: we meten een socket vanaf één host, en een spel waarheen we geen route hebben is volkomen springlevend. Zo’n reeks kan niet langer zijn dan we al meekijken, dus de datum is het feit en de duur volgt daaruit. + + + Geen vermeld spel zit op dit moment in een ononderbroken reeks bereikbaarheid. + + + Spellen die bij elke peiling sinds de genoemde datum bereikbaar waren. Spellen die sinds dezelfde datum bereikbaar zijn delen een plaats; niets hier breekt de gelijkstand. + + + bereikbaar sinds + + + dat is + + + Gearchiveerde spellen staan buiten beide tabellen en verder niets; één geslaagde peiling zet ze terug. + + + Drukst — mediaan gemeten spelers, laatste {days, plural, one {# dag} other {# dagen}} + + + periodes: + + + deze + + + mediaan {median, number} · piek {peak, number} · {samples, plural, one {# telling} other {# tellingen}} over {days, number} van {window, number} dagen + + + bereikbaar bij elke peiling sinds {date} · {duration} + + + Het archief + + + Spellen die gestopt zijn met antwoorden. Er is niets verwijderd. Ze worden nog wekelijks gepeild, en één geslaagde peiling zet een spel diezelfde dag terug in de lijst. + + + het archief doorzoeken + + + Gearchiveerde spellen zoeken + + + naam, codebase of beschrijving + + + tonen + + + {count, plural, =0 {Geen gearchiveerde spellen} one {# gearchiveerd spel} other {# gearchiveerde spellen}} + + + gearchiveerd + + + laatst bereikbaar + + + bekend live + + + ({age} geleden) + + + Geen treffers. + + + nooit, in niets wat we gemeten hebben + + + geen bereikbare tijd gemeten + + + onbekend + + + {years, number, ::.#} jaar + + + {days, plural, one {# dag} other {# dagen}} + + + {from} – {to} · {span} + + + {count, plural, one {# maand} other {# maanden}} + + + {count, plural, one {# jaar} other {# jaar}} + + + {count, plural, one {# spel dat overeenkomt met ‘{query}’} other {# spellen die overeenkomen met ‘{query}’}} + + + {count, plural, one {# spel} other {# spellen}} + + + Laatst bereikbaar: + + + Bekend live: + + + {value} van de gemeten bereikbare tijd + + + Looptijd: + + + Codebase: + + + willekeurig spel + + + {d, date, medium} — gemiddeld {typical}, {low}–{high} over {probes, plural, one {# peiling} other {# peilingen}} + + + {d, date, medium} — {count, plural, =0 {0 spelers} one {# speler} other {# spelers}}, bij elk van {probes, plural, one {# peiling} other {# peilingen}} + + + {d, date, medium} — gepeild, geen telling kon worden gelezen + + + {d, date, medium} — geen meting + + + Typisch {typical} verbonden, met een piek van {peak}, over {counted} van {days, plural, one {# dag} other {# dagen}}. + + + Gelijkmatig over het hele bereik. + + + Ongeveer {change, number, percent} omhoog van het begin van het bereik tot het eind. + + + Ongeveer {change, number, percent} omlaag van het begin van het bereik tot het eind. + + + In dit bereik gepeild, en uit niets ervan kon een spelerstelling worden gelezen. + + + Geen meting in dit bereik. + + + Niets geteld in dit bereik. + + + {from, date, d MMM}–{to, date, d MMM} + + + {d, date, d MMM} + + + {span}: typisch {typical}, piek {peak}, {days, plural, one {# dag} other {# dagen}} geteld + + + {span}: gepeild, geen telling kon worden gelezen + + + {span}: niet gemeten + + + {count, plural, one {# dag gepeild zonder telling} other {# dagen gepeild zonder telling}} + + + {count, plural, one {# dag niet gemeten} other {# dagen niet gemeten}} + + + {line}, {clause} + + + dagen in UTC · {from, date, medium} – {to, date, medium} + + + {value} bovenaan + + + {counted} van {days, plural, one {# dag} other {# dagen}} geteld + + + {d, date, MMM} + + + {d, date, MMM yyyy} + + + gemiddelde van de tellingen die we die dag lazen + + + tot aan de drukste telling van die dag + + + laagste tot hoogste telling van die dag + + + gepeild, geen telling kon worden gelezen + + + niet gemeten — helemaal geen balk + + + niet gemeten — een onderbreking in de lijn + + + Trendbereik + + + Trendvorm + + + {days, plural, one {# dag} other {# dagen}} + + + ← eerder + + + later → + + + lijn + + + staven + + + Hoeveel, in de loop van de tijd + + + {from, date, medium} – {to, date, medium}, UTC + + + eerder + + + een week wordt samengevat over de dagen erin die we geteld hebben; een week zonder zegt dat + + + bereikbaar · laatste {days, plural, one {# dag} other {# dagen}} + + + bereikbaar + + + langste onderbreking + + + laatste oorzaak + + + geen in de periode + + + niets vastgelegd + + + {days, plural, one {# dag geleden} other {# dagen geleden}} + + + vandaag + + + bereikbaar + + + haperend — antwoordde, kon niet afronden + + + onbereikbaar + + + niet gemeten + + + bereikbaar + + + haperend + + + onbereikbaar + + + niet gemeten + + + {d, date, d MMM} — de hele dag bereikbaar + + + {d, date, d MMM} — haperend ({cause}): antwoordde, kon niet afronden + + + {d, date, d MMM} — onbereikbaar ({cause}) + + + {d, date, d MMM} — niet gemeten; we volgden dit spel toen nog niet + + + {days, plural, one {Nog niet gemeten over de laatste # dag.} other {Nog niet gemeten over de laatste # dagen.}} + + + Bereikbaar {percent} van de laatste {days, plural, one {# dag} other {# dagen}}. + + + Bereikbaar {percent} van de {days, plural, one {# dag} other {# dagen}} die we gemeten hebben. + + + {days, plural, one {De bereikbaarheid over de laatste # dag is nog niet gemeten.} other {De bereikbaarheid over de laatste # dagen is nog niet gemeten.}} + + + Geen enkele dag in de periode was onbereikbaar. + + + Geen enkele dag die we gemeten hebben was onbereikbaar. + + + {count, plural, one {# dag onbereikbaar.} other {# dagen onbereikbaar.}} + + + {count, plural, one {# dag haperend — we kwamen binnen en konden niet afronden.} other {# dagen haperend — we kwamen binnen en konden niet afronden.}} + + + Langste onderbreking {duration}. + + + Langste onderbreking {duration} ({cause}). + + + {count, plural, one {# dag ligt vóór alles wat we gemeten hebben.} other {# dagen liggen vóór alles wat we gemeten hebben.}} + + + {from, date, d MMM} – {to, date, d MMM} + + + {d, date, d MMM} + + + {range}: {count, plural, one {# dag} other {# dagen}} {word} + + + {range}: {count, plural, one {# dag} other {# dagen}} {word} ({cause}) + + + Bereikbaar + + + {days, plural, one {laatste # dag} other {laatste # dagen}} + + + Bereikbaar: {percent} van de {days, plural, one {laatste # dag} other {laatste # dagen}} + + + Langste onderbreking: {duration} + + + dns loste niet op + + + verbinding geweigerd + + + tls mislukt + + + time-out + + + handshake liep vast + + + geen oorzaak vastgelegd + + + De eigenaar heeft ons gevraagd het verbindingsscherm van dit spel niet opnieuw te publiceren. + + + Van dit spel is geen verbindingsscherm vastgelegd. + + + {count, plural, one {Er kwam maar # rij terug — te weinig om te tonen.} other {Er kwamen maar # rijen terug — te weinig om te tonen.}} + + + zoals verzonden door de server + + + {columns}×{rows} + + + {columns}×{rows}, dubbele breedte + + + 16-kleuren SGR + + + geen kleur + + + gelezen als {charset} + + + vastgelegd + + + bevroren — het laatste scherm dat we zagen + + + ASCII-kunst: het verbindingsscherm van {game}. De tekst ervan staat onder ‘als tekst lezen’, hieronder. + + + ASCII-kunst: het verbindingsscherm van dit spel. De tekst ervan staat onder ‘als tekst lezen’, hieronder. + + + {count, plural, one {verbindingsscherm: # regel, alleen tekst} other {verbindingsscherm: # regels, alleen tekst}} + + + {count, plural, one {verbindingsscherm: # regel, alleen tekst, gelezen als {charset}} other {verbindingsscherm: # regels, alleen tekst, gelezen als {charset}}} + + + het spel geeft het op, en de server heeft het nooit aangeboden in een handshake. + + + de server biedt het aan, en het spel zegt in zijn eigen opgave van niet. + + + Meestal een verouderd, met de hand ingetypt veld, geen leugen. Getoond omdat een client niet moet vertrouwen op datgene waarover de twee van elkaar afwijken. + + + Niet gevonden + + + Geen spel op dit adres. Controleer de spelling. + + + laatst geantwoord + + + {count, plural, one {# is voorbij zijn verversingstermijn. Oud, niet fout.} other {# zijn voorbij hun verversingstermijn. Oud, niet fout.}} + + + De eigen verwijzingslijst van dit spel noemt: + + + Genoemd door de verwijzingslijst van: + + + sinds {date} + + + niet meer vermeld, laatst gezien + + + Laatst geantwoord op {date}, {ago} geleden. + + + Wordt nog wekelijks gepeild; deze pagina wordt bijgewerkt op de dag dat het antwoordt. + + + {span} bekend live + + + Niet in de lijst, de ranglijsten of het dagcijfer: we denken niet dat dit adres een spel is dat iemand kan spelen. Alles hieronder is wat het ons vertelde, ongewijzigd. + + + Onze reden: {why} + + + Niet in de lijst, de ranglijsten of het dagcijfer, op verzoek van de mensen die het draaien. Alles hieronder is bewaard zoals het was, deze pagina en elk adres dat het ooit gehad heeft blijven antwoorden, en we kloppen er niet aan. + + + Spelers nu: {count} + + + Mogelijkheden ({disagreeing} van {total} wijken af) + + + Opgegeven door het spel + + + Verbindingsscherm + + + Wat er veranderd is + + + gemeten + + + opgegeven + + + ** wijkt af + + + verbonden + + + bereikt + + + zoeken + + + gearchiveerd + + + voor volwassenen + + + opgenomen + + + geen telling + + + geen actuele telling, en niets hier zegt waarom + + + Spelers nu: geen telling (niets hier zegt waarom) + + + crawler actief · laatste peiling {age} + + + crawler wacht · laatste peiling {age} + + + hier is nog geen peiling afgerond + + + niets aan de beurt deze cyclus + + + {considered, plural, one {# aan de beurt} other {# aan de beurt}} · {answered, plural, one {# beantwoord} other {# beantwoord}} · {failed, plural, one {# mislukt} other {# mislukt}} + + + {targets, plural, one {# adres in het register} other {# adressen in het register}}, {due, plural, one {# nu aan de beurt} other {# nu aan de beurt}} + + + {page} — {site} + + + Een gids voor de MU*-hobby — MUSHes, MUDs, MUCKs, MOOs — waar elk feit vermeldt hoe het verkregen is en hoe oud het is. + + + Demogegevens — niets hiervan is gemeten. {description} + + + {site} — gemeten, niet beweerd + + + {title} op {site} + + + Spellen + + + Het archief + + + Ranglijsten + + + Het ecosysteem + + + Naslag + + + Over + + + Niet gevonden + + + Willekeurig spel + + + Je spellen + + + {game} claimen + + + Elke MU* die we bereikt hebben, gefacetteerd op onze metingen: codebase, protocollen uit de handshake, TLS, codering, taal, laatst gezien. + + + Stilgevallen spellen, bewaard. Elk houdt zijn pagina, geschiedenis en URL, wordt wekelijks gepeild en keert terug in de lijst bij één geslaagde verbinding. + + + Drukst, best bereikbaar, langst draaiend — alleen berekend uit metingen. Nergens op deze site stemmen, sterren of beoordelingen. + + + Aandeel per codebase en protocoladoptie over de spellen die we meten, met wat servers aanbieden naast wat ze opgeven. Aandelen, nooit totalen. + + + Met de hand geschreven pagina’s over de codebases, clients en protocollen van de MU*-hobby, met verwijzingen naar tellingen uit de crawl. + + + Hoe deze catalogus gebouwd wordt: wat de crawler doet, wat hij weigert te doen, en hoe hij te stoppen is. + + + Geen spel op dit adres. Hier wordt nooit iets verwijderd, dus een spel dat ooit op deze URL stond, staat er nog — controleer de spelling. + + + Eén spel uit de catalogus, willekeurig gekozen en nooit twee keer hetzelfde. + + + De vermeldingen die je geclaimd hebt, en wat je met een claim kunt wijzigen. + + + Bewijs dat je dit spel draait door een token te publiceren waar alleen wie het draait het kan neerzetten. + + + Gearchiveerd — laatst bereikbaar {age}, en wordt nog gepeild + + + Gearchiveerd, en wordt nog gepeild + + + Spelerstelling onbekend — het spel antwoordt en publiceert geen getal dat wij kunnen lezen + + + {count, plural, one {# speler} other {# spelers}}, {how} {age} + + + Nieuw ontdekt + + + Stilgevallen + + + Weer terug + + + {count, plural, one {# bekend spel} other {# bekende spellen}} + + + {count, plural, one {# nu verbonden (gemeten)} other {# nu verbonden (gemeten)}} + + + {count, plural, one {# antwoordt, ongeteld} other {# antwoorden, ongeteld}} + + + {count, plural, one {# gearchiveerd, wordt nog gepeild} other {# gearchiveerd, worden nog gepeild}} + + + Naslag + + + Wat de codebases zijn, wat de clients doen, en wat de protocollen betekenen. Met de hand geschreven en in de repository naast de crawler bewaard — geen wiki, en er valt op deze pagina niets te bewerken. Elk {number} hier is iets anders: het komt uit de catalogus en wordt opnieuw berekend telkens als de pagina geladen wordt. + + + getal + + + Met de hand geschreven, door één auteur, en van versies voorzien in git. De tekst hier is van ons; elk getal ernaast is door de crawler gemeten en wordt bij elk verzoek opnieuw berekend. Dit is geen wiki, en vanaf deze pagina is er niets te bewerken. + + + Begin hier + + + Codebases + + + Clients + + + Protocollen + + + oriëntatie + + + codebase + + + client + + + protocol + + + Niet gevonden + + + Hier staat geen naslagpagina. Dit onderdeel is met de hand geschreven, dus een gat is werk dat niemand gedaan heeft en niet iets wat verwijderd is — {index}. + + + kijk wat er wel is + + + Spellen die erop draaien + + + We hebben er nog geen vastgesteld. Dat is een feit over wat deze crawler gemeten heeft, niet over wat er bestaat — een spel dat we niet bereikt hebben, of waarvan we de codebase niet konden lezen, telt hier niet mee. + + + {count, plural, one {# vermeld} other {# vermeld}} + + + {count, plural, one {# gearchiveerd} other {# gearchiveerd}} + + + gemeten, nooit beweerd + + + Geteld uit de catalogus bij dit verzoek, over hetzelfde filter als de link hierboven — dus dit getal en die lijst zijn één zoekopdracht en kunnen niet uit elkaar lopen. + + + aangeboden in hun handshakes: {protocols} + + + Gemeten adoptie + + + Nog niets gemeten. + + + van {listed, plural, one {# vermeld spel} other {# vermelde spellen}} — {percent} + + + De spellen die hier niet meetellen zijn geen spellen zonder het protocol. Een spel telt mee wanneer we zijn server de optie in een handshake hebben zien aanbieden; de rest zijn servers die het ons niet aangeboden hebben en servers waarvan we de handshake niet gelezen hebben, en welke van beide het is kunnen we niet zeggen. + + + Spellen die we {protocol} in een handshake hebben zien aanbieden, naar de codebase waarvan we vaststelden dat ze erop draaien. + + + codebase + + + bood het aan + + + vastgesteld + + + Zie ook + + + Mogelijkheden + + + {count, plural, one {# van de {total} vastgesteld uit de eigen documentatie van het project} other {# van de {total} vastgesteld uit de eigen documentatie van het project}} + + + Afgelezen uit de eigen documentatie van elk project, niet door ons gemeten — een client heeft geen handshake die wij kunnen waarnemen. ‘{unknown}’ betekent dat we gekeken hebben en het niet vastgesteld hebben. Het betekent nooit nee. + + + Mogelijkheden van clients, elk afgelezen uit de eigen documentatie van het project. Onbekend betekent dat we het niet vastgesteld hebben, en nooit dat de client het mist. + + + gedocumenteerd + + + bron + + + we hebben er geen gevonden + + + ja + + + nee + + + onbekend + + + Draait op: {platforms} + + + Spellen waarvan we vastgesteld hebben dat ze op deze codebase draaien + + + Nog geen. Dat is een uitspraak over wat we gemeten hebben, niet over wat er bestaat — een spel dat we niet bereikt hebben, of waarvan we de codebase niet konden lezen, telt hier niet mee. + + + {listed, plural, one {# vermeld} other {# vermeld}}, {archived, plural, one {# gearchiveerd} other {# gearchiveerd}} + + + Gemeten in hun handshakes: {protocols} + + + In geen enkele handshake die we van hen gelezen hebben, is er iets aangeboden. + + + {offering} van {listed, plural, one {# vermeld spel} other {# vermelde spellen}} hebben we het zien aanbieden ({percent}) + + + Naar codebase, van de spellen die we hebben vastgesteld + + + {offering} van {identified} boden het aan + + + {count, plural, one {# van de {total} rijen is onbekend} other {# van de {total} rijen zijn onbekend}}: we hebben in de eigen documentatie van het project niets gevonden dat het een of het ander zegt. Een korte eerlijke tabel is beter dan een lange geraden. + + + Je spellen + + + Accounts vereisen een database, en deze site draait op de demofixture. + + + Inloggen + + + Ingelogd als {name}. + + + Uitloggen + + + Je hebt nog niets geclaimd. Zoek je spel op in {listing} en druk op zijn pagina op {claimControl}. + + + de lijst + + + dit spel claimen + + + Afstand gedaan. + + + Het blijft vastgelegd, en je kunt altijd opnieuw aantonen dat je de controle hebt door een nieuw token te publiceren. + + + Opgeslagen. + + + Dat spel + + + De pagina van {game} toont het nu als opgegeven door de eigenaar. + + + We publiceren het verbindingsscherm van {game} niet meer opnieuw. De pagina zegt dat met zoveel woorden in plaats van een gat te laten. + + + Het verbindingsscherm van {game} staat weer op zijn pagina. + + + We kloppen niet meer aan bij {game}, op geen enkel adres dat we ervan hebben. Zijn pagina houdt alles wat vóór je verzoek gemeten is. + + + We kloppen weer aan bij {game}, vanaf zijn volgende beurt in het schema. + + + {game} staat niet meer in de lijst, de ranglijsten en het dagcijfer. Zijn pagina en elk adres dat het ooit gehad heeft blijven antwoorden. + + + {game} staat weer in de lijst. Eén peiling die antwoord krijgt is genoeg om het weer te meten. + + + {field} is niet gewijzigd. + + + Dit zijn antwoorden van één regel; {max} tekens is het meeste dat we opslaan. + + + Dat veld is gemeten. Met een claim kun je toevoegen waar MSSP geen ruimte voor heeft; hij laat niemand wijzigen wat wij waargenomen hebben, ons ook niet. + + + Geclaimd + + + geverifieerd {date} + + + geverifieerd {date}, token laatst gezien {seen} + + + je MSSP controleren + + + {count, plural, one {Ook eigendom van {names} — die een eigen token geverifieerd heeft.} other {Ook eigendom van {names} — die elk een eigen token geverifieerd hebben.}} + + + een ander account + + + je spelerstelling op je eigen site zetten + + + De badge draagt de telling én wanneer we die gemeten hebben, want een getal zonder ouderdom is precies wat deze site wil vervangen. + + + Hij zegt {unknown} in plaats van nul wanneer we niet konden tellen, en {archived} als het spel stopt met antwoorden. + + + Er is ook {json}, als je hem liever zelf tekent. + + + geschiedenis + + + token uitgegeven + + + token opnieuw uitgegeven + + + geverifieerd — we hebben je token gelezen + + + token nog steeds gepubliceerd + + + token deze keer niet gelezen + + + afstand gedaan van de claim + + + token ongebruikt verlopen + + + een ander account heeft de controle aangetoond en het spel overgenomen + + + controle aangevraagd + + + afstand doen van deze claim + + + Typ {word} om te bevestigen. Er wordt niets verwijderd en je kunt opnieuw aantonen dat je de controle hebt door een nieuw token te publiceren; het spel blijft geclaimd als iemand anders het ook in eigendom heeft. + + + Afstand doen van {game} + + + Wachten op een token + + + token uitgegeven {issued}, geldig tot {expires} + + + Passkeys + + + naamloos + + + toegevoegd {date} + + + toegevoegd {date} · slechts op één apparaat + + + Deze passkey staat op één apparaat. Raak je hem kwijt, dan kom je er nog steeds in door een nieuw token op je spel te publiceren, maar met een tweede passkey gaat het sneller. + + + Nog een passkey toevoegen + + + Zo’n spel is er niet + + + {game} claimen + + + Claimen vereist een database, en deze site draait op de demofixture. + + + Je hebt eerst een account nodig. Daar zijn een passkey en een naam voor nodig. + + + Inloggen of een account aanmaken + + + Je spellen + + + {count, plural, one {Dit spel heeft al een eigenaar die de controle over de server heeft aangetoond.} other {Dit spel heeft al # eigenaren die de controle over de server hebben aangetoond.}} Jij kunt dat ook aantonen — de toets is in beide gevallen dezelfde — maar we moeten weten wat je ermee bedoelt, want aan het token kunnen we dat niet zien. + + + Ik draai het ook — voeg me toe als eigenaar + + + Iedereen houdt zijn claim. Dit zijn twee mensen die één spel draaien. + + + Ik heb het overgenomen — draag het aan mij over + + + {count, plural, one {Zodra je token geverifieerd is, wordt de bestaande claim ingetrokken en is het spel van jou.} other {Zodra je token geverifieerd is, worden de bestaande claims ingetrokken en is het spel van jou.}} Zij zien in hun eigen geschiedenis waarom. Er wordt niets verwijderd, en zij kunnen op dezelfde manier als jij nu opnieuw de controle aantonen. + + + Geverifieerd. + + + We hebben je token op {date} uit het MSSP-rapport van het spel gelezen. + + + We hebben je token op {date} van het verbindingsscherm gelezen. + + + Laat het token staan waar het staat. Het dient ook als identiteitssignaal, zodat dit spel herkenbaar blijft als het van host of van naam verandert. Het weghalen maakt je claim niet ongedaan. + + + Publiceer dit token overal waar het spel het aan een anonieme verbinding laat zien. De volgende peiling pikt het op, en daarmee is aangetoond dat je naar die server kunt schrijven. + + + Dit is een overdracht. + + + {count, plural, one {Zodra we dit token lezen, wordt de claim van de huidige eigenaar op dit spel ingetrokken en wordt het van jou.} other {Zodra we dit token lezen, worden de claims van de huidige eigenaren op dit spel ingetrokken en wordt het van jou.}} + + + Een van deze twee volstaat + + + Een MSSP-variabele + + + In {codebase} is dat een regel in {file}; elke codebase met MSSP heeft een equivalent. + + + {aliases} worden ook geaccepteerd. + + + Een regel op het verbindingsscherm + + + Overal op het scherm, en kleurcodes eromheen zijn geen probleem. + + + Daarna + + + We kijken op het gewone crawlschema. Dit token is geldig tot {date}. Kom gerust later terug; er hoeft niets opgeschreven te worden. + + + Eerder kijken + + + Zet je spel vooraan in de rij. We kloppen aan op ons eigen schema, dus dit is eerder en niet nu. + + + Net gevraagd. Probeer het over een paar minuten opnieuw — het is gerantsoeneerd omdat er eerder bij een echte server wordt aangeklopt dan wij anders gedaan hadden. + + + Wat alleen jij ons over {game} kunt vertellen + + + Dit zijn de dingen waar MSSP geen veld voor heeft. Ze verschijnen op de pagina van je spel als {declared}, met de datum waarop je ze voor het laatst bevestigd hebt, naast wat wij gemeten hebben — nooit in plaats daarvan. Niets wat gemeten is kan hiervandaan gewijzigd worden, niet door jou en niet door ons. + + + opgegeven {age}. Maak dit vak leeg om het in te trekken — wat erin stond blijft hoe dan ook vastgelegd. + + + Opslaan wat je opgegeven hebt + + + Wat {game} meldt, en wat jij liever getoond ziet + + + Je MSSP is wat elke crawler leest, en we blijven het tonen naast alles wat je hier neerzet — een antwoord van jou verbergt er geen van je spel. + + + Niets wat gemeten is kan hiervandaan gewijzigd worden: geen spelerstelling, geen mogelijkheid, geen uur bereikbaarheid. + + + Klopt een regel hieronder niet in je {file}, dan lost het daar oplossen het overal op. + + + je spel meldt {value}, bevestigd {age} + + + je spel meldt hier niets + + + De naam wijzigen verandert waaronder {game} vermeld staat en het adres van zijn pagina. Het oude adres blijft voor altijd werken — elk adres dat dit spel ooit gehad heeft verwijst door naar het huidige — en het vak leegmaken geeft de naam terug aan wat je MSSP zegt. + + + Je verbindingsscherm + + + We publiceren het niet opnieuw. De pagina zegt dat met zoveel woorden in plaats van een gat te laten, en de crawler blijft het lezen — zo herkennen we je spel als het verhuist. + + + Weer tonen + + + We tonen het omdat je server het naar elke anonieme verbinding stuurt. Wil je dat liever niet, zeg het dan en we stoppen ermee. We vragen niet waarom. + + + Toon ons verbindingsscherm niet meer + + + Gecrawld worden + + + We zijn gestopt. Er wordt bij {game} nergens meer aangeklopt, en de pagina houdt alles wat vóór je verzoek gemeten is — de lege uren noemen geen oorzaak, want {ourFact} is een feit over ons en geen meting van je spel. + + + dat je ons gevraagd hebt te stoppen + + + Crawl ons weer + + + Deze kwam van je eigen server en niet van hier — {routes}. Wil je weer gecrawld worden, stop dan met het publiceren ervan; dat horen we in de volgende cyclus. + + + je MSSP-rapport publiceert {variable} + + + een TXT-record op {label} vraagt ons te stoppen + + + een verzoek dat we vastgelegd hebben + + + We zijn gestopt op {stopped} en kloppen nog aan bij {dialling}. Dat is meestal een poort die na de afmelding is toegevoegd. + + + Stop ook op elk adres + + + We kloppen volgens een schema aan bij {game} en lezen wat elke anonieme verbinding te zien krijgt. Wil je dat liever niet, zeg het dan en we stoppen — binnen één cyclus, op elk adres dat we van je hebben, en we vragen niet waarom. + + + Wat al gemeten is wordt niet verwijderd: je pagina houdt zijn geschiedenis en zijn adres, en één peiling nadat je dit intrekt begint het weer. Je kunt het ook zonder ons zeggen, in je eigen configuratie — {mssp} in MSSP, of een TXT-record op {dns} — en die honoreren we, of iemand het spel hier nu ooit geclaimd heeft of niet. + + + Crawl ons niet meer + + + In de lijst staan + + + {game} staat niet meer in de lijst, niet in de ranglijsten en niet in het dagcijfer. Zijn pagina en elk adres dat het ooit gehad heeft blijven antwoorden, en alles wat vóór je verzoek gemeten is staat er nog op. Er is niets verwijderd; het is alleen geen plek meer waar een lezer al bladerend aankomt. + + + Zet ons terug in de lijst + + + Eén peiling die antwoord krijgt doet dit ook. Zolang je afmelding staat kloppen we niet aan, dus gebeurt dat niet — maar de dag dat je haar intrekt, komt het adres binnen een week aan de beurt en zet de peiling die antwoord krijgt je terug. Je hoeft het ons geen tweede keer te vragen. + + + We kloppen niet meer bij je aan, en je pagina staat nog in de lijst met wat we daarvóór gemeten hebben. Wil je dat liever niet, zeg het dan en hij gaat eruit — uit de lijst, de ranglijsten en het dagcijfer. + + + Er wordt niets verwijderd en er gaat niets stuk: de pagina antwoordt, elk adres dat hij ooit gehad heeft verwijst er nog naar door, en iedereen die je erheen stuurt ziet hem. Hij houdt op een plek te zijn waar een lezer al bladerend aankomt. Vanaf hier terug te draaien, en door elke peiling die antwoord krijgt nadat je je afmelding intrekt. + + + Haal ons ook uit de lijst + + + wat we konden meten + + + Deze verbergen haalt ze uit deze lijst; het betekent niet dat het spel leeg is. + + + konden niet tellen + + + konden niet bereiken + + + niet getoond in deze lijst + + + niet getoond in deze lijst + + + In de linkerkolom is * een waarde waarop deze lijst gefilterd is en - een waarde die is uitgesloten. Beide zijn keuzes in de zoekopdracht, geen feiten over een spel. + + diff --git a/src/MUI.Web/Resources/Messages.resx b/src/MUI.Web/Resources/Messages.resx new file mode 100644 index 0000000..0e39845 --- /dev/null +++ b/src/MUI.Web/Resources/Messages.resx @@ -0,0 +1,3823 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + {count, plural, one {# game} other {# games}} + counts, which is where the concatenations were + + + {value}, {count, plural, one {# game} other {# games}}, only + counts, which is where the concatenations were + + + {value}, {count, plural, one {# game} other {# games}}, excluded + counts, which is where the concatenations were + + + {value}, {count, plural, one {# game} other {# games}} + counts, which is where the concatenations were + + + any {facet}, {count, plural, one {# game} other {# games}} + counts, which is where the concatenations were + + + {count, plural, =0 {No games listed here.} one {# game, each fact carrying how it was obtained.} other {# games, each fact carrying how it was obtained.}} + counts, which is where the concatenations were + + + {days, plural, one {# day} other {# days}} measured · {probes, plural, one {# probe} other {# probes}} + counts, which is where the concatenations were + + + {days, plural, one {#d} other {#d}} · {count, plural, one {# count} other {# counts}} + counts, which is where the concatenations were + + + {disagreeing, plural, =0 {None of the {total} disagree.} one {# of {total} disagrees with what the game declares.} other {# of {total} disagree with what the game declares.}} + counts, which is where the concatenations were + + + {count, plural, =0 {No games match every answer.} one {# game matches every answer.} other {# games match every answer.}} + find a game, where the count is the whole point of the page + + + of {listed, plural, one {# listed game} other {# listed games}} · {answers, plural, =0 {no answers given} one {# answer given} other {# answers given}} + find a game, where the count is the whole point of the page + + + {count, plural, one {Show the one game} other {Show these # games}} + find a game, where the count is the whole point of the page + + + drop "{answer}" — {count, plural, one {# game} other {# games}} + find a game, where the count is the whole point of the page + + + clear answer to: {question} + find a game, where the count is the whole point of the page + + + Find a game + find a game, where the count is the whole point of the page + + + matching all answers + find a game, where the count is the whole point of the page + + + {count, plural, one {game} other {games}} + find a game, where the count is the whole point of the page + + + clear all answers + find a game, where the count is the whole point of the page + + + start again + find a game, where the count is the whole point of the page + + + {count, plural, one {# more} other {# more}} + find a game, where the count is the whole point of the page + + + answers given + find a game, where the count is the whole point of the page + + + the whole listing + find a game, where the count is the whole point of the page + + + that query was refused + find a game, where the count is the whole point of the page + + + a name, if you have one + find a game, where the count is the whole point of the page + + + name, or part of one + find a game, where the count is the whole point of the page + + + Search by name + find a game, where the count is the whole point of the page + + + Is anyone playing right now? + find a game, where the count is the whole point of the page + + + What do you want to play? + find a game, where the count is the whole point of the page + + + What kind of game? + find a game, where the count is the whole point of the page + + + In which language? + find a game, where the count is the whole point of the page + + + Anything your client needs? + find a game, where the count is the whole point of the page + + + Include games that have gone dark? + find a game, where the count is the whole point of the page + + + doesn't matter + find a game, where the count is the whole point of the page + + + any genre + find a game, where the count is the whole point of the page + + + any kind + find a game, where the count is the whole point of the page + + + any language + find a game, where the count is the whole point of the page + + + doesn't matter + find a game, where the count is the whole point of the page + + + no, only live games + find a game, where the count is the whole point of the page + + + yes, show me those too + find a game, where the count is the whole point of the page + + + games that have gone dark + find a game, where the count is the whole point of the page + + + TLS — encrypted, handshake completed by us + the client question's options + + + MSSP — server self-description + the client question's options + + + MCCP — compressed output + the client question's options + + + MXP — clickable links + the client question's options + + + GMCP — structured client data + the client question's options + + + MSDP — structured client data + the client question's options + + + CHARSET — encoding negotiation + the client question's options + + + UTF-8 — non-Latin text renders + the client question's options + + + TTYPE — client tells its type + the client question's options + + + ATCP — structured client data + the client question's options + + + MSP — sound triggers + the client question's options + + + EOR — prompt marking + the client question's options + + + {token} — measured in the handshake + the client question's options + + + measured + the locked provenance words, one id per context + + + measured + the locked provenance words, one id per context + + + measured + the locked provenance words, one id per context + + + measured + the locked provenance words, one id per context + + + measured + the locked provenance words, one id per context + + + declared + the locked provenance words, one id per context + + + declared + the locked provenance words, one id per context + + + declared + the locked provenance words, one id per context + + + declared + the locked provenance words, one id per context + + + derived + the locked provenance words, one id per context + + + derived + the locked provenance words, one id per context + + + not measured + the four kinds of absence + + + uncounted + the four kinds of absence + + + unreachable + the four kinds of absence + + + not counted + the four kinds of absence + + + no count + the listing's own absences, which are not the game page's + + + from here + the listing's own absences, which are not the game page's + + + archived + the listing's own absences, which are not the game page's + + + claimed + the listing's own absences, which are not the game page's + + + Nothing to pick from + the listing's own absences, which are not the game page's + + + No game matches that filter. Try {listing}, or {archive}. + the listing's own absences, which are not the game page's + + + the whole listing + the listing's own absences, which are not the game page's + + + include the archive + the listing's own absences, which are not the game page's + + + connected + the words the product rests on + + + unclaimed + the words the product rests on + + + claimed by its owner + the words the product rests on + + + still probed + the words the product rests on + + + typical + the words the product rests on + + + peak + the words the product rests on + + + read as text + the accessibility promises + + + plain text + the accessibility promises + + + skip to content + the accessibility promises + + + ASCII banner: the connect screen of {game}. + the accessibility promises + + + Catalogues + site chrome + + + This site and your account + site chrome + + + browse + site chrome + + + learn + site chrome + + + this site + site chrome + + + menu + site chrome + + + games + site chrome + + + find + site chrome + + + random + site chrome + + + archive + site chrome + + + reference + site chrome + + + ecosystem + site chrome + + + rankings + site chrome + + + about + site chrome + + + submit + site chrome + + + submit a game + site chrome + + + sign in + site chrome + + + your games + site chrome + + + theme + site chrome + + + auto + site chrome + + + light + site chrome + + + dark + site chrome + + + Demo data. + site chrome + + + No database is configured, so this is a fixture. Nothing here was measured. + site chrome + + + all games + site chrome + + + archive + site chrome + + + declared by the game + site chrome + + + what changed + site chrome + + + A directory of the MU* hobby + home + + + Every fact carries how it was obtained and how old it is: measured by our crawler, or declared by the game and marked as such. + home + + + Search games by name, theme, codebase or host + home + + + search by name, theme, codebase or host + home + + + search + home + + + games known + home + + + connected now + home + + + answering, uncounted + home + + + archived + home + + + newly discovered + home + + + went dark — still probed + home + + + came back + home + + + Nothing new. + home + + + Nothing went dark. + home + + + Nothing came back. We keep knocking. + home + + + live + home + + + Games + the listing + + + sorted by {order} + the listing + + + random + the listing + + + connected · reached + the listing + + + from here + the listing + + + Nothing matched. + the listing + + + Try fewer words, or drop a filter. + the listing + + + clear filters + the listing + + + about {codebase} + the listing + + + never + the listing + + + claimed by its owner + the listing + + + Unknown Codebase + the listing + + + we could not identify the codebase this game runs + the listing + + + and {count, plural, one {# more} other {# more}}: {names} + the listing + + + Order + the order switch + + + Window + the order switch + + + now + the order switch + + + typical + the order switch + + + peak + the order switch + + + name + the order switch + + + reached + the order switch + + + 7 days + the order switch + + + 30 days + the order switch + + + 90 days + the order switch + + + Search games + the filter panel + + + search games + the filter panel + + + filters + the filter panel + + + showing + the filter panel + + + clear all + the filter panel + + + — stop filtering by this + the filter panel + + + any + the filter panel + + + more filters ({count}) + the filter panel + + + {count, plural, one {# more} other {# more}} + the filter panel + + + also show + the filter panel + + + Off by default. Neither is a judgement about the game. + the filter panel + + + archived + the filter panel + + + adult + the filter panel + + + archived games, {shown, select, true {shown} other {hidden}} + the filter panel + + + games declaring adult content, {shown, select, true {shown} other {hidden}} + the filter panel + + + Counts are games we measured, never estimates. + the filter panel + + + what the badges and the blanks mean + the filter panel + + + A blank is a gap in our measurement, not a no. Each facet spells its own: not identified, not declared, nothing negotiated. + the filter panel + + + A measured zero is a count. An unknown count is not a zero and never sorts as one. + the filter panel + + + Open-ended facets list their {count} commonest values. The rest are reachable by search and by URL. + the filter panel + + + Unticked means not measured — not that the game lacks it. + the filter panel + + + activity + facet groups + + + last seen + facet groups + + + protocols offered + facet groups + + + encrypted + facet groups + + + encoding + facet groups + + + codebase + facet groups + + + version + facet groups + + + lineage + facet groups + + + family + facet groups + + + genre + facet groups + + + language + facet groups + + + connected now + facet values + + + active this week + facet values + + + quiet — no count above 0 + facet values + + + dark — not reached in a month + facet values + + + archived + facet values + + + in the last 24 hours + facet values + + + in the last 7 days + facet values + + + in the last 30 days + facet values + + + longer ago + facet values + + + never reached + facet values + + + nothing negotiated + facet values + + + not identified + facet values + + + not declared + facet values + + + connected over TLS + facet values + + + not {value} + facet values + + + something negotiated + facet values + + + identified at all + facet values + + + declared at all + facet values + + + we watched this happen + evidence, and what each word means + + + the game says so, and we did not check + evidence, and what each word means + + + we grouped what the game told us + evidence, and what each word means + + + name + sort orders + + + connected now + sort orders + + + last reached + sort orders + + + typically on · 7 days + sort orders + + + typically on · 30 days + sort orders + + + typically on · 90 days + sort orders + + + most on at once · 7 days + sort orders + + + most on at once · 30 days + sort orders + + + most on at once · 90 days + sort orders + + + on the row now + sort orders + + + typical + sort orders + + + peak + sort orders + + + Unknown count + sort orders + + + never once reached — not reached long ago + sort orders + + + fewer than {minimum} counts in the window, or none at all — not a typical count of zero + sort orders + + + nothing we could count in the window — not a game nobody was on + sort orders + + + median {value} · {days, plural, one {#d} other {#d}} · {count, plural, one {# count} other {# counts}} + sort orders + + + most {value} at once · {days, plural, one {#d} other {#d}} · {count, plural, one {# count} other {# counts}} + sort orders + + + Connect screen + the game page's own headings + + + Connections by hour + the game page's own headings + + + How many, over time + the game page's own headings + + + Reachable + the game page's own headings + + + What changed + the game page's own headings + + + Capabilities + the game page's own headings + + + Declared by the game + the game page's own headings + + + Referrals + the game page's own headings + + + Unclaimed — everything here was measured. + the game page's own headings + + + Claimed by its owner — measured facts below are still ours. + the game page's own headings + + + Claim this game + the game page's own headings + + + answering since {date} + the game page's own headings + + + read as text — {count, plural, one {# row} other {# rows}} + the game page's own headings + + + capability + the game page's own headings + + + age + the game page's own headings + + + offered + the game page's own headings + + + silent + the game page's own headings + + + absent + the game page's own headings + + + denied + the game page's own headings + + + claimed + the game page's own headings + + + disagrees + the game page's own headings + + + where they disagree ({count}) + the game page's own headings + + + {day} {time} — {count, plural, =0 {0 players, measured} one {# player on average} other {# players on average}} + the week of hours, said in words + + + {day} {time} — probed, no count could be read + the week of hours, said in words + + + {day} {time} — no measurement in this hour + the week of hours, said in words + + + We have not measured this game's activity yet. + the week of hours, said in words + + + No hour of the week has produced a player count. + the week of hours, said in words + + + Measured every hour and nobody has been on in any of them. + the week of hours, said in words + + + {count, plural, one {# hour on {day} has no measurement yet.} other {# hours on {day} have no measurement yet.}} + the week of hours, said in words + + + {count, plural, one {# hour across the week has no measurement yet.} other {# hours across the week have no measurement yet.}} + the week of hours, said in words + + + {count, plural, one {# hour on {day} answered but produced no count.} other {# hours on {day} answered but produced no count.}} + the week of hours, said in words + + + {count, plural, one {# hour across the week answered but produced no count.} other {# hours across the week answered but produced no count.}} + the week of hours, said in words + + + Busiest every day, {window}. + the week of hours, said in words + + + Busiest every day, {part}, {window}. + the week of hours, said in words + + + Busiest {days} {window}. + the week of hours, said in words + + + Busiest {days} {part}, {window}. + the week of hours, said in words + + + Reliably quiet {who}, {window}. + the week of hours, said in words + + + Reliably quiet {who} in the {part}, {window}. + the week of hours, said in words + + + every day + the week of hours, said in words + + + every day we could measure + the week of hours, said in words + + + on weekdays + the week of hours, said in words + + + on {days} + the week of hours, said in words + + + morning + the week of hours, said in words + + + afternoon + the week of hours, said in words + + + evening + the week of hours, said in words + + + small hours + the week of hours, said in words + + + mornings + the week of hours, said in words + + + afternoons + the week of hours, said in words + + + evenings + the week of hours, said in words + + + small hours + the week of hours, said in words + + + {list}, {next} + the week of hours, said in words + + + {first} and {second} + the week of hours, said in words + + + not enough measurements yet + the week of hours, said in words + + + No hour of the week has a measurement yet. + the week of hours, said in words + + + {count, plural, one {# hour answered and produced no count.} other {# hours answered and produced no count.}} + the week of hours, said in words + + + The grid appears once every day of the week has one. + the week of hours, said in words + + + {days, plural, one {Measured on # of the seven days so far; the grid appears once every day has an hour in it.} other {Measured on # of the seven days so far; the grid appears once every day has an hour in it.}} + the week of hours, said in words + + + {count, plural, one {# hour measured, all of it at nobody on.} other {# hours measured, all of them at nobody on.}} + the week of hours, said in words + + + {count, plural, one {# hour measured, the busiest {peak} on {day} at {time} UTC.} other {# hours measured, the busiest {peak} on {day} at {time} UTC.}} + the week of hours, said in words + + + {count, plural, one {# more hour answered and produced no count.} other {# more hours answered and produced no count.}} + the week of hours, said in words + + + {day} — {facts} + the week of hours, said in words + + + {first}, {second} + the week of hours, said in words + + + measured at zero all day + the week of hours, said in words + + + peak {count} at {time} + the week of hours, said in words + + + nobody on {window} + the week of hours, said in words + + + no count in any hour + the week of hours, said in words + + + {count, plural, one {# hour not measured} other {# hours not measured}} + the week of hours, said in words + + + {count, plural, one {# hour probed but uncountable} other {# hours probed but uncountable}} + the week of hours, said in words + + + day + the week of hours, said in words + + + quietest + the week of hours, said in words + + + busiest + the week of hours, said in words + + + at + the week of hours, said in words + + + no count + the week of hours, said in words + + + Players on by day, in UTC. {window}. + the week of hours, said in words + + + times in UTC · {window} + the week of hours, said in words + + + {weeks, plural, one {#-week rolling average} other {#-week rolling average}} + the week of hours, said in words + + + counted, including a measured zero + the week of hours, said in words + + + probed, no count could be read + the week of hours, said in words + + + no measurement in that hour + the week of hours, said in words + + + When people are on (UTC) + the week of hours, said in words + + + counted + the week of hours, said in words + + + we got in and read a number, including a measured zero + the week of hours, said in words + + + we got in and no number could be read + the week of hours, said in words + + + we have no measurement for that hour + the week of hours, said in words + + + language + the switcher's own chrome, which has to read in the locale being left + + + change language + the switcher's own chrome, which has to read in the locale being left + + + About mu*index + about: the page, and what a fact here is + + + Every game here was measured by a machine that connected to it, and every value says where it came from and when. This page covers what that proves, what we get wrong, whose directories we read, and how to make the crawler stop. + about: the page, and what a fact here is + + + What a fact here is + about: the page, and what a fact here is + + + Measured beats declared, and both are shown. + about: the page, and what a fact here is + + + A game's MSSP report is the game describing itself. The telnet handshake is what we watched it do. Both appear on its page, labelled with how and when. Where they disagree, we show the disagreement. + about: the page, and what a fact here is + + + A player count says where it came from. + about: the page, and what a fact here is + + + Either a WHO or DOING read at the connect screen, which we counted, or the game's own MSSP PLAYERS field, which it published. Never merged. + about: the page, and what a fact here is + + + An answer we cannot read is unknown, never zero. + about: the page, and what a fact here is + + + Servers customise their WHO headers freely, and past a point our parser cannot read one. That is uncountable, its own state. A measured zero — we got in, nobody was there — is a count, and prints as one. + about: the page, and what a fact here is + + + Reachable, never uptime. + about: the page, and what a fact here is + + + We open a socket from one host at intervals. A game we cannot route to is unreachable and perfectly alive. Nothing here claims a game's uptime, because nothing here measured it. + about: the page, and what a fact here is + + + An hour is counted, uncountable, or not measured. + about: the page, and what a fact here is + + + The activity grid has three states. The third is empty and names no cause: an hour we could not reach and an hour we never probed are the same absence, and neither is that server's downtime. + about: the page, and what a fact here is + + + What we know we get wrong + about: what we know we get wrong + + + Archive grace is measured from the day we found you. + about: what we know we get wrong + + + A game that stops answering leaves the default listing after its grace period: a quarter of the reachable time we probed, floored at 60 days and capped at 365. A game running since 1995 starts at the floor on the day we discover it. We import nothing to fill in the years before we arrived. + about: what we know we get wrong + + + We do not credit MSSP CREATED toward that grace. + about: what we know we get wrong + + + It is one hand-typed line in a config file, so crediting it would make the archive threshold gameable. It is shown as a declaration and buys nothing. + about: what we know we get wrong + + + Claiming a game earns the ceiling. + about: what we know we get wrong + + + Proving server access is worth the full year of grace, however long we have been watching. + about: what we know we get wrong + + + Everything here is one host, looking at intervals. + about: what we know we get wrong + + + A percentage of reachable time is a fraction of the window we observed, never of one we did not. No graphic here fills in the rest. + about: what we know we get wrong + + + Nothing is ever deleted. + about: what we know we get wrong + + + Archiving takes a game out of the default listing, the rankings and the active-today figure, and nothing else. Its page, URL, history and address survive, it keeps being probed, and one successful probe puts it back. + about: what we know we get wrong + + + What this site will not do + about: what this site will not do + + + No votes, stars, ratings or recommendations. + about: what this site will not do + + + Rankings are computed from measured data only. A directory ranked by who can mobilise the most clicks describes the campaigning, not the hobby, and that is what killed the incumbents. + about: what this site will not do + + + No forums, reviews, wikis, comments or player profiles. + about: what this site will not do + + + Orientation material — what a MUSH is, which codebase suits collaborative roleplay — is written, signed and versioned like the rest of the site. + about: what this site will not do + + + Player names are never persisted. + about: what this site will not do + + + A WHO reply is parsed in memory for a count and the shape of the header. The names are not written down; aggregates use a salted hash with a rotating salt. + about: what this site will not do + + + No absolute population figure is published. + about: what this site will not do + + + Per-codebase and per-protocol shares ship: a ratio over the measured set survives the games we cannot count. "How many people play MU*" does not, because that number would not survive being quoted. + about: what this site will not do + + + The crawler, and how to make it stop + about: the crawler, and how to make it stop + + + A probe is one connection that never logs in. + about: the crawler, and how to make it stop + + + It opens a socket, negotiates telnet options, reads the connect screen, asks for MSSP by negotiating option 70, sends {commands}, and disconnects. No character, no login, nothing changed on the far side. A timeout bounds the session so a wedged probe cannot sit on a connection slot. + about: the crawler, and how to make it stop + + + CRAWL DELAY wins. + about: the crawler, and how to make it stop + + + A game that states a preferred minimum gap in its MSSP report gets it, over our own schedule in both directions: 720 hours means monthly, not weekly. A dark game is still tried for ever at the longer interval, which is how it re-lists itself when it comes back. + about: the crawler, and how to make it stop + + + A referred address is verified, never trusted. + about: the crawler, and how to make it stop + + + MSSP lets a game name other games. Every name is resolved before anything is dialled, and refused unless every address it resolves to is globally routable. A mixed answer refuses the whole target. Our refusal is filed as ours and never appears in a game's record as downtime. + about: the crawler, and how to make it stop + + + Connect screens are shown because they are sent to everybody. + about: the crawler, and how to make it stop + + + A server paints its connect screen, unauthenticated, to every anonymous connection. We display it as evidence and label it. Ask and it comes down. + about: the crawler, and how to make it stop + + + Say stop, and we stop — three ways. + about: the crawler, and how to make it stop + + + Publish {variable} 1 in your MSSP report, and the probe that reads it is the last one. Or publish a TXT record at {label}.your.host reading "{value}", which needs no MSSP support and no account here. Or write to a person. All three are honoured within one crawl cycle, recorded with the date and what we read, and enforced on the submission form too. + about: the crawler, and how to make it stop + + + The MSSP field stops that listener; the record stops the host. + about: the crawler, and how to make it stop + + + MSSP is published by the port that answered, so it speaks for that port — MU* hosting routinely runs unrelated games on one domain, and one must not silence its neighbour. A TXT record covers every port unless it names one, as "{value}=4201". Anything there we cannot read as a port list means the whole host, so "{value}=all" works. + about: the crawler, and how to make it stop + + + The DNS route is the one you can undo without asking us. + about: the crawler, and how to make it stop + + + A TXT record is readable without connecting to a server that told us not to, so we re-read it before every dial. Delete it and we dial again within a week. An MSSP field cannot be re-read without doing the thing you asked us to stop, so MSSP opt-outs and written requests stand until you say otherwise. That TXT lookup is all an opted-out address gets: it touches your nameserver, never your game. + about: the crawler, and how to make it stop + + + Stopping is not deleting, and it is not downtime. + about: the crawler, and how to make it stop + + + A game that opts out keeps its page, its address and everything we measured before it asked. Only new data stops: the activity grid stops gaining hours and names no cause, because our decision to stop knocking is a fact about us. It is recorded on the crawl that did not happen, and in the register of who asked. + about: the crawler, and how to make it stop + + + If stopping is not enough, the listing can go too. + about: the crawler, and how to make it stop + + + Once we have stopped on every address your game answers on, your dashboard offers one more thing: take it out of the listing, the rankings and the daily figure. The page and every address it has ever had still answer, and nothing is deleted — it stops being somewhere a reader arrives by browsing. It needs a verified claim, because it is a decision about your game and we record who made it. And a probe undoes it: take your opt-out back, and the next dial that gets an answer puts you back in the listing without asking us twice. + about: the crawler, and how to make it stop + + + The crawler names itself {name} when a server asks what it is. + about: who is knocking + + + The crawler is configured to call itself {name} but cannot yet say so. Its telnet library gives a client no way to set the terminal type, so your logs see that library's default, and NEW-ENVIRON is answered from the crawler host's environment. Both are gaps in the library and ours to fix there. Until then, recognise a probe by its shape: one connection, no login, a short read-only command set, gone. + about: who is knocking + + + crawler + about: who is knocking + + + contact + about: who is knocking + + + Crawler: {name} + about: who is knocking + + + Contact: {url} + about: who is knocking + + + — placeholder; this deployment set no contact address + about: who is knocking + + + No contact address is configured, so the one above is a placeholder and answers nobody. + about: who is knocking + + + Where the list of games came from + about: where the list of games came from + + + We take addresses. Nothing else. + about: where the list of games came from + + + A backfill takes a host and a port. No player counts, no reachability history, no descriptions, no fields, and no note of which site an address came from. + about: where the list of games came from + + + Deliberately less than those sites can give. + about: where the list of games came from + + + Several hold years of dated player counts. Importing that would fill the heatmaps of the games somebody else was already watching, and rest this site's central claim on another party's prober. + about: where the list of games came from + + + A game's origin is not one fact. + about: where the list of games came from + + + Any game worth listing appears in several of these directories, so "imported from" would name whichever fetch ran first. That a game exists is public information; where we read it adds nothing and is the part of somebody else's work with the least claim to be ours. + about: where the list of games came from + + + Reading somebody's site is still reading somebody's site. + about: where the list of games came from + + + We ask for a bulk export or a documented endpoint before scraping, read robots.txt first, and rate-limit scrapes hard. A source that needs its maintainer's say-so is not fetched until a person can state they were asked. + about: where the list of games came from + + + read — addresses only + about: where the list of games came from + + + not read — awaiting permission + about: where the list of games came from + + + One page, one request. Published by a crawler that connects to each game and prints what it read. + about: where the list of games came from + + + The same crawler's MSDP listing. Nearly a subset of its MSSP sibling, read for the few addresses it reaches that the other does not. + about: where the list of games came from + + + Publishes its whole catalogue on one page, so reading it costs a single request. Our largest source of addresses, and of no measurements. + about: where the list of games came from + + + One index page and one page per world, so a scrape rather than an export. On 30 July 2026 we fetched 143 of their pages, fifteen seconds apart and honouring robots.txt, but before anyone had written to them. That should not have happened. The gate now takes a person willing to state the maintainer was asked. + about: where the list of games came from + + + Implemented, tested, never run. The strongest source here on every axis except permission, and nothing will be fetched until somebody has written to them. + about: where the list of games came from + + + Licence + about: licence + + + The code is MIT. + about: licence + + + The site, the crawler and the parsers are open source under the MIT licence. + about: licence + + + The licence for the data is an open question. + about: licence + + + A separate decision from the code's, and not yet taken. Treat the terms below as this deployment's current answer, not the project's settled position. A rival directory taking the whole catalogue is a success condition here, so whatever is settled will not stand in the way of one. + about: licence + + + code + about: licence + + + data, as this deployment serves it + about: licence + + + credit as + about: licence + + + Code: {licence} + about: licence + + + Data: {licence} + about: licence + + + Credit as: {credit} + about: licence + + + (what this deployment serves. The project's own answer is still open.) + about: licence + + + Submit a game + submit a game + + + Tell us where a game is. A host and a port is the whole form; everything else on this site is measured by our own crawler. + submit a game + + + Host + submit a game + + + Port + submit a game + + + mud.example.org, or paste mud.example.org:4201 and leave the port empty + submit a game + + + Submit + submit a game + + + Submitting needs a database, and this site is running on the demo fixture. There is no crawl registry to write into, so the form is absent rather than quietly doing nothing. + submit a game + + + Not here + submit a game + + + What happens to an address + submit a game + + + We resolve the address before dialling it, and refuse anything that resolves off the public internet. That is a decision about our own socket, never a fact about a game. + submit a game + + + If whoever runs that host has asked us not to crawl it, we will not take the address, whoever submits it. A stranger cannot put your game back on this site. + submit a game + + + If it answers, we read what the server says for itself and keep reading it on its own schedule, for ever. An address only has to be given once. + submit a game + + + Nothing appears on the site until somebody proves they run it. Claiming takes a passkey and one line published on the game itself. + submit a game + + + An address we already have collapses onto the existing entry. Sending it twice makes no second listing and brings no probe forward. + submit a game + + + that address + submit a game + + + In the registry. + submit a game + + + {address} will be dialled on the next crawl cycle, then on its own schedule for ever. It appears here once somebody proves they run it — come back to this form with the same address and it will hand you the link. + submit a game + + + We have it, unclaimed. + submit a game + + + {address} is one we already measure. It stays off the site until somebody proves they run it. If that is you, this is the way in. + submit a game + + + We already have that one. + submit a game + + + {address} is a game we already measure. Nothing was created and nothing was changed. + submit a game + + + We already have that address. + submit a game + + + {address} is already known to us. Nothing was created and nothing was changed. + submit a game + + + Already waiting. + submit a game + + + {address} is in the crawl registry and has not answered yet. Sending it again does not bring it forward: a target keeps its own schedule, so nobody can hurry us at somebody else's server. + submit a game + + + Not an address we can dial. + submit a game + + + A host needs a dot or a colon in it, and a port is a number between 1 and 65535. Fill in both boxes, or paste mud.example.org:4201 into the first. + submit a game + + + We cannot dial that. + submit a game + + + Three things produce this answer for {address}: the name may not resolve, it may resolve off the public internet, or whoever runs that host may have asked us to stay away. We deliberately do not say which, because answering that for a stranger maps a network from outside it. Nothing was recorded about the address; the decision was ours and it is filed as ours. + submit a game + + + Enough for now. + submit a game + + + This form is rate-limited by sender, and you have hit the bound. Come back in an hour. Nothing was lost — anything we took is already in the registry. + submit a game + + + claim this game + submit a game + + + Sign in + signing in, which is a passkey and nothing else + + + Sign in with a passkey to claim a game you run. There is no password to lose and none to steal. + signing in, which is a passkey and nothing else + + + Claiming needs a database, and this site is running on the demo fixture. There is nothing to sign in to. + signing in, which is a passkey and nothing else + + + Sign-in is a passkey. + signing in, which is a passkey and nothing else + + + Your device or password manager holds the private key; we hold only the public half. No password, no email. + signing in, which is a passkey and nothing else + + + Sign in with a passkey + signing in, which is a passkey and nothing else + + + The one page here that needs JavaScript. Passkeys cannot work without it. + signing in, which is a passkey and nothing else + + + No account yet? + signing in, which is a passkey and nothing else + + + You need one only to claim a game you run. Pick a name to be known by — a label beside your claim, not a real name. + signing in, which is a passkey and nothing else + + + Name + signing in, which is a passkey and nothing else + + + e.g. corvid-admin + signing in, which is a passkey and nothing else + + + Create an account with a passkey + signing in, which is a passkey and nothing else + + + What we store + signing in, which is a passkey and nothing else + + + The name you chose. + signing in, which is a passkey and nothing else + + + The public key of each passkey you register, and what your device called it. + signing in, which is a passkey and nothing else + + + Which games you have claimed, and when. + signing in, which is a passkey and nothing else + + + No email address, no password, no IP log tied to your account. Lose every passkey and you can publish a fresh claim token on your game and start again: the game is the proof, not the account. + signing in, which is a passkey and nothing else + + + {day} {month} {year} + dates, ages and provenance — the two shapes on nearly every page + + + {date} {time} UTC + dates, ages and provenance — the two shapes on nearly every page + + + now + the age ladder, in three registers + + + {count, plural, one {#m} other {#m}} + the age ladder, in three registers + + + {count, plural, one {#h} other {#h}} + the age ladder, in three registers + + + {count, plural, one {#d} other {#d}} + the age ladder, in three registers + + + {count, plural, one {#w} other {#w}} + the age ladder, in three registers + + + {count, plural, one {#mo} other {#mo}} + the age ladder, in three registers + + + {count, plural, one {#y} other {#y}} + the age ladder, in three registers + + + just now + the age ladder, in three registers + + + {count, plural, one {#m ago} other {#m ago}} + the age ladder, in three registers + + + {count, plural, one {#h ago} other {#h ago}} + the age ladder, in three registers + + + {count, plural, one {#d ago} other {#d ago}} + the age ladder, in three registers + + + {count, plural, one {#w ago} other {#w ago}} + the age ladder, in three registers + + + {count, plural, one {#mo ago} other {#mo ago}} + the age ladder, in three registers + + + {count, plural, one {#y ago} other {#y ago}} + the age ladder, in three registers + + + just now + the age ladder, in three registers + + + {count, plural, one {#m ago} other {#m ago}} + the age ladder, in three registers + + + {count, plural, one {#h ago} other {#h ago}} + the age ladder, in three registers + + + {count, plural, one {#d ago} other {#d ago}} + the age ladder, in three registers + + + {count, plural, one {#w ago} other {#w ago}} + the age ladder, in three registers + + + {count, plural, one {#mo ago} other {#mo ago}} + the age ladder, in three registers + + + {count, plural, one {#y ago} other {#y ago}} + the age ladder, in three registers + + + {age}, {stamp} + the age ladder, in three registers + + + , {stamp} + the age ladder, in three registers + + + {value} — {how} via {source}, last confirmed {date} + the provenance chip's tooltip + + + {value} — {how} via {source}, last confirmed {date} (past its expected refresh) + the provenance chip's tooltip + + + ({how}, {age}) + the provenance chip's tooltip + + + ({how}, {age}, stale) + the provenance chip's tooltip + + + owner-declared + the provenance chip's tooltip + + + staff + how a value reached us, one id per source + + + the telnet handshake + how a value reached us, one id per source + + + the owner + how a value reached us, one id per source + + + WHO + how a value reached us, one id per source + + + I3 + how a value reached us, one id per source + + + MSSP + how a value reached us, one id per source + + + INFO + how a value reached us, one id per source + + + the I3 mudlist + how a value reached us, one id per source + + + the connect screen + how a value reached us, one id per source + + + The ecosystem + the ecosystem dashboard: shares, never totals, and never a share without its set + + + Shares, never totals. We do not publish a figure for how many people play MU*: a ratio over the games we measured survives the ones we cannot reach, and a headcount does not. + the ecosystem dashboard: shares, never totals, and never a share without its set + + + {count, plural, one {{value} game listed} other {{value} games listed}} + the ecosystem dashboard: shares, never totals, and never a share without its set + + + {count, plural, one {{value} game whose handshake we completed} other {{value} games whose handshake we completed}} + the ecosystem dashboard: shares, never totals, and never a share without its set + + + {count, plural, one {{value} game whose MSSP report we hold} other {{value} games whose MSSP report we hold}} + the ecosystem dashboard: shares, never totals, and never a share without its set + + + Oldest handshake here: confirmed {age} ago. + the ecosystem dashboard: shares, never totals, and never a share without its set + + + {count, number} of {total, number} ({fraction, number, ::percent .0}) + the ecosystem dashboard: shares, never totals, and never a share without its set + + + {count, number} of {total, number} — nothing measured yet + the ecosystem dashboard: shares, never totals, and never a share without its set + + + {fraction, number, ::percent .0} + the ecosystem dashboard: shares, never totals, and never a share without its set + + + Codebases + the ecosystem dashboard: shares, never totals, and never a share without its set + + + Of the {listed, plural, one {# game} other {# games}} listed, {identified, number} told us what they run, and every share below is over those {identified, number}. A codebase we could not read is left out of the denominator, never counted as something else. + the ecosystem dashboard: shares, never totals, and never a share without its set + + + No listed game has told us its codebase yet. + the ecosystem dashboard: shares, never totals, and never a share without its set + + + {share} run a codebase no other listed game runs — one game each, which is a name rather than a share. They are inside the denominator above and folded out of the bars, not dropped: + the ecosystem dashboard: shares, never totals, and never a share without its set + + + Lineages + the ecosystem dashboard: shares, never totals, and never a share without its set + + + The same games, grouped by the tradition their server descends from — our reading of the codebase, not anything a game published. No game reports "MUSH": MSSP has no such value, and most of the MUSH world publishes no MSSP at all, so this is the only way the question can be asked. + the ecosystem dashboard: shares, never totals, and never a share without its set + + + No listed game runs a codebase we place in a lineage yet. + the ecosystem dashboard: shares, never totals, and never a share without its set + + + {count, plural, one {# of those games runs a codebase} other {# of those games run codebases}} we do not place in any lineage — several say as much themselves, publishing {family}. They are inside the denominator above and in nobody's share. + the ecosystem dashboard: shares, never totals, and never a share without its set + + + Protocols + the ecosystem dashboard: shares, never totals, and never a share without its set + + + Read every measured figure below as a floor. We ask for MSSP by name, so silence there is an answer. Nothing else here is requested, and a server may support a protocol without ever offering it. + the ecosystem dashboard: shares, never totals, and never a share without its set + + + {instrument} is the one row below that is not a floor: we ask every server for it by name, so the games that did not offer it were asked and declined. It is also the only one with no declared figure, because every game whose report we hold supports it by demonstration and a count of the ones that also listed it would measure a habit against that. + the ecosystem dashboard: shares, never totals, and never a share without its set + + + We hold {reports, number} reports and {offered, plural, one {# game} other {# games}} offer MSSP today: the other {gap, number} stopped publishing one after we read it, and a report is not thrown away because it stopped being reissued. + the ecosystem dashboard: shares, never totals, and never a share without its set + + + Protocol adoption. Measured is what a server offered in a completed handshake; declared is what its MSSP claims. Two sets of games, so two denominators. + the ecosystem dashboard: shares, never totals, and never a share without its set + + + protocol + the ecosystem dashboard: shares, never totals, and never a share without its set + + + measured — of {basis} + the ecosystem dashboard: shares, never totals, and never a share without its set + + + declared — of {basis} + the ecosystem dashboard: shares, never totals, and never a share without its set + + + not measured — never observed + the ecosystem dashboard: shares, never totals, and never a share without its set + + + {share} · {declined, plural, one {# game} other {# games}} declined when asked + the ecosystem dashboard: shares, never totals, and never a share without its set + + + {share} · {unobserved, plural, one {# game} other {# games}} neither offered nor asked + the ecosystem dashboard: shares, never totals, and never a share without its set + + + {share} · {declined, plural, one {# game} other {# games}} declined when asked · {unobserved, plural, one {# game} other {# games}} neither offered nor asked + the ecosystem dashboard: shares, never totals, and never a share without its set + + + not asked — every report here is the answer + the ecosystem dashboard: shares, never totals, and never a share without its set + + + Adoption over time + the ecosystem dashboard: shares, never totals, and never a share without its set + + + Each point is a share over the games we had measured that day, so this line moves for two reasons: a game changing what it offers, and the set of games we can measure changing around it. Only the first is adoption. The transition count below is the part that is purely games changing their minds. + the ecosystem dashboard: shares, never totals, and never a share without its set + + + Measured share of each protocol, oldest reading first + the ecosystem dashboard: shares, never totals, and never a share without its set + + + then + the ecosystem dashboard: shares, never totals, and never a share without its set + + + now + the ecosystem dashboard: shares, never totals, and never a share without its set + + + not measured + the ecosystem dashboard: shares, never totals, and never a share without its set + + + A snapshot, not a curve + the ecosystem dashboard: shares, never totals, and never a share without its set + + + A snapshot of what we can measure now. An adoption curve plots games changing their minds, and we record a change when it happens, so the curve becomes drawable once enough have been recorded. Plotting when we first reached each game would measure the crawl, not the hobby. + the ecosystem dashboard: shares, never totals, and never a share without its set + + + {count, plural, one {# capability change} other {# capability changes}} recorded so far — the material a curve is drawn from. + the ecosystem dashboard: shares, never totals, and never a share without its set + + + No measured capability has changed yet, so there is nothing to plot. + the ecosystem dashboard: shares, never totals, and never a share without its set + + + Measured is of {measured}; declared is of {declared}. Two sets of games, so two denominators. + the ecosystem dashboard: shares, never totals, and never a share without its set + + + {listed} · {handshakes} · {mssp}. + the ecosystem dashboard: shares, never totals, and never a share without its set + + + The oldest handshake in this picture was last confirmed {age} ago. + the ecosystem dashboard: shares, never totals, and never a share without its set + + + The same games, grouped by the tradition their server descends from. This is {evidence} — {meaning} — and not anything a game published: no game reports "MUSH", because MSSP has no such value and most of the MUSH world publishes no MSSP at all. + the ecosystem dashboard: shares, never totals, and never a share without its set + + + measured: {value} + the ecosystem dashboard: shares, never totals, and never a share without its set + + + declared: {value} + the ecosystem dashboard: shares, never totals, and never a share without its set + + + Rankings + the rankings: computed from measured data only, and it says so first + + + Computed from measured data only. No votes, stars or ratings, ever. Nothing here ranks quality. We have not measured it. + the rankings: computed from measured data only, and it says so first + + + Busiest, by measured concurrent players + the rankings: computed from measured data only, and it says so first + + + Ranking window + the rankings: computed from measured data only, and it says so first + + + {days, plural, one {# day} other {# days}} + the rankings: computed from measured data only, and it says so first + + + Median of the player counts we measured over the last {days, plural, one {# day} other {# days}}. + the rankings: computed from measured data only, and it says so first + + + No game yet has the {samples, number} samples across {days, plural, one {# day} other {# days}} that a median needs. + the rankings: computed from measured data only, and it says so first + + + {eligible, plural, one {# game} other {# games}} of {listed, number} have the {samples, number} samples across {days, plural, one {# day} other {# days}} it needs. + the rankings: computed from measured data only, and it says so first + + + A measured zero counts; an unreadable count does not. + the rankings: computed from measured data only, and it says so first + + + A week says who is busy now; a quarter says who has been busy. They are different questions and a game can lead one and not the other. Days are whole days, UTC. + the rankings: computed from measured data only, and it says so first + + + No listed game has enough counted samples to rank yet — a statement about how long we have been measuring, not about how busy anybody is. + the rankings: computed from measured data only, and it says so first + + + Games ranked by the median of the player counts we measured over the last {days, plural, one {# day} other {# days}}. Games on the same median share a place; nothing here breaks the tie. + the rankings: computed from measured data only, and it says so first + + + # + the rankings: computed from measured data only, and it says so first + + + game + the rankings: computed from measured data only, and it says so first + + + median + the rankings: computed from measured data only, and it says so first + + + peak + the rankings: computed from measured data only, and it says so first + + + counted samples + the rankings: computed from measured data only, and it says so first + + + days measured + the rankings: computed from measured data only, and it says so first + + + Longest unbroken reachable spell + the rankings: computed from measured data only, and it says so first + + + Every probe since the date given found the game reachable. Reachable, not up: we measure a socket from one host, and a game we cannot route to is perfectly alive. A spell cannot be longer than we have been watching, so the date is the fact and the duration follows. + the rankings: computed from measured data only, and it says so first + + + No listed game is in an unbroken reachable spell right now. + the rankings: computed from measured data only, and it says so first + + + Games whose every probe since the date given found them reachable. Games reachable since the same date share a place; nothing here breaks the tie. + the rankings: computed from measured data only, and it says so first + + + reachable since + the rankings: computed from measured data only, and it says so first + + + that is + the rankings: computed from measured data only, and it says so first + + + Archived games are out of both tables and nothing else; one successful probe puts them back. + the rankings: computed from measured data only, and it says so first + + + Busiest — median measured players, last {days, plural, one {# day} other {# days}} + the rankings: computed from measured data only, and it says so first + + + windows: + the rankings: computed from measured data only, and it says so first + + + this one + the rankings: computed from measured data only, and it says so first + + + median {median, number} · peak {peak, number} · {samples, plural, one {# counted sample} other {# counted samples}} over {days, number} of {window, number} days + the rankings: computed from measured data only, and it says so first + + + reachable on every probe since {date} · {duration} + the rankings: computed from measured data only, and it says so first + + + The archive + the archive: removed from the default listing, and from nothing else + + + Games that have stopped answering. Nothing was deleted. Still probed weekly, and one successful probe puts a game back in the listing the same day. + the archive: removed from the default listing, and from nothing else + + + search the archive + the archive: removed from the default listing, and from nothing else + + + Search archived games + the archive: removed from the default listing, and from nothing else + + + name, codebase or description + the archive: removed from the default listing, and from nothing else + + + show + the archive: removed from the default listing, and from nothing else + + + {count, plural, =0 {No archived games} one {# archived game} other {# archived games}} + the archive: removed from the default listing, and from nothing else + + + archived + the archive: removed from the default listing, and from nothing else + + + last reachable + the archive: removed from the default listing, and from nothing else + + + known live + the archive: removed from the default listing, and from nothing else + + + ({age} ago) + the archive: removed from the default listing, and from nothing else + + + Nothing matched. + the archive: removed from the default listing, and from nothing else + + + never, in anything we measured + the archive: removed from the default listing, and from nothing else + + + no reachable time measured + the archive: removed from the default listing, and from nothing else + + + unknown + the archive: removed from the default listing, and from nothing else + + + {years, number, ::.#} years + the archive: removed from the default listing, and from nothing else + + + {days, plural, one {# day} other {# days}} + the archive: removed from the default listing, and from nothing else + + + {from} – {to} · {span} + the archive: removed from the default listing, and from nothing else + + + {count, plural, one {# month} other {# months}} + the archive: removed from the default listing, and from nothing else + + + {count, plural, one {# year} other {# years}} + the archive: removed from the default listing, and from nothing else + + + {count, plural, one {# game} other {# games}} matching "{query}" + the archive: removed from the default listing, and from nothing else + + + {count, plural, one {# game} other {# games}} + the archive: removed from the default listing, and from nothing else + + + Last reachable: + the archive: removed from the default listing, and from nothing else + + + Known live: + the archive: removed from the default listing, and from nothing else + + + {value} of measured reachable time + the archive: removed from the default listing, and from nothing else + + + Run: + the archive: removed from the default listing, and from nothing else + + + Codebase: + the archive: removed from the default listing, and from nothing else + + + random game + /find's footer, which the rebuild left in English + + + {d, date, medium} — {typical} on average, {low}–{high} across {probes, plural, one {# probe} other {# probes}} + the trend chart's per-day title, one per column + + + {d, date, medium} — {count, plural, =0 {0 players} one {# player} other {# players}}, every one of {probes, plural, one {# probe} other {# probes}} + the trend chart's per-day title, one per column + + + {d, date, medium} — probed, no count could be read + the trend chart's per-day title, one per column + + + {d, date, medium} — no measurement + the trend chart's per-day title, one per column + + + Typically {typical} on, peaking at {peak}, over {counted} of {days, plural, one {# day} other {# days}}. + the sentence above the chart + + + Steady across the range. + the sentence above the chart + + + Up about {change, number, percent} from the start of the range to the end. + the sentence above the chart + + + Down about {change, number, percent} from the start of the range to the end. + the sentence above the chart + + + Probed in this range, and no player count could be read from any of it. + the sentence above the chart + + + No measurement in this range. + the sentence above the chart + + + Nothing counted in this range. + the sentence above the chart + + + {from, date, d MMM}–{to, date, d MMM} + the week lines, which are the text alternative and the plain surface both + + + {d, date, d MMM} + the week lines, which are the text alternative and the plain surface both + + + {span}: typically {typical}, peak {peak}, {days, plural, one {# day} other {# days}} counted + the week lines, which are the text alternative and the plain surface both + + + {span}: probed, no count could be read + the week lines, which are the text alternative and the plain surface both + + + {span}: not measured + the week lines, which are the text alternative and the plain surface both + + + {count, plural, one {# day probed without a count} other {# days probed without a count}} + the week lines, which are the text alternative and the plain surface both + + + {count, plural, one {# day not measured} other {# days not measured}} + the week lines, which are the text alternative and the plain surface both + + + {line}, {clause} + the week lines, which are the text alternative and the plain surface both + + + days in UTC · {from, date, medium} – {to, date, medium} + the chart's own chrome + + + {value} at the top + the chart's own chrome + + + {counted} of {days, plural, one {# day} other {# days}} counted + the chart's own chrome + + + {d, date, MMM} + the chart's own chrome + + + {d, date, MMM yyyy} + the chart's own chrome + + + mean of the counts we read that day + the chart's own chrome + + + up to the busiest count that day + the chart's own chrome + + + lowest to highest count that day + the chart's own chrome + + + probed, no count could be read + the chart's own chrome + + + not measured — no bar at all + the chart's own chrome + + + not measured — a break in the line + the chart's own chrome + + + Trend range + seeking and switching, which are links because the range is in the address + + + Trend shape + seeking and switching, which are links because the range is in the address + + + {days, plural, one {# day} other {# days}} + seeking and switching, which are links because the range is in the address + + + ← earlier + seeking and switching, which are links because the range is in the address + + + later → + seeking and switching, which are links because the range is in the address + + + line + seeking and switching, which are links because the range is in the address + + + bars + seeking and switching, which are links because the range is in the address + + + How many, over time + seeking and switching, which are links because the range is in the address + + + {from, date, medium} – {to, date, medium}, UTC + seeking and switching, which are links because the range is in the address + + + earlier + seeking and switching, which are links because the range is in the address + + + a week is summarised over the days in it we counted; a week with none says so + seeking and switching, which are links because the range is in the address + + + reachable · last {days, plural, one {# day} other {# days}} + the reachability strip + + + reachable + the reachability strip + + + longest outage + the reachability strip + + + last cause + the reachability strip + + + none in the window + the reachability strip + + + nothing recorded + the reachability strip + + + {days, plural, one {# day ago} other {# days ago}} + the reachability strip + + + today + the reachability strip + + + reachable + the reachability strip + + + degraded — answered, could not finish + the reachability strip + + + unreachable + the reachability strip + + + not measured + the reachability strip + + + reachable + the reachability strip + + + degraded + the reachability strip + + + unreachable + the reachability strip + + + not measured + the reachability strip + + + {d, date, d MMM} — reachable all day + the reachability strip + + + {d, date, d MMM} — degraded ({cause}): answered, could not finish + the reachability strip + + + {d, date, d MMM} — unreachable ({cause}) + the reachability strip + + + {d, date, d MMM} — not measured; we were not watching this game yet + the reachability strip + + + {days, plural, one {Not yet measured over the last # day.} other {Not yet measured over the last # days.}} + the reachability strip + + + Reachable {percent} of the last {days, plural, one {# day} other {# days}}. + the reachability strip + + + Reachable {percent} of the {days, plural, one {# day} other {# days}} we have measured. + the reachability strip + + + {days, plural, one {Reachability over the last # day is not yet measured.} other {Reachability over the last # days is not yet measured.}} + the reachability strip + + + No day in the window was unreachable. + the reachability strip + + + No day we measured was unreachable. + the reachability strip + + + {count, plural, one {# day unreachable.} other {# days unreachable.}} + the reachability strip + + + {count, plural, one {# day degraded — we got in and could not finish.} other {# days degraded — we got in and could not finish.}} + the reachability strip + + + Longest outage {duration}. + the reachability strip + + + Longest outage {duration} ({cause}). + the reachability strip + + + {count, plural, one {# day predates anything we measured.} other {# days predate anything we measured.}} + the reachability strip + + + {from, date, d MMM} – {to, date, d MMM} + the reachability strip + + + {d, date, d MMM} + the reachability strip + + + {range}: {count, plural, one {# day} other {# days}} {word} + the reachability strip + + + {range}: {count, plural, one {# day} other {# days}} {word} ({cause}) + the reachability strip + + + Reachable + the reachability strip + + + {days, plural, one {last # day} other {last # days}} + the reachability strip + + + Reachable: {percent} of the {days, plural, one {last # day} other {last # days}} + the reachability strip + + + Longest outage: {duration} + the reachability strip + + + dns did not resolve + why a dial did not complete, in a person's words + + + connection refused + why a dial did not complete, in a person's words + + + tls failed + why a dial did not complete, in a person's words + + + timed out + why a dial did not complete, in a person's words + + + handshake stalled + why a dial did not complete, in a person's words + + + no cause recorded + why a dial did not complete, in a person's words + + + The owner asked us not to republish this game's connect screen. + the ANSI capture's frame + + + No connect screen has been captured from this game. + the ANSI capture's frame + + + {count, plural, one {Only # row came back — too little to show.} other {Only # rows came back — too little to show.}} + the ANSI capture's frame + + + as sent by the server + the ANSI capture's frame + + + {columns}×{rows} + the ANSI capture's frame + + + {columns}×{rows}, double-width + the ANSI capture's frame + + + 16-colour SGR + the ANSI capture's frame + + + no colour + the ANSI capture's frame + + + read as {charset} + the ANSI capture's frame + + + captured + the ANSI capture's frame + + + frozen — the last screen we saw + the ANSI capture's frame + + + ASCII art: the connect screen of {game}. Its text is under "read as text", below. + the ANSI capture's frame + + + ASCII art: this game's connect screen. Its text is under "read as text", below. + the ANSI capture's frame + + + {count, plural, one {connect screen: # line, text only} other {connect screen: # lines, text only}} + the ANSI capture's frame + + + {count, plural, one {connect screen: # line, text only, read as {charset}} other {connect screen: # lines, text only, read as {charset}}} + the ANSI capture's frame + + + the game declares it, and the server has never offered it in a handshake. + the capability matrix's disagreements, said in prose + + + the server offers it, and the game's own record says it does not. + the capability matrix's disagreements, said in prose + + + Usually a stale hand-typed field, not a lie. Shown because a client should not rely on what the two disagree about. + the capability matrix's disagreements, said in prose + + + Not found + the rest of the game page + + + No game at this address. Check the spelling. + the rest of the game page + + + last answered + the rest of the game page + + + {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 + + + This game's own referral list names: + the rest of the game page + + + Named by the referral list of: + the rest of the game page + + + since {date} + the rest of the game page + + + no longer listed, last seen + the rest of the game page + + + Last answered {date}, {ago} ago. + the rest of the game page + + + Still probed weekly; this page updates the day it answers. + the rest of the game page + + + {span} known live + the rest of the game page + + + Not in the listing, the rankings or the daily figure: we do not think this address is a game somebody can play. Everything below is what it told us, unchanged. + the rest of the game page + + + Our reason: {why} + the rest of the game page + + + Not in the listing, the rankings or the daily figure, at the request of the people who run it. Everything below is preserved as it was, this page and every address it has ever had go on answering, and we are not dialling it. + the rest of the game page + + + Players now: {count} + the plain surface's own headings for this page + + + Capabilities ({disagreeing, plural, one {# of {total} disagrees} other {# of {total} disagree}}) + the plain surface's own headings for this page + + + Declared by the game + the plain surface's own headings for this page + + + Connect screen + the plain surface's own headings for this page + + + What changed + the plain surface's own headings for this page + + + measured + the plain surface's own headings for this page + + + declared + the plain surface's own headings for this page + + + ** disagree + the plain surface's own headings for this page + + + connected + appended block: the listing row's own labels and the last three chips + + + reached + appended block: the listing row's own labels and the last three chips + + + search + appended block: the listing row's own labels and the last three chips + + + archived + appended block: the listing row's own labels and the last three chips + + + adult + appended block: the listing row's own labels and the last three chips + + + included + appended block: the listing row's own labels and the last three chips + + + no count + added by the accessibility review, second pass + + + no current count, and nothing here says why + added by the accessibility review, second pass + + + Players now: no count (nothing here says why) + added by the accessibility review, second pass + + + crawler live · last probe {age} + the crawler strip, whose sentence was English around a localized age + + + crawler quiet · last probe {age} + the crawler strip, whose sentence was English around a localized age + + + no probe has finished here yet + the crawler strip, whose sentence was English around a localized age + + + nothing due this cycle + the crawler strip, whose sentence was English around a localized age + + + {considered, plural, one {# due} other {# due}} · {answered, plural, one {# answered} other {# answered}} · {failed, plural, one {# failed} other {# failed}} + the crawler strip, whose sentence was English around a localized age + + + {targets, plural, one {# address in the registry} other {# addresses in the registry}}, {due, plural, one {# due now} other {# due now}} + the crawler strip, whose sentence was English around a localized age + + + {page} — {site} + what this site says about itself where it is not this site + + + A directory of the MU* hobby — MUSHes, MUDs, MUCKs, MOOs — where every fact carries how it was obtained and how old it is. + what this site says about itself where it is not this site + + + Demo data — nothing here was measured. {description} + what this site says about itself where it is not this site + + + {site} — measured, not asserted + what this site says about itself where it is not this site + + + {title} on {site} + what this site says about itself where it is not this site + + + Games + what this site says about itself where it is not this site + + + The archive + what this site says about itself where it is not this site + + + Rankings + what this site says about itself where it is not this site + + + The ecosystem + what this site says about itself where it is not this site + + + Reference + what this site says about itself where it is not this site + + + About + what this site says about itself where it is not this site + + + Not found + what this site says about itself where it is not this site + + + Random game + what this site says about itself where it is not this site + + + Your games + what this site says about itself where it is not this site + + + Claim {game} + what this site says about itself where it is not this site + + + Every MU* we have reached, faceted on what we measured: codebase, the protocols a server offered in the handshake, TLS, charset, language, and when we last got in. + what this site says about itself where it is not this site + + + The games that went dark, kept. Each keeps its page, history and URL, is still probed weekly, and returns to the listing on one successful connection. + what this site says about itself where it is not this site + + + Busiest, most reachable, longest running — computed from measurements only. No votes, stars or ratings anywhere on this site. + what this site says about itself where it is not this site + + + Codebase share and protocol adoption across the games we measure, with what servers offer set beside what they declare. Shares, never totals. + what this site says about itself where it is not this site + + + Hand-written pages on the codebases, clients and protocols of the MU* hobby, cross-linked to counts taken from the crawl. + what this site says about itself where it is not this site + + + How this catalogue is built: what the crawler does, what it refuses to do, and how to make it stop. + what this site says about itself where it is not this site + + + No game at this address. Nothing here is ever deleted, so a game that once lived at this URL still does — check the spelling. + what this site says about itself where it is not this site + + + One game from the catalogue, chosen at random and never the same one twice. + what this site says about itself where it is not this site + + + The listings you have claimed, and what a claim lets you change. + what this site says about itself where it is not this site + + + Prove you run this game by publishing a token where only its operator could put it. + what this site says about itself where it is not this site + + + Archived — last reachable {age}, and still probed + what this site says about itself where it is not this site + + + Archived, and still probed + what this site says about itself where it is not this site + + + Player count unknown — the game answers, and publishes no number we can read + what this site says about itself where it is not this site + + + {count, plural, one {# player} other {# players}}, {how} {age} + what this site says about itself where it is not this site + + + Newly discovered + the plain mirror's feed headings and home counts + + + Went dark + the plain mirror's feed headings and home counts + + + Came back + the plain mirror's feed headings and home counts + + + {count, plural, one {# game known} other {# games known}} + the plain mirror's feed headings and home counts + + + {count, plural, one {# connected now (measured)} other {# connected now (measured)}} + the plain mirror's feed headings and home counts + + + {count, plural, one {# answering, uncounted} other {# answering, uncounted}} + the plain mirror's feed headings and home counts + + + {count, plural, one {# archived, still probed} other {# archived, still probed}} + the plain mirror's feed headings and home counts + + + Reference + THE REFERENCE SECTION'S CHROME + + + What the codebases are, what the clients do, and what the protocols mean. Written by hand and kept in the repository beside the crawler — not a wiki, and there is nothing on this page to edit. Every {number} here is a different thing: it comes from the catalogue and is recomputed each time you load the page. + THE REFERENCE SECTION'S CHROME + + + number + THE REFERENCE SECTION'S CHROME + + + Hand-written, single-author, and versioned in git. The prose here is ours; every number beside it was measured by the crawler and is recomputed on each request. This is not a wiki, and there is no way to edit it from this page. + THE REFERENCE SECTION'S CHROME + + + Start here + THE REFERENCE SECTION'S CHROME + + + Codebases + THE REFERENCE SECTION'S CHROME + + + Clients + THE REFERENCE SECTION'S CHROME + + + Protocols + THE REFERENCE SECTION'S CHROME + + + orientation + THE REFERENCE SECTION'S CHROME + + + codebase + THE REFERENCE SECTION'S CHROME + + + client + THE REFERENCE SECTION'S CHROME + + + protocol + THE REFERENCE SECTION'S CHROME + + + Not found + THE REFERENCE SECTION'S CHROME + + + No reference page here. This section is hand-written, so a gap is work nobody has done rather than something that was removed — {index}. + THE REFERENCE SECTION'S CHROME + + + see what there is + THE REFERENCE SECTION'S CHROME + + + Games running it + a codebase page's measured half + + + We have not identified any yet. That is a fact about what this crawler has measured, not about what exists — a game we have not reached, or whose codebase we could not read, is not counted here. + a codebase page's measured half + + + {count, plural, one {# listed} other {# listed}} + a codebase page's measured half + + + {count, plural, one {# archived} other {# archived}} + a codebase page's measured half + + + measured, never asserted + a codebase page's measured half + + + Counted from the catalogue on this request, over the same filter the link above carries — so this number and that listing are one query and cannot drift apart. + a codebase page's measured half + + + offered in their handshakes: {protocols} + a codebase page's measured half + + + Measured adoption + a protocol page's measured half + + + Nothing measured yet. + a protocol page's measured half + + + of {listed, plural, one {# listed game} other {# listed games}} — {percent} + a protocol page's measured half + + + The games not counted here are not games without the protocol. A game is counted when we observed its server offering the option in a handshake; the rest are servers that did not offer it to us and servers whose handshake we have not read, and we cannot tell you which. + a protocol page's measured half + + + Games observed offering {protocol} in a handshake, by the codebase we identified them as running. + a protocol page's measured half + + + codebase + a protocol page's measured half + + + offered it + a protocol page's measured half + + + identified + a protocol page's measured half + + + See also + a protocol page's measured half + + + Capabilities + the client capability matrix, which is documentation rather than measurement + + + {count, plural, one {# of {total} established from the project's own documentation} other {# of {total} established from the project's own documentation}} + the client capability matrix, which is documentation rather than measurement + + + Read off each project's own documentation, not measured by us — a client has no handshake for us to observe. "{unknown}" means we looked and did not establish it. It never means no. + the client capability matrix, which is documentation rather than measurement + + + Client capabilities, each read off the project's own documentation. Unknown means we did not establish it, and never that the client lacks it. + the client capability matrix, which is documentation rather than measurement + + + documented + the client capability matrix, which is documentation rather than measurement + + + source + the client capability matrix, which is documentation rather than measurement + + + we did not find one + the client capability matrix, which is documentation rather than measurement + + + yes + the client capability matrix, which is documentation rather than measurement + + + no + the client capability matrix, which is documentation rather than measurement + + + unknown + the client capability matrix, which is documentation rather than measurement + + + Runs on: {platforms} + the plain mirror's own wording + + + Games we have identified as running this codebase + the plain mirror's own wording + + + None yet. That is a statement about what we have measured, not about what exists — a game we have not reached, or whose codebase we could not read, is not counted here. + the plain mirror's own wording + + + {listed, plural, one {# listed} other {# listed}}, {archived, plural, one {# archived} other {# archived}} + the plain mirror's own wording + + + Measured in their handshakes: {protocols} + the plain mirror's own wording + + + Nothing was offered in any handshake we have read from them. + the plain mirror's own wording + + + {offering} of {listed, plural, one {# listed game} other {# listed games}} were observed offering it ({percent}) + the plain mirror's own wording + + + By codebase, of the games we identified + the plain mirror's own wording + + + {offering} of {identified} offered it + the plain mirror's own wording + + + {count, plural, one {# of {total} rows is unknown} other {# of {total} rows are unknown}}: we did not find the project's own documentation saying either way. A short honest table beats a long guessed one. + the plain mirror's own wording + + + Your games + the dashboard's frame + + + Accounts need a database behind them, and this site is running on the demo fixture. + the dashboard's frame + + + Sign in + the dashboard's frame + + + Signed in as {name}. + the dashboard's frame + + + Sign out + the dashboard's frame + + + You have not claimed anything yet. Find your game in {listing} and press {claimControl} on its page. + the dashboard's frame + + + the listing + the dashboard's frame + + + claim this game + the dashboard's frame + + + Given up. + the one banner a POST comes back with + + + The record of it is kept, and you can prove control again any time by publishing a fresh token. + the one banner a POST comes back with + + + Saved. + the one banner a POST comes back with + + + That game + the one banner a POST comes back with + + + {game}'s page now shows it as owner-declared. + the one banner a POST comes back with + + + We have stopped republishing {game}'s connect screen. The page says so plainly rather than leaving a hole. + the one banner a POST comes back with + + + {game}'s connect screen is on its page again. + the one banner a POST comes back with + + + We have stopped dialling {game}, on every address we have for it. Its page keeps everything measured before you asked. + the one banner a POST comes back with + + + We are dialling {game} again, from its next turn in the schedule. + the one banner a POST comes back with + + + {game} is out of the listing, the rankings and the daily figure. Its page and every URL it has ever had go on answering. + the one banner a POST comes back with + + + {game} is back in the listing. One probe that answers is all it needs to be measured again. + the one banner a POST comes back with + + + {field} was not changed. + the one banner a POST comes back with + + + These are one-line answers; {max} characters is the most we store. + the one banner a POST comes back with + + + That field is measured. A claim lets you add what MSSP has no room for; it never lets anybody edit what we observed, and that includes us. + the one banner a POST comes back with + + + Claimed + a claimed game's block + + + verified {date} + a claimed game's block + + + verified {date}, token last seen {seen} + a claimed game's block + + + check your MSSP + a claimed game's block + + + {count, plural, one {Also owned by {names} — who verified a token of their own.} other {Also owned by {names} — each having verified a token of their own.}} + a claimed game's block + + + another account + a claimed game's block + + + put your player count on your own site + a claimed game's block + + + The badge carries the count and when we measured it, because a number with no age is the thing this site exists to replace. + a claimed game's block + + + It says {unknown} rather than nought when we could not count, and {archived} if the game stops answering. + a claimed game's block + + + There is {json} too, if you would rather draw your own. + a claimed game's block + + + history + the audit log + + + token issued + the audit log + + + token issued again + the audit log + + + verified — we read your token + the audit log + + + token still published + the audit log + + + token not read this time + the audit log + + + claim given up + the audit log + + + token expired unused + the audit log + + + another account proved control and took the game over + the audit log + + + check requested + the audit log + + + give up this claim + the audit log + + + Type {word} to confirm. Nothing is deleted and you can prove control again by publishing a fresh token; the game stays claimed if anybody else owns it. + the audit log + + + Give up {game} + the audit log + + + Waiting on a token + waiting on a token, and the passkeys + + + token issued {issued}, good until {expires} + waiting on a token, and the passkeys + + + Passkeys + waiting on a token, and the passkeys + + + unnamed + waiting on a token, and the passkeys + + + added {date} + waiting on a token, and the passkeys + + + added {date} · on one device only + waiting on a token, and the passkeys + + + This passkey lives on one device. If you lose it you can still get back in by publishing a fresh token on your game, but a second passkey is quicker. + waiting on a token, and the passkeys + + + Add another passkey + waiting on a token, and the passkeys + + + No such game + claiming a game you run + + + Claim {game} + claiming a game you run + + + Claiming needs a database behind it, and this site is running on the demo fixture. + claiming a game you run + + + You need an account first. It takes a passkey and a name. + claiming a game you run + + + Sign in or create an account + claiming a game you run + + + Your games + claiming a game you run + + + {count, plural, one {This game already has an owner who proved control of the server.} other {This game already has # owners who proved control of the server.}} You can prove it too — the test is the same either way — but we need to know what you mean by it, because we cannot tell from the token. + claiming a game you run + + + I run it too — add me as an owner + claiming a game you run + + + Everyone keeps their claim. This is two people running one game. + claiming a game you run + + + I have taken it over — transfer it to me + claiming a game you run + + + {count, plural, one {When your token verifies, the existing claim is revoked and the game is yours.} other {When your token verifies, the existing claims are revoked and the game is yours.}} They will see why in their own history. Nothing is deleted, and they can prove control again the same way you are about to. + claiming a game you run + + + Verified. + claiming a game you run + + + We read your token from the game's MSSP report on {date}. + claiming a game you run + + + We read your token from the connect screen on {date}. + claiming a game you run + + + Leave the token where it is. It doubles as an identity signal, so this game stays recognisable if it moves host or changes name. Removing it will not un-claim you. + claiming a game you run + + + Publish this token anywhere the game shows it to an anonymous connection. The next probe picks it up, which proves you can write to that server. + claiming a game you run + + + This is a transfer. + claiming a game you run + + + {count, plural, one {When we read this token, the current owner's claim on this game is revoked and it becomes yours.} other {When we read this token, the current owners' claims on this game are revoked and it becomes yours.}} + claiming a game you run + + + Either of these will do + claiming a game you run + + + An MSSP variable + claiming a game you run + + + In {codebase} that is a line in {file}; every codebase with MSSP has an equivalent. + claiming a game you run + + + {aliases} are accepted too. + claiming a game you run + + + A line on the connect screen + claiming a game you run + + + Anywhere in the screen, and colour codes around it are fine. + claiming a game you run + + + Then + claiming a game you run + + + We check on the ordinary crawl schedule. This token is good until {date}. Come back any time; nothing needs writing down. + claiming a game you run + + + Look sooner + claiming a game you run + + + Brings your game to the front of the queue. We dial on our own schedule, so this is sooner rather than now. + claiming a game you run + + + Just asked. Try again in a few minutes — it is rationed because it dials a real server sooner than we would have. + claiming a game you run + + + What only you can tell us about {game} + what a claim actually grants (the owner panel, §8.5 and §11) + + + These are the things MSSP has no field for. They appear on your game's page as {declared}, with the date you last confirmed them, beside what we measured — never instead of it. Nothing measured can be edited from here, by you or by us. + what a claim actually grants (the owner panel, §8.5 and §11) + + + declared {age}. Empty this box to withdraw it — the record of what it said is kept either way. + what a claim actually grants (the owner panel, §8.5 and §11) + + + Save what you declared + what a claim actually grants (the owner panel, §8.5 and §11) + + + What {game} reports, and what you would rather we showed + what a claim actually grants (the owner panel, §8.5 and §11) + + + Your MSSP is what every crawler reads, and we go on showing it beside anything you put here — an answer of yours does not hide one of your game's. + what a claim actually grants (the owner panel, §8.5 and §11) + + + Nothing measured can be edited from here: not a player count, not a capability, not an hour of reachability. + what a claim actually grants (the owner panel, §8.5 and §11) + + + If a line below is wrong in your {file}, fixing it there fixes it everywhere. + what a claim actually grants (the owner panel, §8.5 and §11) + + + your game reports {value}, confirmed {age} + what a claim actually grants (the owner panel, §8.5 and §11) + + + your game reports nothing here + what a claim actually grants (the owner panel, §8.5 and §11) + + + Changing the name changes what {game} is listed as and the address of its page. The old address goes on working for ever — every URL this game has ever had redirects to its current one — and clearing the box hands the name back to whatever your MSSP says. + what a claim actually grants (the owner panel, §8.5 and §11) + + + Your connect screen + what a claim actually grants (the owner panel, §8.5 and §11) + + + We are not republishing it. The page says so plainly rather than leaving a hole, and the crawler goes on reading it — it is how we recognise your game if it moves. + what a claim actually grants (the owner panel, §8.5 and §11) + + + Show it again + what a claim actually grants (the owner panel, §8.5 and §11) + + + We show it because your server sends it to every anonymous connection. If you would rather we did not, say so and we stop. We will not ask why. + what a claim actually grants (the owner panel, §8.5 and §11) + + + Stop showing our connect screen + what a claim actually grants (the owner panel, §8.5 and §11) + + + Being crawled + what a claim actually grants (the owner panel, §8.5 and §11) + + + We have stopped. Nothing on {game} is dialled, and the page keeps everything measured before you asked — the empty hours name no cause, because {ourFact} is our fact and not a measurement of your game. + what a claim actually grants (the owner panel, §8.5 and §11) + + + you asked us to stop + what a claim actually grants (the owner panel, §8.5 and §11) + + + Start crawling us again + what a claim actually grants (the owner panel, §8.5 and §11) + + + This one came from your own server rather than from here — {routes}. To be crawled again, stop publishing it; we will hear that on the next cycle. + what a claim actually grants (the owner panel, §8.5 and §11) + + + your MSSP report publishes {variable} + what a claim actually grants (the owner panel, §8.5 and §11) + + + a {label} TXT record asks us to stop + what a claim actually grants (the owner panel, §8.5 and §11) + + + a request we recorded + what a claim actually grants (the owner panel, §8.5 and §11) + + + We have stopped on {stopped} and are still dialling {dialling}. That is usually a port added after the opt-out. + what a claim actually grants (the owner panel, §8.5 and §11) + + + Stop on every address too + what a claim actually grants (the owner panel, §8.5 and §11) + + + We dial {game} on a schedule and read what any anonymous connection is shown. If you would rather we did not, say so and we stop — within one cycle, on every address we have for you, and we will not ask why. + what a claim actually grants (the owner panel, §8.5 and §11) + + + Nothing already measured is deleted: your page keeps its history and its URL, and one probe after you take this back starts it again. You can also say it without us, in your own config — {mssp} in MSSP, or a {dns} TXT record — and we honour those whether or not anybody has ever claimed the game here. + what a claim actually grants (the owner panel, §8.5 and §11) + + + Stop crawling us + what a claim actually grants (the owner panel, §8.5 and §11) + + + Being listed + what a claim actually grants (the owner panel, §8.5 and §11) + + + {game} is out of the listing, out of the rankings and out of the daily figure. Its page and every URL it has ever had go on answering, and everything measured before you asked is still on it. Nothing was deleted; it is simply not somewhere a reader arrives by browsing. + what a claim actually grants (the owner panel, §8.5 and §11) + + + Put us back in the listing + what a claim actually grants (the owner panel, §8.5 and §11) + + + One probe that answers does this too. While your opt-out stands we do not dial, so nothing will — but the day you take it back, the address comes up within a week and the probe that gets an answer puts you back. You do not have to ask us twice. + what a claim actually grants (the owner panel, §8.5 and §11) + + + We have stopped dialling you, and your page is still in the listing with what we measured before that. If you would rather it were not, say so and it comes out — of the listing, the rankings and the daily figure. + what a claim actually grants (the owner panel, §8.5 and §11) + + + Nothing is deleted and nothing breaks: the page answers, every URL it has ever had still redirects to it, and anyone you send there sees it. It stops being somewhere a reader can arrive by browsing. Reversible from here, and by any probe that answers after you take your opt-out back. + what a claim actually grants (the owner panel, §8.5 and §11) + + + Take us out of the listing too + what a claim actually grants (the owner panel, §8.5 and §11) + + + what we could measure + what we could measure (the two measurement switches, spec §9) + + + Hiding these takes them out of your listing; it does not mean the game is empty. + what we could measure (the two measurement switches, spec §9) + + + could not count + what we could measure (the two measurement switches, spec §9) + + + could not reach + what we could measure (the two measurement switches, spec §9) + + + hidden from this listing + what we could measure (the two measurement switches, spec §9) + + + hidden from this listing + what we could measure (the two measurement switches, spec §9) + + + In the left column, * is a value this listing is filtered to and - is one it is filtered against. Both are choices in the query, not facts about a game. + what we could measure (the two measurement switches, spec §9) + + diff --git a/src/MUI.Web/Resources/Messages.zh-Hans.resx b/src/MUI.Web/Resources/Messages.zh-Hans.resx new file mode 100644 index 0000000..a303b42 --- /dev/null +++ b/src/MUI.Web/Resources/Messages.zh-Hans.resx @@ -0,0 +1,2877 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + {count, plural, other {#个游戏}} + + + {value},{count, plural, other {#个游戏}},仅此项 + + + {value},{count, plural, other {#个游戏}},已排除 + + + {value},{count, plural, other {#个游戏}} + + + 任意{facet},{count, plural, other {#个游戏}} + + + {count, plural, =0 {此处未列出任何游戏。} other {#个游戏,每项事实都标明它的来源。}} + + + {days, plural, other {#天}}实测 · {probes, plural, other {#次探测}} + + + {days, plural, other {#天}} · {count, plural, other {#次计数}} + + + {disagreeing, plural, =0 {全部{total}项均一致。} other {在{total}项中,有#项与游戏自述不符。}} + + + {count, plural, =0 {没有游戏符合全部回答。} other {有#个游戏符合全部回答。}} + + + 在{listed, plural, other {#个已列出的游戏}}中 · {answers, plural, =0 {未作回答} other {已回答#项}} + + + {count, plural, other {显示这#个游戏}} + + + 去掉“{answer}”——{count, plural, other {#个游戏}} + + + 清除该问题的回答:{question} + + + 查找游戏 + + + 符合全部回答 + + + {count, plural, other {游戏}} + + + 清除全部回答 + + + 重新开始 + + + {count, plural, other {另有#项}} + + + 已回答 + + + 全部列表 + + + 该查询已被拒绝 + + + 名称(若有) + + + 名称或部分名称 + + + 按名称搜索 + + + 现在有人在玩吗? + + + 你想玩什么? + + + 哪种游戏? + + + 使用哪种语言? + + + 你的客户端有什么需求吗? + + + 是否包含已失联的游戏? + + + 不限 + + + 任意题材 + + + 任意类型 + + + 任意语言 + + + 不限 + + + 否,仅未失联的游戏 + + + 是,也一并显示 + + + 已失联的游戏 + + + TLS——已加密,握手由我们完成 + + + MSSP——服务器自述 + + + MCCP——输出压缩 + + + MXP——可点击链接 + + + GMCP——结构化客户端数据 + + + MSDP——结构化客户端数据 + + + CHARSET——编码协商 + + + UTF-8——非拉丁文本可正常显示 + + + TTYPE——客户端自述类型 + + + ATCP——结构化客户端数据 + + + MSP——声音触发 + + + EOR——提示符标记 + + + {token}——握手中实测 + + + 实测 + + + 实测 + + + 实测 + + + 实测 + + + 实测 + + + 自述 + + + 自述 + + + 自述 + + + 自述 + + + 推算 + + + 推算 + + + 未实测 + + + 未能计数 + + + 无法连通 + + + 未计数 + + + 无计数 + + + 自本站 + + + 已归档 + + + 已认领 + + + 没有可挑的游戏 + + + 没有游戏符合该筛选。试试{listing},或者{archive}。 + + + 全部列表 + + + 把归档也算上 + + + 连接数 + + + 无人认领 + + + 已由所有者认领 + + + 仍在探测 + + + 典型值 + + + 峰值 + + + 以文本形式阅读 + + + 纯文本 + + + 跳至主要内容 + + + ASCII 横幅:{game} 的连接画面。 + + + 目录 + + + 本站与账户 + + + 浏览 + + + 了解 + + + 本站 + + + 菜单 + + + 游戏 + + + 查找 + + + 随机 + + + 归档 + + + 参考 + + + 生态 + + + 排行 + + + 关于 + + + 提交 + + + 提交游戏 + + + 登录 + + + 我的游戏 + + + 主题 + + + 自动 + + + 浅色 + + + 深色 + + + 演示数据。 + + + 未配置数据库,因此这是一份样例。此处没有任何内容经过实测。 + + + 全部游戏 + + + 归档 + + + 游戏自述 + + + 变更记录 + + + MU* 爱好者的游戏目录 + + + 每一项事实都标明它的来源与时间:由我们的爬虫实测,或由游戏自述并明确标出。 + + + 按名称、主题、代码库或主机搜索游戏 + + + 按名称、主题、代码库或主机搜索 + + + 搜索 + + + 已知游戏 + + + 当前连接数 + + + 有应答,未能计数 + + + 已归档 + + + 新发现 + + + 已失联——仍在探测 + + + 已恢复 + + + 没有新发现。 + + + 没有游戏失联。 + + + 没有游戏恢复。我们仍会继续叩门。 + + + 实时 + + + 游戏 + + + 按{order}排序 + + + 随机 + + + 连接数 · 连通 + + + 自本站 + + + 没有匹配结果。 + + + 试试减少关键词,或去掉一个筛选条件。 + + + 清除筛选 + + + 关于 {codebase} + + + 从未 + + + 已由所有者认领 + + + 未知代码库 + + + 我们无法识别该游戏运行的代码库 + + + 另有{count, plural, other {#项}}:{names} + + + 排序 + + + 时间窗 + + + 当前 + + + 典型值 + + + 峰值 + + + 名称 + + + 连通 + + + 7天 + + + 30天 + + + 90天 + + + 搜索游戏 + + + 搜索游戏 + + + 筛选 + + + 当前显示 + + + 全部清除 + + + ——取消此项筛选 + + + 任意 + + + 更多筛选({count}) + + + {count, plural, other {另有#项}} + + + 同时显示 + + + 默认关闭。两者都不是对游戏的评判。 + + + 已归档 + + + 成人 + + + 已归档游戏,{shown, select, true {显示} other {隐藏}} + + + 自述含成人内容的游戏,{shown, select, true {显示} other {隐藏}} + + + 计数均为我们实测到的游戏,绝非估算。 + + + 徽标与留空的含义 + + + 留空是我们测量中的缺口,而不是否定。每个分面各有说法:未识别、未自述、无协商结果。 + + + 实测到的零是一个计数。未知的计数不是零,排序时也绝不按零处理。 + + + 开放式分面只列出最常见的 {count} 个值。其余的可通过搜索和 URL 访问。 + + + 未勾选表示未实测,而不是游戏不具备该项。 + + + 活跃度 + + + 最近可见 + + + 提供的协议 + + + 加密 + + + 编码 + + + 代码库 + + + 版本 + + + 谱系 + + + 家族 + + + 题材 + + + 语言 + + + 当前有连接 + + + 本周活跃 + + + 冷清——没有高于 0 的计数 + + + 失联——一个月未连通 + + + 已归档 + + + 24 小时内 + + + 7 天内 + + + 30 天内 + + + 更早 + + + 从未连通 + + + 无协商结果 + + + 未识别 + + + 未自述 + + + 已通过 TLS 连接 + + + 非{value} + + + 有协商结果 + + + 已识别 + + + 有自述 + + + 我们亲眼观测到 + + + 游戏这样说,我们未加核实 + + + 我们对游戏所述内容做了归类 + + + 名称 + + + 当前连接数 + + + 最近连通 + + + 典型连接数 · 7天 + + + 典型连接数 · 30天 + + + 典型连接数 · 90天 + + + 同时最多连接 · 7天 + + + 同时最多连接 · 30天 + + + 同时最多连接 · 90天 + + + 当前行内 + + + 典型值 + + + 峰值 + + + 未知计数 + + + 从未连通过——并非只是很久未连通 + + + 窗口内计数少于 {minimum} 次,或完全没有——并非典型连接数为零 + + + 窗口内没有任何我们能计到的数——并非无人连接的游戏 + + + 中位 {value} · {days, plural, other {#天}} · {count, plural, other {#次计数}} + + + 同时最多 {value} · {days, plural, other {#天}} · {count, plural, other {#次计数}} + + + 连接画面 + + + 按小时的连接数 + + + 人数随时间变化 + + + 可连通 + + + 变更记录 + + + 能力 + + + 游戏自述 + + + 转介 + + + 无人认领——此处的一切均为实测。 + + + 已由所有者认领——下方的实测事实仍出自我们。 + + + 认领此游戏 + + + 自 {date} 起有应答 + + + 以文本形式阅读——{count, plural, other {#行}} + + + 能力 + + + 距今 + + + 已提供 + + + 静默 + + + 缺失 + + + 已拒绝 + + + 自述有 + + + 不一致 + + + 不一致之处({count}) + + + {day} {time}——{count, plural, =0 {0人连接,实测} other {平均#人连接}} + + + {day} {time}——已探测,未能读取计数 + + + {day} {time}——该小时未实测 + + + 我们尚未实测该游戏的活跃度。 + + + 一周中没有任何一个小时得出过连接人数。 + + + 每个小时都已实测,其中没有任何一个小时有人连接。 + + + {count, plural, other {{day}有#小时尚未实测。}} + + + {count, plural, other {一周中有#小时尚未实测。}} + + + {count, plural, other {{day}有#小时有应答,但未能计数。}} + + + {count, plural, other {一周中有#小时有应答,但未能计数。}} + + + 每天最热闹,{window}。 + + + 每天{part}最热闹,{window}。 + + + {days}最热闹,{window}。 + + + {days}{part}最热闹,{window}。 + + + {who}一贯冷清,{window}。 + + + {who}的{part}一贯冷清,{window}。 + + + 每天 + + + 我们实测到的每一天 + + + 工作日 + + + {days} + + + 上午 + + + 下午 + + + 晚上 + + + 凌晨 + + + 上午 + + + 下午 + + + 晚上 + + + 凌晨 + + + {list}、{next} + + + {first}和{second} + + + 实测次数尚不足 + + + 一周中尚无任何一个小时有过实测。 + + + {count, plural, other {#小时有应答,但未能计数。}} + + + 待一周七天每天都有实测后,网格才会显示。 + + + {days, plural, other {七天中已实测#天;待每天都有实测小时后,网格才会显示。}} + + + {count, plural, other {已实测#小时,其中始终无人连接。}} + + + {count, plural, other {已实测#小时,最热闹的是{day} {time} UTC,{peak}人连接。}} + + + {count, plural, other {另有#小时有应答,但未能计数。}} + + + {day}——{facts} + + + {first},{second} + + + 全天实测为零 + + + {time}峰值{count}人 + + + {window}无人连接 + + + 各小时均无计数 + + + {count, plural, other {#小时未实测}} + + + {count, plural, other {#小时已探测但未能计数}} + + + 星期 + + + 最冷清 + + + 最热闹 + + + 时间 + + + 无计数 + + + 按天显示的连接人数,时间为 UTC。{window}。 + + + 时间为 UTC · {window} + + + {weeks, plural, other {#周滚动平均}} + + + 已计数,含实测为零 + + + 已探测,未能读取计数 + + + 该小时未实测 + + + 有人连接的时段(UTC) + + + 已计数 + + + 我们连上了并读到了数字,包括实测为零 + + + 我们连上了,但读不到数字 + + + 我们没有该小时的实测数据 + + + 语言 + + + 切换语言 + + + 关于 mu*index + + + 这里的每一个游戏,都由一台连入其中的机器实测得出,每一项数值都标明它的来源与时间。本页说明这能证明什么、我们哪里会出错、我们读了谁的目录,以及如何让爬虫停下。 + + + 本站所说的事实是什么 + + + 实测优先于自述,两者都会呈现。 + + + 游戏的 MSSP 报告是它对自己的描述,telnet 握手则是我们亲眼看到它做了什么。两者都会出现在它的页面上,并标注来源与时间。两者不一致时,我们把这份不一致本身呈现出来。 + + + 每一项人数计数都标明它从何而来。 + + + 要么是我们在连接画面上读到并数出的 WHO 或 DOING,要么是游戏自己发布的 MSSP PLAYERS 字段。二者从不合并。 + + + 读不出来的回应是未知,绝不是零。 + + + 服务器可以随意定制自己的 WHO 表头,超过一定程度,我们的解析器就读不出来了。那是无法计数,自成一种状态。而实测到的零——我们连上了,里面没有人——是一个计数,也按计数显示。 + + + 只说可连通,从不说在线率。 + + + 我们从一台主机上按间隔打开一个套接字。路由不到的游戏是无法连通,而它完全可能好好活着。本站不宣称任何游戏的在线率,因为本站没有测过它。 + + + 一个小时要么已计数,要么无法计数,要么未实测。 + + + 活跃度网格有三种状态。第三种是空白,并且不指明原因:我们连不上的那个小时,和我们从未探测过的那个小时,是同一种缺失,两者都不是那台服务器的停机时间。 + + + 我们知道自己哪里会出错 + + + 归档宽限期从我们发现你的那天算起。 + + + 游戏停止应答后,会在宽限期结束时离开默认列表:宽限期是我们探测到的可连通时长的四分之一,下限 60 天,上限 365 天。一个从 1995 年就在运行的游戏,在我们发现它的那天也只从下限起算。我们不会导入任何数据,去填补我们到来之前的那些年。 + + + MSSP CREATED 不计入这段宽限期。 + + + 它只是配置文件里手打的一行,若予采信,归档门槛就会变得可以操纵。它以自述的身份显示,换不来任何东西。 + + + 认领游戏可以拿到上限。 + + + 只要证明拥有服务器访问权,无论我们观察了多久,都值整整一年的宽限期。 + + + 本站的一切,都出自一台主机的间隔观测。 + + + 可连通时间的百分比,是我们观测过的那段窗口内的比例,绝不是我们没观测过的窗口的比例。本站的任何图形都不会替其余部分补上数据。 + + + 任何内容都不会被删除。 + + + 归档只是把游戏移出默认列表、排行和今日活跃数字,仅此而已。它的页面、URL、历史和地址都保留,我们仍会继续探测,一次成功的探测就能让它回来。 + + + 本站不会做的事 + + + 没有投票、星级、评分或推荐。 + + + 排行仅由实测数据算出。一个按谁能动员最多点击来排名的目录,描述的是拉票活动,而不是这个爱好本身——前辈们正是这样倒下的。 + + + 没有论坛、评测、维基、评论或玩家资料页。 + + + 入门资料——什么是 MUSH、哪种代码库适合协作扮演——和本站其余内容一样,由人撰写、署名,并纳入版本管理。 + + + 玩家名称从不留存。 + + + WHO 的回复只在内存中解析,取出一个计数和表头的形状。名称不会被记录下来;汇总使用加盐哈希,盐值会轮换。 + + + 不发布任何绝对人口数字。 + + + 我们发布按代码库和按协议的占比:在实测集合上算出的比例,即使还有我们数不出来的游戏也依然成立。“有多少人在玩 MU*”则不然,因为那个数字一经引用就站不住脚。 + + + 爬虫,以及如何让它停下 + + + 一次探测就是一个从不登录的连接。 + + + 它打开一个套接字,协商 telnet 选项,读取连接画面,通过协商选项 70 索取 MSSP,发送 {commands},然后断开。不建角色,不登录,对端没有任何改动。会话有超时上限,卡住的探测不会一直占着一个连接位。 + + + CRAWL DELAY 说了算。 + + + 游戏若在 MSSP 报告中给出希望的最小间隔,我们就照办,并在两个方向上都优先于我们自己的排期:720 小时就是每月一次,而不是每周一次。失联的游戏仍会按这个更长的间隔被一直尝试下去,它恢复之后,正是这样重新回到列表的。 + + + 转介来的地址只做核验,从不轻信。 + + + MSSP 允许一个游戏点名其他游戏。任何拨号之前,每个名称都先做解析;除非它解析出的每一个地址都是全球可路由的,否则一律拒绝。解析结果混杂时,整个目标都被拒绝。这种拒绝记在我们自己名下,绝不会作为停机出现在某个游戏的记录里。 + + + 展示连接画面,是因为它本来就发给所有人。 + + + 服务器会向每一个匿名连接、无需认证地绘出自己的连接画面。我们把它作为证据展示,并加以标注。你若开口,我们就撤下。 + + + 说停我们就停——有三种方式。 + + + 在你的 MSSP 报告中发布 {variable} 1,读到它的那次探测就是最后一次。或者在 {label}.your.host 上发布一条内容为“{value}”的 TXT 记录,这既不需要支持 MSSP,也不需要在本站注册账户。再或者,写信给一个人。这三种方式都会在一个爬取周期之内生效,并连同日期和我们读到的内容一起记录,提交表单上同样照此执行。 + + + MSSP 字段停的是那个监听端口,TXT 记录停的是整台主机。 + + + MSSP 由应答的那个端口发布,因此它只代表那个端口——MU* 托管商常常在同一个域名下运行互不相关的游戏,谁也不该替邻居闭嘴。TXT 记录默认覆盖所有端口,除非它指名某一个,写作“{value}=4201”。凡是我们读不出端口清单的写法,都按整台主机处理,所以“{value}=all”也是有效的。 + + + DNS 这条路,你不用问我们就能撤销。 + + + 读一条 TXT 记录,不必连上那台叫我们别来的服务器,所以我们在每次拨号前都会重新读它。你把它删掉,一周之内我们就会再次拨号。而 MSSP 字段不做你叫我们别做的那件事就无法重读,所以通过 MSSP 提出的退出和书面请求会一直有效,直到你另行告知。已退出的地址只会得到这一次 TXT 查询:它触及的是你的域名服务器,绝不会触及你的游戏。 + + + 停下不是删除,也不是停机。 + + + 选择退出的游戏保留它的页面、它的地址,以及它开口之前我们实测到的一切。停下的只有新数据:活跃度网格不再增加小时,并且不指明原因,因为不再叩门是我们的决定,那是关于我们的事实。它记录在那次没有发生的爬取上,也记录在提出请求者的名册里。 + + + 如果只是停下还不够,列表条目也可以撤下。 + + + 当我们在你游戏应答的每一个地址上都已停手之后,你的控制台会多出一项:把它移出列表、排行和每日数字。页面和它曾用过的每一个地址仍然照常应答,任何内容都不会被删除——它只是不再是读者靠浏览就能走到的地方。这需要一次经过验证的认领,因为这是关于你游戏的决定,我们会记录是谁做的。而一次探测就能撤销它:收回你的退出声明,下一次拨号只要得到应答,你就会重新回到列表,不必再来问我们第二次。 + + + 服务器询问来者何人时,爬虫自报为 {name}。 + + + 爬虫已配置为自称 {name},但目前还说不出口。它使用的 telnet 库没有给客户端设置终端类型的途径,所以你的日志里看到的是那个库的默认值,而 NEW-ENVIRON 是用爬虫主机的环境作答的。这两处都是那个库的缺口,该由我们去那里修补。在此之前,请靠形态辨认一次探测:一个连接,不登录,一组简短的只读命令,然后就走。 + + + 爬虫 + + + 联系方式 + + + 爬虫:{name} + + + 联系方式:{url} + + + ——占位值;本部署未设置联系地址 + + + 未配置联系地址,因此上面那个只是占位值,不会有人回应。 + + + 游戏名单从何而来 + + + 我们只取地址,别的都不取。 + + + 一次回填只取一个主机和一个端口。不取人数计数,不取连通历史,不取简介,不取字段,也不记录某个地址来自哪个站点。 + + + 刻意少于那些站点能给的。 + + + 其中有几家存着多年的带日期人数计数。把它们导进来,会用别人早已在观测的游戏去填满热力图,也会把本站最核心的主张,架在别人的探测器上。 + + + 一个游戏的出处不是单一事实。 + + + 凡是值得列出的游戏,都会同时出现在其中好几个目录里,所以“导入自”只会写上先跑完的那次抓取。某个游戏存在,这是公开信息;我们从哪里读到的,既不增添任何东西,也是别人劳动成果里最没理由算作我们的那一部分。 + + + 读别人的站点,终究是在读别人的站点。 + + + 抓取之前,我们先索要批量导出或有文档的接口,先读 robots.txt,并对抓取严格限速。凡是需要维护者点头的来源,在有人能声明已经征询过之前,一律不抓。 + + + 已读取——仅地址 + + + 未读取——等待许可 + + + 一个页面,一次请求。由一个逐个连入游戏、并打印所读内容的爬虫发布。 + + + 同一个爬虫的 MSDP 列表。几乎是其 MSSP 版本的子集,读取它只为那少数几个另一份没有覆盖到的地址。 + + + 在一个页面上发布整份目录,因此读一次只花一个请求。它是我们最大的地址来源,而不是任何实测数据的来源。 + + + 一个索引页,外加每个世界一个页面,所以只能抓取,而不能导出。2026 年 7 月 30 日,我们以十五秒的间隔、遵守 robots.txt 抓取了他们的 143 个页面,但当时还没有人写信联系过他们。这本不该发生。现在这道闸门需要有人愿意声明已经征询过维护者才会打开。 + + + 已实现,已测试,从未运行。除许可这一项之外,它在各方面都是这里最强的来源;在有人写信联系他们之前,我们不会抓取任何内容。 + + + 许可协议 + + + 代码采用 MIT 许可协议。 + + + 本站、爬虫和各个解析器,均在 MIT 许可协议下开源。 + + + 数据的许可协议尚无定论。 + + + 这是与代码分开的一项决定,目前尚未作出。请把下面的条款当作本部署当前的答复,而不是本项目已定的立场。竞争的目录把整份名录拿走,在这里算作一项成功,所以无论最终如何定夺,都不会挡它的路。 + + + 代码 + + + 数据,按本部署提供的形式 + + + 署名为 + + + 代码:{licence} + + + 数据:{licence} + + + 署名为:{credit} + + + (这是本部署提供的形式。本项目自己的答复仍未定。) + + + 提交游戏 + + + 告诉我们某个游戏在哪里。一个主机加一个端口就是整张表单;本站其余的一切,都由我们自己的爬虫实测得出。 + + + 主机 + + + 端口 + + + mud.example.org,或者粘贴 mud.example.org:4201 并把端口留空 + + + 提交 + + + 提交需要数据库,而本站正运行在演示数据上。没有可写入的爬取登记表,所以这里干脆不放表单,而不是放一个悄悄什么也不做的表单。 + + + 此处没有 + + + 一个地址会经历什么 + + + 拨号之前我们先解析地址,凡是解析到公网之外的一律拒绝。那是关于我们自己套接字的决定,绝不是关于某个游戏的事实。 + + + 如果运行那台主机的人已经要求我们不要爬取,那么无论谁提交,我们都不会接受这个地址。陌生人无法把你的游戏重新放上本站。 + + + 如果它有应答,我们就读取服务器对自己的说法,并按它自己的节奏一直读下去。一个地址只需要给我们一次。 + + + 在有人证明自己是运营者之前,站上不会出现任何内容。认领需要一个通行密钥,以及在游戏本身上发布的一行文字。 + + + 我们已经有的地址会并入现有条目。重复提交不会产生第二个条目,也不会让探测提前。 + + + 那个地址 + + + 已进入登记表。 + + + {address} 会在下一个爬取周期被拨号,此后一直按它自己的节奏进行。等有人证明自己是运营者,它就会出现在这里——带着同一个地址回到这张表单,它会把链接交给你。 + + + 我们有它,尚无人认领。 + + + {address} 是我们已经在实测的地址。在有人证明自己是运营者之前,它不会出现在站上。如果那个人就是你,这里就是入口。 + + + 这个我们已经有了。 + + + {address} 是我们已经在实测的游戏。没有创建任何内容,也没有改动任何内容。 + + + 这个地址我们已经有了。 + + + {address} 我们已经知道了。没有创建任何内容,也没有改动任何内容。 + + + 已经在等了。 + + + {address} 已在爬取登记表中,尚未应答。再次提交不会让它提前:每个目标都按自己的节奏来,所以谁也不能借我们去催别人的服务器。 + + + 这不是我们能拨号的地址。 + + + 主机名里需要有一个点或一个冒号,端口是 1 到 65535 之间的数字。请把两个框都填上,或者把 mud.example.org:4201 粘贴到第一个框里。 + + + 我们无法拨号到那里。 + + + 对 {address} 给出这个答复有三种可能:名称可能解析不了,可能解析到公网之外,也可能运行那台主机的人已经要求我们别去。我们刻意不说是哪一种,因为向陌生人回答这个问题,等于从外部把一张网络画出来。关于该地址没有记录任何内容;这个决定是我们做的,也记在我们自己名下。 + + + 暂时够了。 + + + 这张表单按提交者限速,你已经到上限了。请一小时后再来。没有丢失任何东西——我们收下的内容都已经在登记表里。 + + + 认领此游戏 + + + 登录 + + + 用通行密钥登录,即可认领你运营的游戏。没有密码可丢,也没有密码可偷。 + + + 认领需要数据库,而本站正运行在演示数据上。这里没有可登录的东西。 + + + 登录就是一个通行密钥。 + + + 私钥保存在你的设备或密码管理器里;我们只保存公钥那一半。没有密码,没有电子邮箱。 + + + 用通行密钥登录 + + + 本站唯一需要 JavaScript 的页面。没有它,通行密钥无法工作。 + + + 还没有账户? + + + 只有在认领你运营的游戏时才需要账户。取一个对外显示的名字——它是认领旁边的一个标签,不是真名。 + + + 名称 + + + 例如 corvid-admin + + + 用通行密钥创建账户 + + + 我们存什么 + + + 你选择的名称。 + + + 你注册的每个通行密钥的公钥,以及你的设备给它取的名字。 + + + 你认领了哪些游戏,以及认领的时间。 + + + 不存电子邮箱,不存密码,也不把 IP 日志与你的账户关联。即使丢失了全部通行密钥,你也可以在自己的游戏上发布一个新的认领令牌,重新来过:凭据是游戏,不是账户。 + + + {year}年{month}{day}日 + + + {date} {time} UTC + + + 刚刚 + + + {count, plural, other {#分钟}} + + + {count, plural, other {#小时}} + + + {count, plural, other {#天}} + + + {count, plural, other {#周}} + + + {count, plural, other {#个月}} + + + {count, plural, other {#年}} + + + 刚刚 + + + {count, plural, other {#分钟前}} + + + {count, plural, other {#小时前}} + + + {count, plural, other {#天前}} + + + {count, plural, other {#周前}} + + + {count, plural, other {#个月前}} + + + {count, plural, other {#年前}} + + + 刚刚 + + + {count, plural, other {#分钟前}} + + + {count, plural, other {#小时前}} + + + {count, plural, other {#天前}} + + + {count, plural, other {#周前}} + + + {count, plural, other {#个月前}} + + + {count, plural, other {#年前}} + + + {age},{stamp} + + + ,{stamp} + + + {value}——经{source}{how},最近确认于 {date} + + + {value}——经{source}{how},最近确认于 {date}(已过预期刷新期) + + + ({how},{age}) + + + ({how},{age},已过刷新期) + + + 所有者自述 + + + 本站工作人员 + + + telnet 握手 + + + 游戏所有者 + + + WHO + + + I3 + + + MSSP + + + INFO + + + I3 的游戏列表 + + + 连接画面 + + + 生态 + + + 只讲占比,绝不讲总量。我们不发布有多少人在玩 MU* 的数字:在我们实测过的游戏上算出的比例,即使还有连不上的游戏也依然成立,人头总数则不然。 + + + {count, plural, other {已列出的游戏 {value} 个}} + + + {count, plural, other {已完成握手的游戏 {value} 个}} + + + {count, plural, other {已持有 MSSP 报告的游戏 {value} 个}} + + + 此处最早的握手:{age}前确认。 + + + {total, number} 个中的 {count, number}({fraction, number, ::percent .0}) + + + {total, number} 个中的 {count, number}——尚无实测 + + + {fraction, number, ::percent .0} + + + 代码库 + + + 在已列出的{listed, plural, other {#个游戏}}中,有 {identified, number} 个告诉了我们它们运行的是什么,下面每一项占比都是相对这 {identified, number} 个算出来的。读不出代码库的游戏被排除在分母之外,绝不会被算作别的东西。 + + + 还没有已列出的游戏告诉过我们它的代码库。 + + + {share} 运行的代码库,没有任何其他已列出的游戏在用——每种各一个游戏,那是一个名字,而不是一份占比。它们计入上面的分母,只是从条形图中折叠起来,并没有被丢弃: + + + 谱系 + + + 还是这些游戏,按其服务器所承袭的传统分组——这是我们对代码库的判读,而不是任何游戏发布过的东西。没有游戏会报告“MUSH”:MSSP 里没有这个取值,而 MUSH 世界的大多数根本不发布 MSSP,所以只有这样,这个问题才问得出来。 + + + 还没有已列出的游戏运行我们能归入某个谱系的代码库。 + + + {count, plural, other {其中#个游戏运行的代码库}}我们没有归入任何谱系——有几个自己也这么说,它们发布的是 {family}。它们计入上面的分母,但不属于任何一份占比。 + + + 协议 + + + 下面每一个实测数字都应读作下限。MSSP 是我们指名索取的,所以在这一项上,沉默本身就是一种回答。其余各项我们都不索取,服务器可能支持某个协议,却从不主动提供。 + + + {instrument} 是下面唯一不算下限的一行:我们向每台服务器指名索取它,所以没有提供它的游戏,是被问过之后拒绝的。它也是唯一没有自述数字的一项,因为凡是我们持有其报告的游戏,都已用行动证明支持它,再去数其中有多少同时把它列了出来,量到的只是一种习惯。 + + + 我们持有 {reports, number} 份报告,而今天提供 MSSP 的有{offered, plural, other {#个游戏}}:另外 {gap, number} 个在我们读过之后就不再发布了,一份报告不会因为不再重发而被丢弃。 + + + 协议采用情况。实测是服务器在一次完成的握手中提供的内容,自述是它的 MSSP 所声称的内容。两组不同的游戏,因此有两个分母。 + + + 协议 + + + 实测——占 {basis} + + + 自述——占 {basis} + + + 未实测——从未观测到 + + + {share} · {declined, plural, other {#个游戏}}被问及时拒绝 + + + {share} · {unobserved, plural, other {#个游戏}}既未提供也未被问及 + + + {share} · {declined, plural, other {#个游戏}}被问及时拒绝 · {unobserved, plural, other {#个游戏}}既未提供也未被问及 + + + 未索取——这里的每一份报告本身就是回答 + + + 采用情况随时间的变化 + + + 每个点都是当天我们已实测的那些游戏上的占比,所以这条线会因两件事而移动:某个游戏改变了它提供的内容,以及我们能实测的游戏集合在它周围发生了变化。只有前者才是采用。下面的变更次数,才是纯粹由游戏改变主意构成的那一部分。 + + + 各协议的实测占比,最早的读数在前 + + + 当时 + + + 现在 + + + 未实测 + + + 这是快照,不是曲线 + + + 这是我们现在能实测到的情况的一张快照。采用曲线画的是游戏改变主意,而我们在变化发生时才记录它,所以等记录得足够多,曲线才画得出来。若改用我们首次连通各个游戏的时间来作图,量到的将是这次爬取,而不是这个爱好。 + + + {count, plural, other {迄今已记录#次能力变更}}——曲线正是用这些材料画出来的。 + + + 还没有任何实测能力发生过变化,因此无从作图。 + + + 实测的分母是 {measured},自述的分母是 {declared}。两组不同的游戏,因此有两个分母。 + + + {listed} · {handshakes} · {mssp}。 + + + 这张图里最早的握手,最近一次确认是在 {age}前。 + + + 还是这些游戏,按其服务器所承袭的传统分组。这是{evidence}——{meaning}——而不是任何游戏发布过的东西:没有游戏会报告“MUSH”,因为 MSSP 里没有这个取值,而 MUSH 世界的大多数根本不发布 MSSP。 + + + 实测:{value} + + + 自述:{value} + + + 排行 + + + 仅由实测数据算出。永远没有投票、星级或评分。这里没有任何东西是对质量的排名。我们没有测过质量。 + + + 最热闹——按实测的同时连接人数 + + + 排行时间窗 + + + {days, plural, other {#天}} + + + 最近{days, plural, other {#天}}内我们实测到的人数的中位数。 + + + 还没有游戏在{days, plural, other {#天}}内取得中位数所需的 {samples, number} 个样本。 + + + {listed, number} 个已列出的游戏中,有{eligible, plural, other {#个}}在{days, plural, other {#天}}内取得了所需的 {samples, number} 个样本。 + + + 实测到的零算数;读不出来的计数不算。 + + + 一周说的是现在谁热闹,一个季度说的是一直以来谁热闹。这是两个不同的问题,一个游戏可能在其中一项领先,而在另一项不然。天数按整天计,时间为 UTC。 + + + 还没有已列出的游戏拥有足以参与排行的已计数样本——这说的是我们测了多久,而不是谁有多热闹。 + + + 按最近{days, plural, other {#天}}内我们实测到的人数的中位数为游戏排名。中位数相同的游戏并列同一名次,这里不做任何附加排序来分先后。 + + + # + + + 游戏 + + + 中位数 + + + 峰值 + + + 已计数样本 + + + 实测天数 + + + 连续可连通时间最长 + + + 自所给日期起的每一次探测,都发现该游戏可连通。是可连通,不是在线:我们从一台主机上测一个套接字,路由不到的游戏完全可能好好活着。一段连续时长不可能超过我们观察的时长,所以日期才是事实,时长由它推出。 + + + 目前没有已列出的游戏处在连续可连通的时段中。 + + + 自所给日期起每一次探测都可连通的游戏。自同一日期起可连通的游戏并列同一名次,这里不做任何附加排序来分先后。 + + + 可连通起始 + + + 即 + + + 已归档的游戏只是不在这两张表里,仅此而已;一次成功的探测就能让它们回来。 + + + 最热闹——最近{days, plural, other {#天}}实测人数中位数 + + + 时间窗: + + + 当前这个 + + + 中位数 {median, number} · 峰值 {peak, number} · {window, number} 天中的 {days, number} 天里共有{samples, plural, other {#个已计数样本}} + + + 自 {date} 起每次探测均可连通 · {duration} + + + 归档 + + + 已经停止应答的游戏。没有删除任何内容。我们仍每周探测,一次成功的探测当天就能把游戏放回列表。 + + + 搜索归档 + + + 搜索已归档的游戏 + + + 名称、代码库或简介 + + + 显示 + + + {count, plural, =0 {没有已归档的游戏} other {#个已归档的游戏}} + + + 已归档 + + + 最近连通 + + + 已知存活 + + + ({age}前) + + + 没有匹配结果。 + + + 在我们实测过的范围内从未连通 + + + 未实测到可连通时长 + + + 未知 + + + {years, number, ::.#} 年 + + + {days, plural, other {#天}} + + + {from} – {to} · {span} + + + {count, plural, other {#个月}} + + + {count, plural, other {#年}} + + + {count, plural, other {有#个游戏}}匹配“{query}” + + + {count, plural, other {#个游戏}} + + + 最近连通: + + + 已知存活: + + + 实测可连通时长 {value} + + + 运行期: + + + 代码库: + + + 随机游戏 + + + {d, date, medium}——平均 {typical},{probes, plural, other {#次探测}}中介于 {low}–{high} + + + {d, date, medium}——{count, plural, =0 {0 人} other {#人}},{probes, plural, other {#次探测}}次次如此 + + + {d, date, medium}——已探测,未能读取计数 + + + {d, date, medium}——无实测 + + + 在{days, plural, other {#天}}中的 {counted} 天里,典型有 {typical} 人在线,峰值 {peak}。 + + + 整个区间内保持平稳。 + + + 从区间开始到结束,约上升 {change, number, percent}。 + + + 从区间开始到结束,约下降 {change, number, percent}。 + + + 本区间内有过探测,其中任何一次都未能读出在线人数。 + + + 本区间内没有实测。 + + + 本区间内没有计到任何数。 + + + {from, date, d MMM}–{to, date, d MMM} + + + {d, date, d MMM} + + + {span}:典型 {typical},峰值 {peak},已计数{days, plural, other {#天}} + + + {span}:已探测,未能读取计数 + + + {span}:未实测 + + + {count, plural, other {#天已探测但没有计数}} + + + {count, plural, other {#天未实测}} + + + {line},{clause} + + + 按 UTC 计日 · {from, date, medium} – {to, date, medium} + + + 顶端为 {value} + + + {days, plural, other {#天}}中已计数 {counted} 天 + + + {d, date, MMM} + + + {d, date, MMM yyyy} + + + 当天我们读到的各次计数的平均值 + + + 直到当天最热闹的那次计数 + + + 当天最低到最高的计数 + + + 已探测,未能读取计数 + + + 未实测——完全没有条形 + + + 未实测——线条断开一段 + + + 趋势区间 + + + 趋势图形 + + + {days, plural, other {#天}} + + + ← 更早 + + + 更近 → + + + 折线 + + + 条形 + + + 人数随时间变化 + + + {from, date, medium} – {to, date, medium},UTC + + + 更早 + + + 一周的概况按其中我们计到数的那些天汇总;一天也没有的那一周会明确说明 + + + 可连通 · 最近{days, plural, other {#天}} + + + 可连通 + + + 最长中断 + + + 最近原因 + + + 窗口内没有 + + + 未记录 + + + {days, plural, other {#天前}} + + + 今天 + + + 可连通 + + + 降级——有应答,未能完成 + + + 无法连通 + + + 未实测 + + + 可连通 + + + 降级 + + + 无法连通 + + + 未实测 + + + {d, date, d MMM}——全天可连通 + + + {d, date, d MMM}——降级({cause}):有应答,未能完成 + + + {d, date, d MMM}——无法连通({cause}) + + + {d, date, d MMM}——未实测;当时我们还没有开始观测这个游戏 + + + {days, plural, other {最近#天尚未实测。}} + + + 最近{days, plural, other {#天}}中,有 {percent} 的时间可连通。 + + + 在我们已实测的{days, plural, other {#天}}中,有 {percent} 的时间可连通。 + + + {days, plural, other {最近#天的可连通情况尚未实测。}} + + + 窗口内没有哪一天是无法连通的。 + + + 我们实测过的日子里,没有哪一天是无法连通的。 + + + {count, plural, other {#天无法连通。}} + + + {count, plural, other {#天降级——我们连上了,但未能完成。}} + + + 最长中断 {duration}。 + + + 最长中断 {duration}({cause})。 + + + {count, plural, other {有#天早于我们的任何实测。}} + + + {from, date, d MMM} – {to, date, d MMM} + + + {d, date, d MMM} + + + {range}:{word}{count, plural, other {#天}} + + + {range}:{word}{count, plural, other {#天}}({cause}) + + + 可连通 + + + {days, plural, other {最近#天}} + + + 可连通:{days, plural, other {最近#天}}中的 {percent} + + + 最长中断:{duration} + + + dns 未能解析 + + + 连接被拒绝 + + + tls 失败 + + + 超时 + + + 握手停滞 + + + 未记录原因 + + + 所有者要求我们不要再发布这个游戏的连接画面。 + + + 尚未从这个游戏捕获到连接画面。 + + + {count, plural, other {只回来了#行——太少,无法显示。}} + + + 按服务器发送的原样 + + + {columns}×{rows} + + + {columns}×{rows},双倍宽 + + + 16 色 SGR + + + 无颜色 + + + 按 {charset} 解读 + + + 已捕获 + + + 已冻结——我们最后看到的画面 + + + ASCII 图画:{game} 的连接画面。它的文本在下方的“以文本形式阅读”中。 + + + ASCII 图画:这个游戏的连接画面。它的文本在下方的“以文本形式阅读”中。 + + + {count, plural, other {连接画面:#行,仅文本}} + + + {count, plural, other {连接画面:#行,仅文本,按 {charset} 解读}} + + + 游戏自述具备该项,而服务器在握手中从未提供过它。 + + + 服务器提供该项,而游戏自己的记录说它没有。 + + + 通常只是一个久未更新的手打字段,而不是谎言。之所以显示出来,是因为客户端不该依赖两者不一致的部分。 + + + 未找到 + + + 这个地址上没有游戏。请检查拼写。 + + + 最近应答 + + + {count, plural, other {有#项已超过刷新窗口。是旧,不是错。}} + + + 这个游戏自己的转介列表点名了: + + + 被以下游戏的转介列表点名: + + + 自 {date} 起 + + + 已不再列出,最后一次见于 + + + 最近一次应答是 {date},距今 {ago}。 + + + 仍每周探测;它一应答,本页当天就会更新。 + + + 已知存活 {span} + + + 不在列表、排行或每日数字中:我们认为这个地址不是一个可供人游玩的游戏。下面的一切都是它告诉我们的内容,原样未改。 + + + 我们的理由:{why} + + + 应运营者的要求,不在列表、排行或每日数字中。下面的一切按原样保留,本页和它曾用过的每一个地址都继续应答,而我们不再向它拨号。 + + + 当前人数:{count} + + + 能力({total} 项中有 {disagreeing} 项不一致) + + + 游戏自述 + + + 连接画面 + + + 变更记录 + + + 实测 + + + 自述 + + + ** 不一致 + + + 连接数 + + + 连通 + + + 搜索 + + + 已归档 + + + 成人 + + + 已包含 + + + 无计数 + + + 当前无计数,此处也不指明原因 + + + 当前人数:无计数(此处不指明原因) + + + 爬虫运行中 · 最近探测 {age} + + + 爬虫空闲 · 最近探测 {age} + + + 此处尚未完成过任何探测 + + + 本周期无到期目标 + + + {considered, plural, other {#个到期}} · {answered, plural, other {#个应答}} · {failed, plural, other {#个失败}} + + + 登记表中有{targets, plural, other {#个地址}},{due, plural, other {其中#个已到期}} + + + {page} — {site} + + + MU* 爱好的游戏目录——MUSH、MUD、MUCK、MOO——每一项事实都标明它的来源与时间。 + + + 演示数据——此处没有任何内容经过实测。{description} + + + {site}——实测,而非自述 + + + {site} 上的 {title} + + + 游戏 + + + 归档 + + + 排行 + + + 生态 + + + 参考 + + + 关于 + + + 未找到 + + + 随机游戏 + + + 我的游戏 + + + 认领 {game} + + + 我们连通过的每一个 MU*,按实测结果分面:代码库、服务器在握手中提供的协议、TLS、编码、语言,以及我们最近一次连入的时间。 + + + 已经失联的游戏,都保留下来。每个都保有自己的页面、历史和 URL,仍每周探测,一次成功连接即可回到列表。 + + + 最热闹、最可连通、运行最久——仅由实测数据算出。本站任何地方都没有投票、星级或评分。 + + + 我们实测的游戏中,代码库的占比与协议的采用情况,服务器提供的内容与它自述的内容并列呈现。只讲占比,绝不讲总量。 + + + 关于 MU* 爱好中的代码库、客户端与协议的手写页面,并与爬取所得的计数相互链接。 + + + 这份目录是如何建成的:爬虫做什么、拒绝做什么,以及如何让它停下。 + + + 这个地址上没有游戏。本站任何内容都不会被删除,所以曾经在这个 URL 上的游戏如今依然在——请检查拼写。 + + + 从目录中随机挑出的一个游戏,绝不会连着两次相同。 + + + 你已认领的条目,以及一次认领能让你改动什么。 + + + 在只有运营者才能放置的地方发布一个令牌,以此证明这个游戏由你运营。 + + + 已归档——最近连通 {age},仍在探测 + + + 已归档,仍在探测 + + + 人数未知——游戏有应答,但没有发布我们能读出的数字 + + + {count, plural, other {#人连接}},{age}{how} + + + 新发现 + + + 已失联 + + + 已恢复 + + + {count, plural, other {已知#个游戏}} + + + {count, plural, other {当前#人连接(实测)}} + + + {count, plural, other {#个游戏有应答,未能计数}} + + + {count, plural, other {#个已归档,仍在探测}} + + + 参考 + + + 代码库是什么,客户端做什么,协议又意味着什么。由人手写,与爬虫一同存放在代码仓库里——这不是维基,本页上也没有任何可编辑的东西。这里的每一个{number}都是另一回事:它取自目录,每次加载页面时都重新算出。 + + + 数字 + + + 手写,单一作者,并用 git 做版本管理。这里的文字出自我们;旁边的每一个数字都由爬虫实测得出,并在每次请求时重新算出。这不是维基,本页也没有任何编辑它的途径。 + + + 从这里开始 + + + 代码库 + + + 客户端 + + + 协议 + + + 入门 + + + 代码库 + + + 客户端 + + + 协议 + + + 未找到 + + + 这里没有参考页面。本节由人手写,所以缺口是还没人做的工作,而不是被移除的内容——{index}。 + + + 看看现有的内容 + + + 运行它的游戏 + + + 我们尚未识别到任何一个。这是关于这台爬虫实测到了什么的事实,而不是关于存在什么的事实——我们没有连通过的游戏,或者代码库读不出来的游戏,都不算进这里。 + + + {count, plural, other {已列出#个}} + + + {count, plural, other {已归档#个}} + + + 实测,绝非断言 + + + 这是本次请求时从目录中数出的,用的正是上方链接所带的同一个筛选条件——所以这个数字和那份列表出自同一个查询,不可能对不上。 + + + 在它们的握手中提供:{protocols} + + + 实测采用情况 + + + 尚无实测。 + + + 在{listed, plural, other {#个已列出的游戏}}中——{percent} + + + 未算进这里的游戏,并不是没有这个协议的游戏。一个游戏被算进这里,条件是我们观测到它的服务器在一次握手中提供了这个选项;其余的既包括没有向我们提供它的服务器,也包括我们还没有读到过握手的服务器,而我们说不出是哪一种。 + + + 在一次握手中被观测到提供 {protocol} 的游戏,按我们识别出的其运行代码库分组。 + + + 代码库 + + + 已提供 + + + 已识别 + + + 另见 + + + 能力 + + + {count, plural, other {在{total}项中,有#项依据项目自己的文档确定}} + + + 逐项读自各项目自己的文档,而非由我们实测——客户端没有握手可供我们观测。“{unknown}”表示我们查过,没能确定。它绝不表示“否”。 + + + 客户端能力,每一项都读自项目自己的文档。未知表示我们没能确定,而绝不表示这个客户端不具备该项。 + + + 有文档 + + + 来源 + + + 我们没有找到 + + + 有 + + + 否 + + + 未知 + + + 运行平台:{platforms} + + + 我们识别为运行该代码库的游戏 + + + 尚无。这是关于我们实测到了什么的说法,而不是关于存在什么的说法——我们没有连通过的游戏,或者代码库读不出来的游戏,都不算进这里。 + + + {listed, plural, other {已列出#个}},{archived, plural, other {已归档#个}} + + + 在它们的握手中实测到:{protocols} + + + 在我们读到过的它们的任何一次握手中,都没有提供过任何东西。 + + + 在{listed, plural, other {#个已列出的游戏}}中,有 {offering} 个被观测到提供了它({percent}) + + + 按代码库,在我们已识别的游戏中 + + + {identified} 个中有 {offering} 个提供了它 + + + {count, plural, other {在{total}行中,有#行是未知}}:我们没有找到项目自己的文档给出任何一种说法。一张诚实的短表胜过一张靠猜的长表。 + + + 我的游戏 + + + 账户需要数据库支撑,而本站正运行在演示数据上。 + + + 登录 + + + 已以 {name} 登录。 + + + 退出登录 + + + 你还没有认领任何游戏。在{listing}中找到你的游戏,在它的页面上点击{claimControl}。 + + + 列表 + + + 认领此游戏 + + + 已放弃。 + + + 相关记录会保留,你随时可以再发布一个新令牌,重新证明控制权。 + + + 已保存。 + + + 那个游戏 + + + {game} 的页面现在把它标为所有者自述。 + + + 我们已停止发布 {game} 的连接画面。页面会明确说明,而不是留一个空洞。 + + + {game} 的连接画面又回到了它的页面上。 + + + 我们已停止向 {game} 拨号,我们掌握的它的每一个地址都停了。它的页面保留你开口之前实测到的一切。 + + + 我们又开始向 {game} 拨号,从它在排期中的下一轮起。 + + + {game} 已不在列表、排行和每日数字中。它的页面和它曾用过的每一个 URL 仍然照常应答。 + + + {game} 已回到列表中。一次得到应答的探测,就足以让它重新被实测。 + + + {field} 未作改动。 + + + 这些是一行以内的回答;我们最多保存 {max} 个字符。 + + + 该字段是实测的。认领让你补上 MSSP 放不下的内容;它绝不让任何人改动我们观测到的内容,我们自己也不例外。 + + + 已认领 + + + 已于 {date} 验证 + + + 已于 {date} 验证,令牌最近一次见于 {seen} + + + 检查你的 MSSP + + + {count, plural, other {另有 {names} 共同拥有——各自验证过自己的令牌。}} + + + 另一个账户 + + + 把你的在线人数放到自己的网站上 + + + 这个徽标同时带上计数和我们实测它的时间,因为一个没有时间的数字,正是本站要取而代之的东西。 + + + 我们未能计数时,它显示{unknown}而不是零;游戏停止应答时,它显示{archived}。 + + + 还有{json},如果你更愿意自己画。 + + + 历史 + + + 已签发令牌 + + + 已再次签发令牌 + + + 已验证——我们读到了你的令牌 + + + 令牌仍在发布 + + + 本次未读到令牌 + + + 已放弃认领 + + + 令牌未使用即过期 + + + 另一个账户证明了控制权,接管了这个游戏 + + + 已请求提前查看 + + + 放弃此认领 + + + 输入{word}以确认。任何内容都不会被删除,你可以再发布一个新令牌重新证明控制权;如果还有别人拥有它,这个游戏仍是已认领状态。 + + + 放弃 {game} + + + 等待令牌 + + + 令牌签发于 {issued},有效期至 {expires} + + + 通行密钥 + + + 未命名 + + + 添加于 {date} + + + 添加于 {date} · 仅在一台设备上 + + + 这个通行密钥只存在于一台设备上。万一丢了,你仍可以在自己的游戏上发布一个新令牌重新进来,但再加一个通行密钥更快。 + + + 再添加一个通行密钥 + + + 没有这个游戏 + + + 认领 {game} + + + 认领需要数据库,而本站正运行在演示数据上。 + + + 你需要先有一个账户。只需一个通行密钥和一个名称。 + + + 登录或创建账户 + + + 我的游戏 + + + {count, plural, other {这个游戏已经有#位所有者证明了对该服务器的控制权。}}你也可以证明——两种情况下的检验方式相同——但我们需要知道你的意思是什么,因为从令牌上看不出来。 + + + 我也在运营它——把我加为所有者 + + + 每个人都保留自己的认领。这是两个人一起运营一个游戏。 + + + 我已经接手了它——把它转给我 + + + {count, plural, other {你的令牌通过验证后,现有的认领会被撤销,这个游戏归你。}}他们会在自己的历史里看到原因。任何内容都不会被删除,他们也可以用你即将采用的同一种方式重新证明控制权。 + + + 已验证。 + + + 我们在 {date} 从这个游戏的 MSSP 报告中读到了你的令牌。 + + + 我们在 {date} 从连接画面上读到了你的令牌。 + + + 把令牌留在原处。它同时也是一个身份标记,这样即使这个游戏换了主机或改了名字,我们仍认得出它。把它撤掉不会取消你的认领。 + + + 把这个令牌发布在游戏会展示给匿名连接的任何地方。下一次探测就会读到它,这就证明了你能写入那台服务器。 + + + 这是一次转移。 + + + {count, plural, other {我们读到这个令牌时,当前所有者对这个游戏的认领会被撤销,它归你。}} + + + 两种方式都可以 + + + 一个 MSSP 变量 + + + 在 {codebase} 中那是 {file} 里的一行;每个支持 MSSP 的代码库都有对应的做法。 + + + {aliases} 同样可用。 + + + 连接画面上的一行 + + + 画面中任何位置都行,周围有颜色代码也没关系。 + + + 然后 + + + 我们按平常的爬取排期来查看。这个令牌有效期至 {date}。你随时可以再来;没有什么需要记下来。 + + + 提前查看 + + + 把你的游戏排到队列前面。我们按自己的排期拨号,所以这只是提前,而不是立刻。 + + + 刚刚请求过。请过几分钟再试——之所以限量,是因为它会让我们比原定更早去拨一台真实的服务器。 + + + 关于 {game},只有你能告诉我们的事 + + + 这些是 MSSP 没有字段可放的东西。它们会以{declared}的身份出现在你游戏的页面上,带着你最后一次确认的日期,与我们实测到的内容并列——绝不取而代之。实测到的任何内容都无法从这里编辑,你不能,我们也不能。 + + + {age}自述。清空此框即可撤回——不论如何,它曾写过什么的记录都会保留。 + + + 保存你的自述 + + + {game} 报告了什么,以及你更希望我们显示什么 + + + 你的 MSSP 是每个爬虫都会读到的内容,无论你在这里填了什么,我们都会把它并列显示出来——你给的答案不会遮住你游戏给的答案。 + + + 实测到的任何内容都无法从这里编辑:人数计数不行,能力不行,可连通的某个小时也不行。 + + + 如果下面某一行在你的 {file} 里就是错的,在那里改掉,处处都会跟着改。 + + + 你的游戏报告 {value},{age}确认 + + + 你的游戏在这里什么也没有报告 + + + 改名会同时改变 {game} 在列表中的名称和它页面的地址。旧地址永远继续有效——这个游戏曾用过的每一个 URL 都会重定向到它当前的地址——而清空此框,名称就交还给你的 MSSP 所说的内容。 + + + 你的连接画面 + + + 我们不再发布它。页面会明确说明,而不是留一个空洞;爬虫仍会继续读它——你的游戏搬了家,我们靠的就是它认出你。 + + + 重新显示 + + + 我们展示它,是因为你的服务器本来就把它发给每一个匿名连接。你若不愿意,说一声我们就撤下。我们不会问为什么。 + + + 不要再展示我们的连接画面 + + + 被爬取 + + + 我们已经停下。{game} 上没有任何地址还在被拨号,页面保留你开口之前实测到的一切——那些空白的小时不指明原因,因为{ourFact}是关于我们的事实,而不是对你游戏的实测。 + + + 你要求我们停下 + + + 重新开始爬取我们 + + + 这一条来自你自己的服务器,而不是来自这里——{routes}。要重新被爬取,请停止发布它;下一个周期我们就会知道。 + + + 你的 MSSP 报告发布了 {variable} + + + 一条 {label} TXT 记录要求我们停下 + + + 一份我们记录在案的请求 + + + 我们已在 {stopped} 上停下,仍在拨 {dialling}。这通常是在退出声明之后又新增的端口。 + + + 在每一个地址上也都停下 + + + 我们按排期向 {game} 拨号,读取任何匿名连接都能看到的内容。你若不愿意,说一声我们就停——在一个爬取周期之内,在我们掌握的你的每一个地址上,而且我们不会问为什么。 + + + 已经实测到的内容不会被删除:你的页面保留它的历史和它的 URL,而你收回这项设置之后,一次探测就会让它重新开始。你也可以不经过我们,在自己的配置里说出来——MSSP 里的 {mssp},或者一条 {dns} TXT 记录——无论有没有人在这里认领过这个游戏,我们都照办。 + + + 不要再爬取我们 + + + 被列出 + + + {game} 已不在列表中,不在排行中,也不在每日数字中。它的页面和它曾用过的每一个 URL 仍然照常应答,你开口之前实测到的一切也都还在上面。任何内容都没有被删除;它只是不再是读者靠浏览就能走到的地方。 + + + 把我们放回列表 + + + 一次得到应答的探测同样能做到这件事。你的退出声明还立着的时候我们不会拨号,所以不会有这样的探测——但你收回它的那一天,这个地址会在一周之内轮到,得到应答的那次探测就会把你放回去。你不必再来问我们第二次。 + + + 我们已经不再向你拨号,而你的页面仍在列表中,带着在那之前我们实测到的内容。你若不愿意,说一声它就撤出来——从列表、排行和每日数字中撤出。 + + + 任何内容都不会被删除,也不会有什么坏掉:页面照常应答,它曾用过的每一个 URL 仍然重定向到它,你指引过去的人都能看到它。它只是不再是读者靠浏览就能走到的地方。这可以从这里撤销,而你收回退出声明之后,任何一次得到应答的探测也能撤销它。 + + + 把我们也移出列表 + + + 实测到了什么 + + + 隐藏这些会把它们从当前列表中移出;这并不表示该游戏里没有人。 + + + 未能计数 + + + 无法连通 + + + 已从此列表中隐藏 + + + 已从此列表中隐藏 + + + 左栏中,* 表示此列表已筛选为该值,- 表示此列表已把该值排除。两者都是查询中的选择,而不是关于某个游戏的事实。 + + diff --git a/src/MUI.Web/SiteComposition.cs b/src/MUI.Web/SiteComposition.cs index bc336b7..f502805 100644 --- a/src/MUI.Web/SiteComposition.cs +++ b/src/MUI.Web/SiteComposition.cs @@ -1,3 +1,5 @@ +using Microsoft.Extensions.Localization; + using MUI.Catalog; using MUI.Crawler; using MUI.Web.Accounts; @@ -6,6 +8,7 @@ using MUI.Web.Data; using MUI.Web.Fixtures; using MUI.Web.Icons; +using MUI.Web.Localization; using MUI.Web.Submissions; using MUI.Web.Theme; @@ -52,6 +55,9 @@ public static IServiceCollection AddMuiSite( services.AddRazorComponents(); + // The chrome's own words, before anything that renders them. + services.AddMuiLocalization(); + // The read API (spec §10) reads through the same IGameQueries the pages do, so the two // surfaces cannot disagree about a fact. What it adds of its own — the dataset licence, the // slug aliases and the attribution list — is configuration, because none of it is a @@ -112,6 +118,25 @@ public static IServiceCollection AddMuiSite( return services; } + /// + /// The resource set the chrome's strings are read from. + /// + /// + /// The arrangement SharpMUSH's portal uses for its own chrome — AddLocalization over a + /// Resources folder, resolved through a marker class, with the SDK compiling one + /// satellite assembly per culture and no <EmbeddedResource> entries anywhere. What + /// differs is the values: theirs are composite-format strings and these are ICU patterns, + /// because {0} substitutes and cannot agree. See . + /// + public static IServiceCollection AddMuiLocalization(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddLocalization(options => options.ResourcesPath = "Resources"); + + return services; + } + /// /// The middleware and the routes, in the order they have to be in. /// @@ -129,6 +154,18 @@ public static WebApplication UseMuiSite(this WebApplication app, string? connect // limit. app.UseSubmitterAddress(); + // Before anything routes, because the locale is a path segment and every @page directive is + // written without it: the middleware moves the prefix into PathBase so one route table + // serves every language. It is also before the not-found page, so a mistyped URL inside a + // locale is answered in that locale rather than in English. + app.UseMuiLocale(); + + // Explicit, and that is load-bearing rather than tidy: with no UseRouting call of its own an + // app gets one inserted at the very top of the pipeline, which resolved the endpoint before + // the middleware above had rewritten the path — so every localized URL 404'd while the + // unprefixed one worked. Naming it here is what puts routing after the rewrite. + app.UseRouting(); + // A reader who mistyped a URL, and a crawler indexing one, both got a 404 with an empty body: // the fragment inside is never rendered under static server rendering, so // the site's own "no game here" paragraph was dead copy. This answers those with the page — @@ -168,6 +205,10 @@ public static WebApplication UseMuiSite(this WebApplication app, string? connect // deployment has the same eyes as a reader of the real one. app.MapMuiTheme(); + // Beside the theme endpoint and for the same reason: it writes a cookie and redirects, and + // a reader of the demo deployment reads the same language as a reader of the real one. + app.MapMuiLocale(); + // §5.7, and before the route that would answer with "not found": a slug this game used to // have is a URL somebody is still holding, and it redirects to the page it has now — // permanently, and for an archived game exactly as for a live one. diff --git a/src/MUI.Web/Submissions/SubmissionEndpoint.cs b/src/MUI.Web/Submissions/SubmissionEndpoint.cs index e503b7d..53d60fc 100644 --- a/src/MUI.Web/Submissions/SubmissionEndpoint.cs +++ b/src/MUI.Web/Submissions/SubmissionEndpoint.cs @@ -3,6 +3,7 @@ using MUI.Catalog.Persistence; using MUI.Discovery; using MUI.Web.Components; +using MUI.Web.Localization; namespace MUI.Web.Submissions; @@ -56,7 +57,9 @@ await sources.OfAsync(SubmitterAddress.Of(context), context.RequestAborted), ? game.Slug : null; - return Results.Redirect(SubmitLinks.For(receipt.Outcome, receipt.Address, slug)); + return Results.Redirect(LocaleRouting.Link( + context.LocaleOf().Tag, + SubmitLinks.For(receipt.Outcome, receipt.Address, slug))); }); } } diff --git a/src/MUI.Web/Theme/ThemeEndpoint.cs b/src/MUI.Web/Theme/ThemeEndpoint.cs index 0904882..7b0b61b 100644 --- a/src/MUI.Web/Theme/ThemeEndpoint.cs +++ b/src/MUI.Web/Theme/ThemeEndpoint.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Mvc; +using MUI.Web.Localization; namespace MUI.Web.Theme; @@ -42,7 +43,13 @@ public static IEndpointRouteBuilder MapMuiTheme(this IEndpointRouteBuilder endpo // 303 rather than 302: the reader posted a form and what they should now be holding is // the page they came from, fetched with GET. A 302 gets there by convention on every // browser written since 1996, and this says what was meant. - context.Response.Headers.Location = ReaderTheme.Back(returnTo); + // + // And back into the language they were reading, which the return field cannot carry: it + // holds Request.Path, from which the middleware has already taken the prefix. A reader + // who followed a shared /de/… link has no cookie either, so this redirect was the whole + // of what stood between them and an English page — for choosing a light background. + context.Response.Headers.Location = + LocaleRouting.Link(context.LocaleOf().Tag, ReaderTheme.Back(returnTo)); return Results.StatusCode(StatusCodes.Status303SeeOther); }).DisableAntiforgery(); diff --git a/src/MUI.Web/wwwroot/app.css b/src/MUI.Web/wwwroot/app.css index 09d448e..0e0ae29 100644 --- a/src/MUI.Web/wwwroot/app.css +++ b/src/MUI.Web/wwwroot/app.css @@ -12,23 +12,74 @@ */ :root { - --cpad: 14px; /* the density control writes this, and only this */ - --row-pad: 9px; + /* + Spacing, radii and motion, in the relationships the design handoff's own token files carry. + The values are theirs; the colours below are ours — the handoff's palette is a different + product's and its README says so. What transfers is the scale: content padding at 20px rather + than the 14 this site had, one radius for controls and a larger one for the surfaces that hold + them, and a single easing curve at an eighth of a second. + */ + --cpad: 24px; /* the density control writes this, and only this */ + --row-pad: 13px; /* a listing row: 13px 20px, over a 44px floor */ + + /* + The one horizontal inset the whole site shares — the bar, every content band, every listing + row. Fluid rather than fixed because the page is full-bleed now: at the width the handoff was + drawn to it lands on the 24px the drawing uses, and it grows a little rather than leaving a + wide monitor's chrome pinned to the very edge of the glass. + */ + --gutter: clamp(14px, 1.6vw, 28px); + + --radius: 9px; /* controls, chips, rows, nav items */ + --radius-lg: 14px; /* cards and panels — the things controls sit in */ + --radius-pill: 99px; /* pills and badges */ + + --ease: cubic-bezier(0.4, 0, 0.2, 1); + --dur: 0.14s; --bg: #0f1113; --surface: #16191c; --raised: #1d2125; --recessed: #0b0d0f; + + /* + Two hairline steps, as the handoff's tokens carry them: --line is a control's own edge and + --line-soft is the rule between two panels. One value did both, so a filter column's edge and + a button's border were the same weight — and the panel rules, which are ten times longer, + read as the loudest thing on the page. + */ --line: #262b30; + --line-soft: #1c2024; --text: #e8eaec; /* values and names */ --dim: #9aa2a9; /* prose and secondary */ --faint: #6b747c; /* provenance, ages, meta */ --accent: #35d29a; /* measured */ + + /* + The accent as *text on an accent tint* — the selected facet row, which paints a 12% wash of + the accent behind accent-coloured words. Every ratio beside a token here is measured against + the page, and a tinted row is not the page: in light, --accent is 5.0:1 on the background it + was tuned for and 3.9:1 on this one, which is the row a reader has just clicked. Dark clears + it either way, so there the two are the same colour. + */ + --accent-ink: var(--accent); --amber: #d8a13a; /* declared / ageing */ --derived: #a394f0; /* our own classification, and nobody else's */ + /* + Refusal, and only refusal. Not a provenance colour — nothing on this site is red because of + what we did or did not measure. It marks one thing: a facet value the reader has excluded, + where the handoff draws a danger tint, a danger ring and a struck-through label. A state the + reader put the control into, never a state of the data. + */ + --danger: #f4574c; + + /* The card's own elevation, and the one glow the accent is allowed. */ + --shadow: 0 1px 0 rgb(255 255 255 / 3%) inset, 0 4px 16px rgb(0 0 0 / 40%); + --glow: 0 0 8px color-mix(in srgb, var(--accent) 55%, transparent); + --sans: "Hanken Grotesk", ui-sans-serif, system-ui, -apple-system, sans-serif; --mono: "Cascadia Mono", ui-monospace, "SFMono-Regular", Consolas, monospace; --kick: "JetBrains Mono", var(--mono); @@ -60,38 +111,69 @@ data-theme wins over the system in both directions, so a toggle can be added without moving any of this. */ +/* + ── the light theme, and why none of it is white ─────────────────────────── + The page surface was #ffffff, and once the card went full-bleed that stopped being a panel and + became the whole window. A large field of pure white glares: the halation it produces around dark + text is worst for readers with astigmatism and is one of the things dyslexic readers most often + ask to be turned down, and 21:1 body contrast is past the point where more helps and into the + point where it tires. Nobody's guideline forbids it — WCAG has a floor and no ceiling — which is + why every design system that has thought about it lands in the same place by convention instead: + GitHub Primer's canvas.subtle #f6f8fa, IBM Carbon's #f4f4f4 background under white layers, + Material 3's tinted surfaces, Solarized Light's cream. All of them are off-white. + + So the ramp is a soft cool grey and the near-white is reserved for the things that are supposed to + sit *above* it — tiles, the hover plane. That also restores a distinction the light theme had lost + entirely: --surface and --raised were both #ffffff, so a card lifted off the page by a step in the + dark theme was lifted by nothing at all here, and its border was doing all the work. + + Text is a near-black with the ramp's own hue rather than #000, at ~15:1 — comfortably past the + 4.5:1 floor and short of the glare. Every step below was measured against the new surface, and + --faint moved because it did not clear 4.5:1 against the old one: it carries ages and provenance + at 12px, which is exactly the small low-contrast text the handoff asked to have checked. +*/ @media (prefers-color-scheme: light) { :root { - --bg: #f7f8f9; - --surface: #ffffff; - --raised: #ffffff; - --recessed: #eef0f2; - --line: #dfe3e6; - - --text: #14181b; - --dim: #4d565d; - --faint: #79838a; - - --accent: #04795b; /* same hue, dropped in value to clear 4.5:1 on white */ - --amber: #8a5d00; - --derived: #5b46c4; + --bg: #eef1f2; /* behind the page; barely visible now the shell is full-bleed */ + --surface: #f4f6f7; /* the page itself */ + --raised: #fbfcfc; /* tiles, hover planes — above the page, never below it */ + --recessed: #e9edee; /* the bar, the filter column */ + --line: #d3d9dc; + --line-soft: #e2e7e9; + + --text: #1a1f23; /* 15.3:1 — past the floor, short of the glare */ + --dim: #4d585f; /* 6.7:1 */ + --faint: #646f76; /* 4.8:1 — was 3.5:1 and carried 12px ages */ + + --accent: #04795b; /* same hue, dropped in value: 5.0:1 */ + --accent-ink: #036349; /* 5.3:1 on the accent's own 12% tint, where --accent is 3.9:1 */ + --amber: #7d5400; /* 6.2:1 */ + --derived: #5240b8; /* 7.0:1 */ + --danger: #a8261e; /* 6.5:1 */ + + --shadow: 0 1px 2px rgb(16 24 32 / 4%), 0 4px 16px rgb(16 24 32 / 5%); } } :root[data-theme="light"] { - --bg: #f7f8f9; - --surface: #ffffff; - --raised: #ffffff; - --recessed: #eef0f2; - --line: #dfe3e6; + --bg: #eef1f2; + --surface: #f4f6f7; + --raised: #fbfcfc; + --recessed: #e9edee; + --line: #d3d9dc; + --line-soft: #e2e7e9; - --text: #14181b; - --dim: #4d565d; - --faint: #79838a; + --text: #1a1f23; + --dim: #4d585f; + --faint: #646f76; --accent: #04795b; - --amber: #8a5d00; - --derived: #5b46c4; + --accent-ink: #036349; + --amber: #7d5400; + --derived: #5240b8; + --danger: #a8261e; + + --shadow: 0 1px 2px rgb(16 24 32 / 4%), 0 4px 16px rgb(16 24 32 / 5%); } :root[data-theme="dark"] { @@ -100,19 +182,29 @@ --raised: #1d2125; --recessed: #0b0d0f; --line: #262b30; + --line-soft: #1c2024; --text: #e8eaec; --dim: #9aa2a9; --faint: #6b747c; --accent: #35d29a; + + /* Reset with the rest of them. The light media block sets this unconditionally, so a reader who + pinned dark on a light-preferring system kept the light ink — a dark green on a dark tint, + 2.7:1, which is worse than the value it was added to fix. Every token this block redeclares + is redeclared for that reason; this one was missed. */ + --accent-ink: var(--accent); --amber: #d8a13a; --derived: #a394f0; + --danger: #f4574c; + + --shadow: 0 1px 0 rgb(255 255 255 / 3%) inset, 0 4px 16px rgb(0 0 0 / 40%); } :root[data-density="compact"] { - --cpad: 9px; - --row-pad: 5px; + --cpad: 13px; + --row-pad: 8px; } * { box-sizing: border-box; } @@ -125,16 +217,74 @@ body { text-wrap: pretty; } -main { max-width: 1080px; margin: 0 auto; padding: calc(var(--cpad) * 2) var(--cpad); } +/* + The shell: everything the handoff draws sits in one card — the bar along its top edge, the content + under it — rather than loose on the page. It is the difference between a document and an + instrument, and this site is the second: a directory whose bar, filters and rows are one panel. +*/ +/* + ── the shell ────────────────────────────────────────────────────────────── + Full-bleed. The handoff draws the site as one card floating on a ground, and that is a drawing + convention rather than a product decision — a directory is an instrument you fill the window with, + not a document you frame. So the card's border, radius, shadow and side margins are gone and the + page IS the surface; what the card was actually carrying — one bar along the top, bands separated + by hairlines, every block sharing one left edge — is unchanged and is the part that mattered. + + The radial that used to sit on the body moves here, because the body is no longer visible behind + anything. It is the one thing keeping a full-height dark page from reading as flat black. +*/ +.shell { + min-height: 100vh; + margin: 0; + background: var(--surface) radial-gradient(80% 90% at 78% -20%, + color-mix(in srgb, var(--raised) 45%, var(--surface)) 0%, var(--surface) 100%) fixed; + + /* + Clipped sideways and open downwards. `hidden` on both axes is what a card wants and it + swallowed the nav's own menu: at the width where both groups collapse into a disclosure, the + panel is positioned under the bar and every pixel of it was being clipped away, so the one + control that carries the whole catalogue on a narrow window opened onto nothing. `clip` pairs + with `visible` — `hidden` would coerce it to `auto` and hand the page a scrollbar. + */ + overflow-x: clip; + overflow-y: visible; +} + +/* + The content column's own padding, at the handoff's 30px/24px/26px. A page whose blocks bleed to + the card's edges — the listing's two columns, the game page's hairline-separated bands — cancels + it and pads each block itself, because a full-bleed rule cannot be drawn inside a padded box. +*/ +main { padding: 30px var(--gutter) 40px; } +main:has(> .card-page) { padding: 0; } + +/* + One vertical rhythm for the whole content column, as a gap rather than as margins on each block. + Margins collapse, disagree with each other and leave a dead band wherever a block renders nothing + — which is what the absent crawler strip did to the front page. +*/ +main > .card-page, +.hero { display: flex; flex-direction: column; gap: 22px; } h1 { font-size: 26px; font-weight: 600; letter-spacing: -0.01em; margin: 0 0 6px; } -h2 { font-size: 19px; font-weight: 600; margin: calc(var(--cpad) * 2) 0 var(--cpad); } +h2 { font-size: 20px; font-weight: 600; margin: calc(var(--cpad) * 1.4) 0 10px; } a { color: inherit; } .dim { color: var(--dim); } .faint { color: var(--faint); } .mono { font-family: var(--mono); font-variant-ligatures: none; } +/* Every count and every age, so the columns scan. */ +.mono, .tabular { font-variant-numeric: tabular-nums; } + +/* + ── the kicker, and why its typography is locale-gated ────────────────────── + Uppercase plus 0.16em tracking is this site's most recognisable label, and it is Latin-specific + twice over (i18n S1). Letter-spacing severs the cursive joins in Arabic, so connected words come + apart into unrelated shapes; Devanagari and Thai lose conjuncts and mark stacks the same way. + text-transform: uppercase is a no-op in CJK, turns ß into SS, and gets Turkish dotted/dotless i + wrong. The class is the semantics; :lang() decides the typography. +*/ .kicker { font: 10px/1 var(--kick); letter-spacing: 0.16em; @@ -142,18 +292,100 @@ a { color: inherit; } color: var(--faint); } -/* ── site chrome ───────────────────────────────────────────────────────── */ +/* Cursive and complex scripts: prominence from weight and size, never from tracking. */ +:is(.kicker, .nav-label, .bar-label, .facet-name, .evidence, .kick):is( + :lang(ar), :lang(he), :lang(fa), :lang(ur), :lang(th), :lang(hi), :lang(mr), :lang(ne)) { + text-transform: none; + letter-spacing: normal; + font-weight: 600; + font-size: 11.5px; + line-height: 1.5; +} + +/* CJK: uppercase does nothing, tracking looks like a fault, and 10px is unreadable. */ +:is(.kicker, .nav-label, .bar-label, .facet-name, .evidence, .kick):is( + :lang(zh), :lang(ja), :lang(ko)) { + text-transform: none; + letter-spacing: 0.02em; + font-size: 12px; +} + +/* ── site chrome ─────────────────────────────────────────────────────────── + A fixed 60px bar that never wraps. It stretches rather than centres, so a nav item is a + full-height box and its current-page marker can land on the bar's own bottom edge. */ header.site { + /* One row is 60px and stays 60px; a bar that has wrapped is as tall as what it holds. */ + min-height: 60px; + row-gap: 4px; + flex: none; border-bottom: 1px solid var(--line); - padding: var(--cpad); + background: var(--recessed); + padding: 0 var(--gutter); display: flex; - gap: var(--cpad); - align-items: baseline; + align-items: stretch; + justify-content: space-between; + gap: 20px; + + /* + A bar spanning the window is chrome, so it stays where chrome belongs. The game page runs to + three and a half thousand pixels and the catalogue is at the top of it; scrolling back up to + reach the nav is a cost the card layout hid by being short. + */ + position: sticky; + top: 0; + z-index: 30; + + /* + Sideways only. `overflow: hidden` is what a fixed-height bar wants — nothing may spill out of + it — but it clipped the nav's own dropdown to nothing at the width where that dropdown IS the + navigation. `clip` on one axis leaves `visible` legal on the other; `hidden` would coerce it. + */ + overflow-x: clip; + overflow-y: visible; } -header.site .mark { font-family: var(--mono); font-weight: 600; text-decoration: none; } -header.site .mark .star { color: var(--accent); } +/* + The bar is one line of chrome and the demo banner above it is a statement about the whole site, so + the banner scrolls away and the bar does not. Where there is no banner the bar is simply at the + top of the document. +*/ +.demo-banner { position: relative; z-index: 31; } + +/* + The bar measures itself. + + Every threshold in the ladder below used to be a pixel count, and every one of them was really a + measurement of *English*: the seven catalogue links are 430px in English and 517px in Japanese, so + 860 was the width at which the English bar stopped fitting and German, Dutch and Japanese spent the + whole 861–1100 band with links running out of their box. 1024 is one of the four widths this design + is reviewed at, and all three failed at it. + + `max-content` is the browser's own answer to "how wide is this text, in this language, in this + font" — so the nav asks for exactly what its links need and `flex-wrap` gives it its own row the + moment the bar cannot. No number to keep in step with a translation, nothing to re-tune when a + string changes, and a language nobody has measured yet is right on arrival. + + What the reader gets in that band is a two-row bar rather than a menu, which is the trade: every + destination stays visible and in the same order, and the row that moves is the one that was being + clipped. Below the collapse width it is still one disclosure, because a menu is the right shape on + a phone whatever the language. +*/ +header.site { flex-wrap: wrap; } +header.site .mark { flex: 0 0 auto; } +header.site .site-nav { flex: 1 1 auto; min-width: max-content; } +header.site nav.account { flex: 0 0 auto; } +header.site form.theme { flex: 0 0 auto; } + +header.site .mark { + display: flex; + align-items: center; + font: 700 17px/1 var(--sans); + letter-spacing: -0.01em; + text-decoration: none; + white-space: nowrap; +} +header.site .mark .star { color: var(--accent); text-shadow: var(--glow); } /* ── provenance chip ─────────────────────────────────────────────────────── Not a box. At rest it is the value, one state glyph and a relative age — no border, no fill, @@ -178,14 +410,43 @@ header.site .mark .star { color: var(--accent); } /* ── capability matrix ─────────────────────────────────────────────────── */ -table.matrix { border-collapse: collapse; width: 100%; } +/* + A framed table, as the handoff draws it: a header row on the recessed surface, hairlines between + the rows, and the caption as a foot band inside the same frame rather than a loose line under it. + A caption that sits outside the border reads as a note about the section; inside it, it reads as + what it is — the table's own summary, with the table still in view. +*/ +table.matrix { + border-collapse: collapse; + width: 100%; + border: 1px solid var(--line-soft); + border-radius: var(--radius); + overflow: hidden; +} table.matrix th, table.matrix td { text-align: left; - padding: var(--row-pad) var(--cpad); - border-bottom: 1px solid var(--line); + /* Its own padding rather than the row token: this table is six rows of two-word states, and a + listing row's 13px by 20px turns each of them into a paragraph. */ + padding: 11px 16px; + border-top: 1px solid var(--line-soft); + white-space: nowrap; +} +table.matrix thead th { + font: 10px/1 var(--kick); letter-spacing: 0.16em; text-transform: uppercase; color: var(--faint); + background: var(--recessed); + border-top: 0; + padding: 10px 16px; } -table.matrix thead th { font: 10px/1 var(--kick); letter-spacing: 0.16em; text-transform: uppercase; color: var(--faint); } +table.matrix tbody tr:first-child th, table.matrix tbody tr:first-child td { border-top: 1px solid var(--line-soft); } table.matrix tr.disagrees { background: color-mix(in srgb, var(--amber) 9%, transparent); } +/* At the foot and inside the frame, where a caption belongs on a table whose heading is directly + above it — and stated once, here, rather than in the heading as well. */ +table.matrix caption.count { + caption-side: bottom; text-align: left; font-size: 12.5px; + padding: 10px 16px; + background: var(--recessed); + border-top: 1px solid var(--line-soft); +} .state-present { color: var(--accent); } .state-absent { color: var(--dim); } .state-unknown { color: var(--faint); } @@ -210,15 +471,30 @@ td.gap { background: transparent; outline: 1px dashed var(--faint); out Foreign colour is quoted, not hosted. Its own fixed black ground and its own locked 16-colour table in both themes, so arbitrary SGR is expected inside and nothing bleeds either way. */ -figure.ansi { margin: 0; border: 1px solid var(--line); background: #000; } +/* + One panel holding the art, the words and the caption — rather than a black box with a kicker strip + welded under it and the words in a separate disclosure below. The frame is the site's own recessed + surface at the controls' radius; the art keeps its fixed black ground inside, because the SGR it + carries is somebody else's and has to sit on the terminal it was drawn for. +*/ +figure.ansi { + margin: 0; + border: 1px solid var(--line-soft); + border-radius: var(--radius); + background: var(--recessed); + padding: 16px 18px; + display: flex; + flex-direction: column; + gap: 12px; + overflow: hidden; +} figure.ansi figcaption { - font: 10px/1 var(--kick); - letter-spacing: 0.16em; - text-transform: uppercase; + font-family: var(--mono); + font-size: 11.5px; color: var(--faint); - padding: 8px var(--cpad); - border-top: 1px solid var(--line); - background: var(--surface); + padding: 0; + background: none; + border: 0; } /* @@ -258,22 +534,259 @@ figure.ansi figcaption { .skip:focus { left: 8px; top: 8px; } -:where(a, button, input, summary):focus-visible { +:where(a, button, input, select, summary):focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } /* ── chrome ────────────────────────────────────────────────────────────── */ -/* Wrapping, because seven catalogue links do not fit a 390px phone on one line and the alternative - is a page that scrolls sideways — which the whole layout is otherwise careful never to do. */ -header.site nav { display: flex; gap: var(--cpad); flex-wrap: wrap; } -header.site nav a { color: var(--dim); text-decoration: none; } +/* + Every item is a full-height flex box, and nothing wraps. The bar's job is to be one line of + chrome; when it stops fitting it degrades through the steps below rather than reflowing. +*/ +header.site nav { display: flex; align-items: stretch; min-width: 0; } +header.site nav a, header.site nav span.nav-item { + display: flex; align-items: center; white-space: nowrap; + color: var(--dim); text-decoration: none; font-size: 13.5px; +} header.site nav a:hover { color: var(--text); text-decoration: underline; text-underline-offset: 3px; } -/* The one link in this bar that leads somewhere only you can go, so it is not dimmed like the - catalogues beside it. */ +/* ── the two groups ──────────────────────────────────────────────────────── + Places to browse, and things to read about the hobby. The label is the kicker face the rest of + the site uses for column heads, and it is aria-hidden: the group carries the same word as its + accessible name, and a screen reader announcing "browse" as a text node and again as a label is + the duplication this pass exists to remove. */ + +header.site .site-nav { display: flex; align-items: stretch; justify-content: center; gap: 14px; } +header.site .nav-group { display: flex; align-items: stretch; gap: 11px; min-width: 0; } +header.site .nav-label { + display: flex; align-items: center; white-space: nowrap; + font: 9px/1 var(--kick); letter-spacing: 0.16em; text-transform: uppercase; color: var(--faint); +} + +/* A hairline between the groups rather than a wider gap: the bar is short and the gap that would + read as a separation is one that reads as a wrap. 18px, centred in the 60px bar. */ +header.site .nav-rule { flex: 0 0 auto; width: 1px; margin: 21px 0; background: var(--line); } + +/* + The page you are on, marked *inside* the item's own box. + + The marker is an inset shadow and NOTHING else — no border, no padding change. Either of those + adds a pixel to the current item's box, and because the bar is a flex line that grows to its + tallest child, the whole row moved down one pixel the moment a marked item was on it. A bar that + shifts when you arrive is a bar you cannot aim at, and it is the defect the handoff names by hand. +*/ +header.site nav a.on { color: var(--text); box-shadow: inset 0 -2px 0 0 var(--accent); } +header.site nav a.on:hover { text-decoration: none; } + +/* The far end of the bar: what to do, rather than where to go. Submit is an action and about is + site meta, so neither is a catalogue and neither sits in a group above. */ +header.site nav.account { display: flex; align-items: center; gap: 12px; } header.site nav.account a { color: var(--text); } +header.site nav.account a.action { + border: 1px solid var(--line); border-radius: var(--radius); padding: 6px 12px; + font-size: 13px; white-space: nowrap; +} +header.site nav.account a.action:hover { border-color: var(--dim); text-decoration: none; } + +/* The accent button: ring and tint together, as the handoff draws it — the fill is what tells it + apart from the bordered one beside it at a glance. */ +header.site nav.account a.action.primary { + border-color: color-mix(in srgb, var(--accent) 40%, transparent); + background: color-mix(in srgb, var(--accent) 12%, transparent); + color: var(--accent); +} + +/* + ── the nav's degradation order (i18n S2) ────────────────────────────────── + Width triggers the steps, never language: a German reader on a wide monitor never leaves step 0 + and an English reader in a narrow window reaches step 2. Each step loses DECORATION, never a + destination — which is the whole point, and why the collapse at the end is a disclosure holding + every link rather than a shorter bar. + + step 1 group labels drop; the divider keeps the grouping + step 2 submit loses its border, becoming a plain link + step 3 both groups collapse into one `menu` disclosure + + Budget every nav string at 1.4x the English width when reviewing a locale. +*/ + +/* + Step 0 to 2 the disclosure is not a disclosure: the summary is hidden and its content forced + visible, so the two groups sit in the bar exactly as if the were not there. Guarded on + ::details-content, because a browser without it cannot be made to show a closed — there + the summary stays and the nav is a menu button at every width, which is a worse bar and a working + one. +*/ +header.site .nav-menu { display: flex; position: relative; align-items: stretch; min-width: 0; } +header.site .nav-menu > summary { + display: flex; align-items: center; gap: 6px; cursor: pointer; + color: var(--dim); font-size: 13.5px; white-space: nowrap; list-style: none; +} +header.site .nav-menu > summary::-webkit-details-marker { display: none; } +header.site .nav-menu > summary:hover { color: var(--text); } +header.site .nav-menu[open] > summary { color: var(--text); } + +/* The panel, as a dropdown. Above the collapse width the same element is the bar's own centre row. */ +header.site .nav-menu .menu-panel { + position: absolute; top: 100%; left: 0; z-index: 20; min-width: 200px; + display: flex; flex-direction: column; gap: 2px; + padding: 8px; margin-top: 1px; + background: var(--raised); border: 1px solid var(--line); + border-radius: var(--radius); box-shadow: var(--shadow); +} +header.site .nav-menu .menu-panel > .nav-group { flex-direction: column; align-items: stretch; gap: 2px; } +header.site .nav-menu .menu-panel a { padding: 6px 10px; border-radius: 6px; } +header.site .nav-menu .menu-panel a:hover { background: var(--surface); text-decoration: none; } + +/* + The current page, marked for a list rather than for a bar — and only while the panel IS a list. + The inset shadow that lands on the bar's bottom edge becomes a full-width rule under one row of a + dropdown, which reads as a text field rather than as "you are here", so in the panel the marker + moves to the leading edge. Scoped to the collapsed width: unscoped it drew a vertical accent bar + and a tinted box down the side of the current item in the open bar. +*/ +@media (max-width: 880px) { + header.site .nav-menu .menu-panel a.on { + box-shadow: inset 2px 0 0 0 var(--accent); + background: color-mix(in srgb, var(--accent) 10%, transparent); + color: var(--accent); + } +} +header.site .nav-menu .menu-panel .nav-label { padding: 6px 8px 2px; } +header.site .nav-menu .menu-panel .nav-rule { display: none; } +header.site .menu-tail { display: flex; flex-direction: column; gap: 2px; } + +@supports selector(::details-content) { + @media (min-width: 881px) { + header.site .nav-menu > summary { display: none; } + + /* + The wrapper has to carry the bar's full height, not its content's. A nav item is a + full-height box so that the current-page marker — an inset shadow, drawn inside the box — + lands on the bar's own bottom edge; with the wrapper sized to its text the marker floated + eighteen pixels above it, which is the underline hanging in mid-bar rather than sitting on + it. `display: flex` is what makes the panel below a stretched flex item. + */ + header.site .nav-menu::details-content { + content-visibility: visible; + block-size: 100%; + display: flex; + align-items: stretch; + } + + /* The panel is the bar's middle row: a static flex line, not a dropdown. */ + header.site .nav-menu .menu-panel { + position: static; min-width: 0; height: 100%; + display: flex; flex-direction: row; align-items: stretch; gap: 14px; + padding: 0; margin: 0; background: none; border: 0; border-radius: 0; box-shadow: none; + } + header.site .nav-menu .menu-panel > .nav-group { flex-direction: row; align-items: stretch; gap: 11px; } + header.site .nav-menu .menu-panel a { padding: 0; border-radius: 0; } + header.site .nav-menu .menu-panel a:hover { background: none; } + header.site .nav-menu .menu-panel .nav-rule { display: block; } + header.site .nav-menu .menu-panel .nav-label { padding: 0; } + + /* Step 4's overflow home, which the bar itself still carries at these widths. */ + header.site .menu-tail { display: none; } + } + + /* + Step 1 — the labels go and the divider stays. The grouping is the fact; the words naming the + groups are a gloss on it, and the rule between them carries the same fact in one pixel. Losing + both at once would leave seven links in a row in no order anybody could name, which is the + state this bar was rebuilt out of. + */ + @media (min-width: 881px) and (max-width: 1180px) { + header.site .nav-label { display: none; } + header.site .nav-menu .menu-panel { gap: 14px; } + header.site .nav-menu .menu-panel > .nav-group { gap: 11px; } + } +} + +@media (max-width: 1000px) { /* step 2 — submit keeps the word, loses the box */ + header.site nav.account a.action:not(.primary) { + border-color: transparent; padding: 6px 0; color: var(--dim); + } + header.site nav.account a.action:not(.primary):hover { color: var(--text); } +} + +@media (max-width: 880px) { /* step 3 — one disclosure, every destination kept */ + header.site .site-nav { justify-content: flex-start; } + + /* + And it stops being the bar's shock absorber. Above this width the nav holds seven links and + `flex: 1 1 auto; min-width: 0` is what lets it give room back to the two ends; below it the + nav is one word and a chevron, and the same rule let flexbox squeeze that word to 33px — the + German bar rendered "Men" with the chevron cut off, on the only control that reaches the other + six pages. The ends are sized by their content and the middle was the only thing that could + yield, so a longer word at either end came out of the one item that must never shrink. + */ + header.site .site-nav { flex: 0 0 auto; } +} + +/* + Step 4 — the right cluster runs out too. `submit` joins the menu, which is where the markup + already carries it; `about` and `sign in` stay, the first because it is two words and the second + because it is the one item at that end that changes what the site will let a reader do. Still no + destination lost. +*/ +@media (max-width: 640px) { + header.site { padding: 0 14px; gap: 12px; } + header.site nav.account a.action:not(.primary) { display: none; } + header.site form.theme { gap: 6px; } +} + +/* + Step 5 — below a small phone the type gives way before anything else does. Nothing is dropped and + nothing moves; the two clusters at the ends are simply set a step smaller, which is the last + decoration the bar has left to spend. +*/ +@media (max-width: 460px) { + header.site { padding: 0 8px; gap: 6px; } + header.site .mark { font-size: 15px; } + header.site form.theme { gap: 4px; } + header.site form.theme button { font-size: 12px; } + header.site nav.account { gap: 6px; } + header.site nav.account a { font-size: 12.5px; } + header.site nav.account a.action { padding: 5px 8px; } +} + +/* + Step 6 — and the bar is measured in a language, not in English. + + Every step above was tuned against a bar reading "menu · about · submit · sign in · auto light + dark", which is the shortest this chrome is ever going to be. The same bar in German is "Menü · + über uns · anmelden · auto hell dunkel" and wanted seventeen more pixels than a 430px phone has; + Dutch was twelve pixels from the same wall. So the last step is a real one rather than a hedge, + and it spends the only thing left that is not a destination: the theme control's three words + become the row's tightest type, and the wordmark gives up a point with them. Nothing moves and + nothing is dropped — the German bar and the English bar hold the same seven pages either side of + it, which is the property the whole ladder exists to keep. +*/ +/* + Step 7 — and on the narrowest phone the bar stops being one line, for whichever language needs it. + + At 360px the German bar wants 412px: "mu*index · Menü ▾ · über uns · anmelden · auto hell dunkel". + Everything above this step spends decoration and the ladder has none left — and the item that fell + off the end was the theme control, clipped past `.shell`'s `overflow-x: clip` and so not merely + cramped but gone. A reader on a 360px phone in German could not change the theme at all. + + No width triggers this and no language is named in it: the bar already wraps on content, so the + theme control takes a second row exactly where it stops fitting. German and Dutch reach it at 360 + and English and Chinese do not, which is the ladder working rather than a rule about German. The + one thing set here is what happens *after* a wrap — the control keeps the trailing edge it has on + one line, so the bar does not appear to change its mind about which end it belongs to. +*/ +header.site form.theme { margin-inline-start: auto; } + +@media (max-width: 480px) { + header.site .mark { font-size: 14px; letter-spacing: 0; } + header.site form.theme { gap: 4px; } + header.site form.theme button { font-size: 11.5px; } + header.site nav.account a { font-size: 12px; } +} /* ── the reader's theme ──────────────────────────────────────────────────── Buttons drawn as the links beside them, because they belong to that row and a row of three real @@ -281,7 +794,10 @@ header.site nav.account a { color: var(--text); } brighter: a state carried by colour alone is a state a greyscale display does not carry, which is the same rule the heatmap's hatch exists for. */ -form.theme { display: flex; align-items: baseline; gap: 8px; margin: 0; } +/* Centred in the bar rather than baseline-aligned to it: the bar stretches its children to 60px, + and a baseline inside a stretched box lands wherever the box's first line happens to be — which + put this cluster sixteen pixels above the buttons beside it. */ +form.theme { display: flex; align-items: center; gap: 8px; margin: 0; } form.theme .kick { font: 10px/1 var(--kick); letter-spacing: 0.16em; @@ -312,19 +828,55 @@ pre.plain { white-space: pre-wrap; font-size: 13px; line-height: 1.6; color: var /* ── home ──────────────────────────────────────────────────────────────── */ -.hero h1 { font-size: 28px; max-width: 24ch; line-height: 1.2; } +.page-flow { display: flex; flex-direction: column; gap: 22px; } + +/* + The heading runs to whatever width it has. It carried a 24ch clamp, which broke "A directory of + the MU* hobby" across two lines at every width including 1440 — a measure is for paragraphs, and + this is one short line whose whole job is to be read at a glance. +*/ +.hero-head { display: flex; flex-direction: column; gap: 8px; } +.hero h1 { font-size: 26px; line-height: 1.2; letter-spacing: -0.01em; margin: 0; } -form.search { display: flex; gap: 8px; max-width: 560px; margin: var(--cpad) 0; } -form.search input { flex: 1; min-width: 0; } +/* The one sentence a stranger needs before they read a count. Dimmed a step so the heading above it + stays the first thing the eye takes, and measured so it never runs past a comfortable line. */ +.hero .lede { font-size: 14.5px; line-height: 1.6; color: var(--dim); max-width: 62ch; margin: 0; } +/* + One field with the search glyph inside it, and the submit beside it at the field's own height. + The two used to be separate boxes at two different sizes, which read as a form rather than as the + one control it is. +*/ +form.search { display: flex; gap: 8px; max-width: 520px; margin: 0; } +.search-field { + flex: 1 1 auto; min-width: 0; + display: flex; align-items: center; gap: 10px; + background: var(--recessed); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 0 14px; +} +.search-field:focus-within { border-color: color-mix(in srgb, var(--accent) 55%, var(--line)); } +.search-glyph { color: var(--accent); font-size: 15px; line-height: 1; } input[type="search"], input[type="text"] { background: var(--recessed); border: 1px solid var(--line); color: var(--text); font: 14px var(--sans); padding: 8px 11px; - border-radius: 6px; + border-radius: var(--radius); +} + +/* After the generic input rule, not before it: the field's own box is the bordered thing here and + the input inside it is only the caret. Ordered the other way round the generic rule won on equal + specificity and drew a second border inside the first. */ +.search-field input[type="search"] { + flex: 1; min-width: 0; + background: none; border: 0; padding: 11px 0; border-radius: 0; + color: var(--text); font: 14px var(--sans); } +.search-field input[type="search"]:focus { outline: none; } +.search-field input[type="search"]::placeholder { color: var(--faint); } button { background: var(--raised); @@ -332,95 +884,268 @@ button { color: var(--text); font: 14px var(--sans); padding: 8px 14px; - border-radius: 6px; + border-radius: var(--radius); cursor: pointer; } -ul.counts { +/* ── the four figures ────────────────────────────────────────────────────── + A label and a value, in a framed tile, at the size a front page's whole claim deserves. They were + one 12px mono line reading like a caption, which is what a count of the hobby is not. */ + +ul.tiles { list-style: none; margin: 0; padding: 0; - display: flex; flex-wrap: wrap; gap: 20px; font-size: 12px; color: var(--dim); + display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 12px; +} +ul.tiles li { + border: 1px solid var(--line-soft); border-radius: var(--radius-lg); + background: var(--surface); box-shadow: var(--shadow); padding: 16px; + display: grid; gap: 8px; align-content: start; +} +ul.tiles a { text-decoration: none; display: grid; gap: 8px; } + +/* + The value stays in the machine face. The drawing sets it in the UI sans, and the handoff's own + token rules say the opposite two pages earlier — "mono for anything the machine said: counts, + versions, ages … the one typographic rule carrying meaning". A count of the hobby is the purest + case of a machine-said value on this site, so the prose wins over the component. The size is the + drawing's. +*/ +ul.tiles .kicker { color: var(--faint); } +ul.tiles .figure { + font-size: 28px; font-weight: 600; letter-spacing: -0.01em; color: var(--text); + line-height: 1; } -ul.counts strong { color: var(--text); font-weight: 400; } +ul.tiles .figure.state-present { color: var(--accent); } /* The crawler strip, under the counts it is the provenance of. A step smaller and dimmer than they are, because it is about the instrument rather than about the hobby — a reader who never notices it has lost nothing, and one who comes looking for it knows where to look. */ +/* Hairlines top and bottom, as the handoff frames it: the strip is a band of provenance under the + figures it qualifies, and a band reads as one thing where a loose line reads as a footnote. */ p.crawler { - margin: 10px 0 0; font-size: 11px; + margin: 0; padding: 12px 0; font-size: 12.5px; + display: flex; align-items: center; gap: 10px; + border-top: 1px solid var(--line-soft); border-bottom: 1px solid var(--line-soft); } +p.crawler .state-present { text-shadow: var(--glow); } .feeds { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); - gap: var(--cpad); - margin: calc(var(--cpad) * 2) 0; + gap: 22px; + margin: 0; } +/* A column: its heading, then rows. The heading carried a 33.6px top margin from the global h2 + rule, which put each of the three columns a block lower than the rhythm the page is set on. */ +.feeds .feed { display: flex; flex-direction: column; } +.feeds .feed h2 { margin: 0 0 10px; } +.feeds .feed-lit h2 { color: var(--accent); } +.feeds .feed-dark h2 { color: var(--amber); } + /* ── liveness feed cards ─────────────────────────────────────────────────── One shape, three registers. Neutral, unlit, and — for a return — the one place the site raises its voice, because a game dark for two years answering is what no incumbent can tell you. */ -.feed-card { - border: 1px solid var(--line); - background: var(--recessed); - border-radius: 8px; - padding: 12px 14px; - margin-bottom: 10px; +/* A row: name left, age right, a hairline between. Three columns of boxed cards gave a name and an + age the furniture of a section, and framed things on this site are supposed to mean something — + the connect screen is quoted in a frame because it is somebody else's. */ + +.feed-row { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 12px; + padding: 9px 0; + border-bottom: 1px solid var(--line-soft); } -.feed-card .feed-name { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; margin-top: 6px; } -.feed-card .feed-name a { font-size: 15px; font-weight: 600; text-decoration: none; } -.feed-card .detail { font-size: 11.5px; color: var(--dim); margin: 4px 0 0; } -.feed-card.unlit { opacity: 0.78; } -.feed-card.unlit .feed-name a { color: var(--dim); } +/* The last row of a column closes on the column's own edge rather than on a rule to nothing. */ +.feeds .feed-row:last-child { border-bottom: 0; } -.feed-card.returned { - border-color: color-mix(in srgb, var(--accent) 34%, transparent); - background: color-mix(in srgb, var(--accent) 10%, transparent); - animation: return-glow 1.4s ease-out 1; +.feed-row .feed-name { + min-width: 0; font-size: 14px; color: var(--text); text-decoration: none; +} +.feed-row .feed-name:hover { text-decoration: underline; text-underline-offset: 3px; } +.feed-row .feed-state { + flex: none; display: inline-flex; align-items: baseline; gap: 5px; + font-size: 12.5px; color: var(--faint); +} +.feed-row .feed-state .sep { color: var(--faint); } +.feed-row.unlit .feed-name { color: var(--dim); } + +/* The return is the same row as the other two. The accent is on the word that says it is back and + nowhere else — a tinted card with a glow gave one column furniture the other two did not have, + and the eye cannot run down three lists that are drawn as different kinds of thing. */ +.feed-row.returned .feed-state .state-present { color: var(--accent); } + +/* ── the card's own footer ───────────────────────────────────────────────── + The same row on every page: where else to go, and the text mirror last. A rule above it, because + it is the end of the card and not another block in the column. */ + +.card-footer { + display: flex; flex-wrap: wrap; gap: 16px; + margin: 0; padding-top: 14px; + border-top: 1px solid var(--line-soft); + font-size: 13px; color: var(--dim); +} + +/* On a card page the footer is a band of the card rather than a block in a padded column, so it + pads itself to the same edge every other band uses. */ +.card-page > .card-footer { padding: 14px var(--gutter) 22px; } +.card-footer { align-items: baseline; } +.card-footer a { text-decoration: none; } +.card-footer a:hover { text-decoration: underline; text-underline-offset: 3px; } +.card-footer .sep { color: var(--faint); } + +/* + The language switcher, at the far end of the footer beside the plain-text link — the two controls + that change how the page is read rather than which page it is. Pushed there rather than placed, so + the destinations keep the left edge they had. +*/ +form.locale { display: flex; align-items: center; gap: 10px; margin: 0 0 0 auto; } + +/* The label is the theme control's kicker, because these two sit side by side and are the same kind + of thing: a small named control that changes how the page is read. */ +form.locale .kick { + font: 10px/1 var(--kick); + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--faint); } -/* Motion budget, one of three: once, on arrival, never repeating. */ -@keyframes return-glow { - from { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 55%, transparent); } - to { box-shadow: 0 0 0 14px transparent; } +/* + One box, drawn as the search field is: the control and its submit inside a single hairline rather + than a native dropdown and a wide button beside it. The footer is otherwise text, and two heavy + rectangles in it read as the loudest thing on the page — which a language switcher is not. +*/ +.locale-field { + display: flex; align-items: center; gap: 2px; + background: var(--recessed); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 0 4px 0 11px; +} +.locale-field:focus-within { border-color: color-mix(in srgb, var(--accent) 55%, var(--line)); } + +/* Stripped of the platform's own chrome so it matches the site rather than the OS — a native select + is a different shade, a different radius and a different caret on each of the three, and none of + them is this one. */ +form.locale select { + appearance: none; + background: none; border: 0; border-radius: 0; margin: 0; + padding: 7px 18px 7px 0; + color: var(--text); font: 13px var(--sans); + cursor: pointer; + + /* Sized to the option in force rather than to the longest one in the list. A select is as wide + as its widest child by default, so "English" sat in a box cut for "Русский (CI canary)" and + the caret ended up a hand's width from the word it belongs to. Where this is not supported the + box is merely the old width, which is the behaviour it replaces — nothing is lost by it. */ + field-sizing: content; +} +/* + The ring goes round the box, not round the flat control inside it. Stripping `appearance` takes the + platform's focus ring with it, and the border tint below is a mouse affordance rather than an + indicator a keyboard reader can rely on — 2.4.7 wants something that survives at any contrast. +*/ +form.locale select:focus { outline: none; } +.locale-field:has(select:focus-visible) { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +/* The nav's chevron, not a new glyph. Pulled back over the select's right padding and transparent to + the pointer, so the caret is part of the same click target rather than a dead spot beside it. */ +.locale-caret { + margin-left: -16px; + color: var(--faint); font-size: 11px; line-height: 1; + pointer-events: none; +} + +/* The search's own arrow. A submit is needed because this site runs no script, and this is the + smallest shape that says "commit" in a vocabulary the reader has already met. */ +.locale-go { + flex: none; display: flex; align-items: center; justify-content: center; + width: 22px; height: 22px; padding: 0; + background: none; border: 0; border-radius: 6px; + color: var(--faint); font-size: 13px; cursor: pointer; } +.locale-go:hover { color: var(--accent); background: var(--raised); } + -.feed-card.returned .return-line { font-size: 15px; line-height: 1.5; color: var(--text); margin: 6px 0 0; } /* ── game page ─────────────────────────────────────────────────────────── */ -.game-head { display: flex; gap: var(--cpad); flex-wrap: wrap; align-items: flex-start; } -.game-head .game-title { flex: 1; min-width: 260px; } +/* The header band: padded like every other band on a card page, with a rule under it running the + card's full width. */ +.game-head { + display: flex; gap: 20px; flex-wrap: wrap; + align-items: flex-start; justify-content: space-between; + padding: 24px var(--gutter); + border-bottom: 1px solid var(--line-soft); +} + +.game-identity { display: flex; gap: 16px; align-items: flex-start; min-width: 0; flex: 1 1 340px; } +.game-head .game-title { display: flex; flex-direction: column; gap: 6px; min-width: 0; } +.game-head .game-title h1 { margin: 0; } +.game-head .claim-line { margin: 0; font-size: 13.5px; } .game-head .plate { - width: 68px; height: 68px; flex: none; - border: 1px solid var(--line); border-radius: 8px; + width: 64px; height: 64px; flex: none; + border: 1px solid var(--line-soft); border-radius: 12px; background: var(--recessed); color: var(--dim); display: grid; place-items: center; font-size: 22px; + padding: 8px; box-sizing: border-box; +} + +/* + The count and the probe that produced it, as one block. It was a number and a provenance chip on + one line at body size, which set the fact most readers came for at the size of the metadata + qualifying it. +*/ +.game-figure { + flex: 0 0 auto; display: flex; flex-direction: column; align-items: flex-end; gap: 6px; + text-align: right; } +.game-figure .figure-count { + margin: 0; font-size: 20px; color: var(--accent); + text-shadow: var(--glow); font-variant-numeric: tabular-nums; +} +.game-figure .figure-count .unit { font-size: 15px; } + +/* 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; } +.game-figure .figure-count.unknown { + color: var(--faint); text-shadow: none; font-size: 15px; font-variant-numeric: normal; +} +.game-figure .figure-age { margin: 0; } + +/* One column, and the sections spaced by it rather than by margins that disagree. */ +.game-body { display: flex; flex-direction: column; gap: 28px; padding: 28px var(--gutter) 8px; } +.game-body > section { display: flex; flex-direction: column; gap: 10px; } +.game-body > section > h2 { margin: 0; font-size: 17px; } /* An icon sits in the monogram's place and at its size. `contain` rather than `cover`: a logo that is not square is letterboxed rather than cropped, because cropping somebody's artwork to fit our box is a small edit to a thing they published. The plate's own background shows through where the image does not reach, so a transparent PNG lands on the same recessed square as the monogram. */ .game-head img.plate { object-fit: contain; padding: 4px; } -.game-head .connect { flex: none; min-width: 240px; } -.game-head .lede { font-size: 15px; line-height: 1.65; max-width: 62ch; } -.game-head .tagline { font-size: 16px; } -.game-head .live { font-size: 14px; } +.game-head .lede { font-size: 14.5px; line-height: 1.6; max-width: 62ch; margin: 0; color: var(--dim); } +.game-head .tagline { font-size: 15px; margin: 0; } -/* The count's label, beside the count. Spaced in CSS rather than by a space in the markup, because - the renderer trims whitespace around a conditional — and sized to the line it qualifies. */ -.game-head .live .chip { margin-left: 6px; } -.game-head .live .chip .age { font-size: inherit; } +/* + An address is a line, not a card. Each was a bordered panel the full width of the title column, + which gave a hostname the furniture of the one framed thing on this page — somebody else's output, + quoted. +*/ .game-head .endpoint { - background: var(--raised); border: 1px solid var(--line); border-radius: 6px; - padding: 7px 10px; font-size: 12.5px; margin-bottom: 6px; + display: flex; flex-wrap: wrap; align-items: baseline; gap: 12px; + margin: 0; padding-top: 4px; + font-size: 13.5px; color: var(--text); } -/* Same reason as the chip above, and it shipped as "penultimatemush.com 9500answering here since - Jul 2026": the renderer trims the whitespace between the port and a conditional span, so the gap - has to be a margin rather than a space in the markup. */ -.game-head .endpoint > span { margin-left: 8px; } +.game-head .endpoint .faint { font-size: 12.5px; } +.game-head .endpoint.former .address { color: var(--dim); } .badges { margin: 4px 0 6px; } .badge.claimed { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 34%, transparent); } @@ -433,6 +1158,7 @@ p.crawler { } .archive-plate strong { color: var(--text); font-weight: 500; } +/* Kept for any surface that still wants two columns; the game page is one column now. */ .two-up { display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(0, 0.85fr); gap: calc(var(--cpad) * 2); } /* @@ -441,7 +1167,7 @@ p.crawler { on it was clipped, while .heat-wrap's own overflow-x sat there doing nothing. The only region allowed to scroll sideways is the ANSI frame. */ -.two-up > *, .feeds > *, .row-main > * { min-width: 0; } +.two-up > *, .feeds > *, .row-main > *, .game-body > * { min-width: 0; } .section-head { display: flex; align-items: baseline; justify-content: space-between; gap: var(--cpad); flex-wrap: wrap; } .section-head .count { font-size: 12px; margin: 0; } @@ -556,8 +1282,14 @@ nav.spans .span { padding: 6px 10px; border: 1px solid var(--line); border-radius: 999px; color: var(--dim); text-decoration: none; } -nav.spans .span:hover { border-color: var(--dim); color: var(--text); } -nav.spans .span.on { border-color: var(--accent); color: var(--accent); } +nav.spans a.span:hover { border-color: var(--dim); color: var(--text); } + +/* The pressed one, which is a span rather than a link to the page you are on. Tinted as well as + outlined, because with no hover to distinguish it the border alone was doing all the work. */ +nav.spans .span.on { + border-color: var(--accent); color: var(--accent); + background: color-mix(in srgb, var(--accent) 10%, transparent); +} /* The shape sits under the range and is the smaller question of the two, so it follows immediately rather than being spaced as a second block. */ nav.spans.shapes { margin-top: -4px; } @@ -569,11 +1301,37 @@ details.read-as-text[open] > *:not(summary) { animation: reveal 120ms ease-out; /* Motion budget, three of three. */ @keyframes reveal { from { opacity: 0; } to { opacity: 1; } } -ul.perday, ul.spells { +ul.spells { list-style: none; margin: 6px 0 0; padding: 0; font-family: var(--mono); font-size: 12px; color: var(--dim); } -ul.perday li, ul.spells li { padding: 2px 0; } +ul.spells li { padding: 2px 0; } + +/* The grid's text alternative: seven rows rather than a hundred and sixty-eight cells. Numbers are + tabular so the columns scan, and the caption sits under the table it describes. */ +table.perday { + width: 100%; margin: 6px 0 0; border-collapse: collapse; + font-family: var(--mono); font-size: 12px; color: var(--dim); + caption-side: bottom; +} +table.perday caption { padding: 8px 0 0; color: var(--faint); text-align: left; } +table.perday th, table.perday td { padding: 4px 8px 4px 0; text-align: left; } +table.perday thead th { + font: 10px/1 var(--kick); letter-spacing: 0.16em; text-transform: uppercase; color: var(--faint); + border-bottom: 1px solid var(--line); padding-bottom: 6px; +} +table.perday tbody th { color: var(--text); font-weight: 600; } +table.perday td.num { font-variant-numeric: tabular-nums; } + +/* Not enough measured days to draw a week. A panel rather than a bare line, so the section reads as + answered rather than as a graphic that failed to load. */ +.sparse { + margin: 10px 0 var(--cpad); padding: calc(var(--cpad) * 1.5) var(--cpad); + border: 1px dashed var(--line); border-radius: 8px; + background: var(--recessed); color: var(--dim); text-align: center; +} +.sparse p { margin: 0; } +.sparse .kicker { margin-bottom: 6px; } /* ── availability strip ──────────────────────────────────────────────────── Ninety bars, oldest left. Degraded is a *short* bar and not merely another colour; unreachable @@ -600,19 +1358,50 @@ dl.stats dd { margin: 3px 0 0; font-size: 17px; } /* ── the ANSI quotation frame ──────────────────────────────────────────── */ -figure.ansi .quote { position: relative; background: #000; } -figure.ansi .quote pre { - margin: 0; padding: var(--cpad); - font: 13px/1.5 var(--mono-cjk); font-variant-ligatures: none; - color: #aaaaaa; white-space: pre; overflow-x: auto; +/* Every row, once, in a region that scrolls — a screen twice the height of the frame is scrolled + rather than cropped and offered again below. The cap is about 26 rows, which is what a terminal + showed the player; past that the reader scrolls the same way they would have on connecting. + Focusable, because a scrolling region a keyboard cannot reach is one a keyboard cannot read. */ +figure.ansi .quote { + position: relative; background: #000; + border-radius: 6px; + max-height: 33em; overflow: auto; overscroll-behavior: contain; } -figure.ansi .crop-fade { - position: absolute; left: 0; right: 0; bottom: 0; height: 56px; - background: linear-gradient(to bottom, rgba(0, 0, 0, 0), #000); - pointer-events: none; +figure.ansi .quote:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } +figure.ansi .quote pre { + margin: 0; padding: 14px 16px; + font: 12px/1.4 var(--mono-cjk); font-variant-ligatures: none; + color: #aaaaaa; white-space: pre; } figure.ansi figcaption { display: flex; gap: 8px; flex-wrap: wrap; } -details.read-as-text .ansi { border: 1px solid var(--line); } + +/* + ── the words, in the same figure ────────────────────────────────────────── + The prose lines are what a player actually needs — the address and the connect/create/QUIT + instructions — and they were behind a disclosure outside the frame, which made the block a picture + with a footnote rather than a quotation with a reading. Under a hairline, with the kicker the + handoff names, and open: this is the one text copy of the art, and a text alternative a reader has + to open is one most readers never see. +*/ +.screen-text { + border-top: 1px solid var(--line-soft); + padding-top: 12px; +} +.screen-text > summary { cursor: pointer; list-style: none; } +.screen-text > summary::-webkit-details-marker { display: none; } +.screen-text > summary::after { content: " ▾"; color: var(--faint); letter-spacing: normal; } +.screen-text[open] > summary::after { content: " ▴"; } +.screen-text .kicker { color: var(--accent); } +.screen-text > summary:hover .kicker { color: var(--text); } +.screen-text > pre { margin-top: 8px; } +.screen-text pre { + margin: 0; + font: 12.5px/1.7 var(--mono-cjk); font-variant-ligatures: none; + color: var(--text); + white-space: pre-wrap; + overflow-wrap: anywhere; +} + pre.plain-screen { background: var(--recessed); padding: var(--cpad); overflow-x: auto; font-size: 12px; } .ansi-plate { @@ -637,28 +1426,54 @@ pre.plain-screen { background: var(--recessed); padding: var(--cpad); overflow-x ul.games, ul.archive { list-style: none; margin: 0; padding: 0; } +/* + The row pads itself and the hairline runs the column's full width. It used to hang on a negative + margin so a hover plane could reach past the reading measure — the results column owns its own + padding now, so the row simply is the width it is drawn at. +*/ ul.games > li, ul.archive > li { - border-bottom: 1px solid var(--line); - padding: var(--row-pad) var(--cpad); - - /* Negative margin so the hover plane runs to the reading measure's own edge and the text does - not shift when it appears — a row that moves under the pointer is a row you mis-click. */ - margin: 0 calc(var(--cpad) * -1); + border-bottom: 1px solid var(--line-soft); + padding: var(--row-pad) var(--gutter); + margin: 0; } -ul.games > li.game-row:hover { background: var(--surface); } +ul.games > li.game-row:hover { background: var(--raised); } + +/* The row is a touch target before it is a layout: the game's name is the link, and on a phone a + list of five hundred of them is a list of five hundred things to hit. The handoff's geometry — + 13px by 20px over a 46px floor — is what puts a comfortable target under every name. */ +ul.games > li.game-row { min-height: 46px; padding: var(--row-pad) var(--gutter); } /* Three columns: the game's face, what it is, and what we measured. The plate is what gives a five-hundred-row list a left edge the eye can run down — before it, every row began with a different-length name and the only way to find a game was to read them. `start` rather than `baseline` because a 36px box has no baseline worth aligning a name to. */ +/* Two zones on the row and a third under them: identity on the left, measurement on the right, and + the meta line spanning beneath the name. The handoff's geometry exactly. */ +/* + Three columns and two lines: identity, measurement, freshness across the top, and the machine + facts spanning beneath. The count and the age are siblings on one baseline — they were a nested + two-row grid in the third column, which stacked the age under the count and left neither able to + line up down the page. +*/ .row-main { display: grid; - grid-template-columns: auto minmax(0, 1fr) auto; - gap: 2px var(--cpad); - align-items: start; + grid-template-columns: minmax(0, 1fr) auto auto; + gap: 3px 20px; + align-items: baseline; } +/* + `contents` so the name and the metadata are grid items of the row itself rather than of a box + inside column one — that is what lets the metadata span all three columns while the name occupies + only the first. +*/ +.row-main .row-text { display: contents; } +.row-main .row-head { grid-column: 1; grid-row: 1; min-width: 0; } +.row-main .row-count { grid-column: 2; grid-row: 1; } +.row-main .row-seen { grid-column: 3; grid-row: 1; } +.row-main .meta { grid-column: 1 / -1; grid-row: 2; min-width: 0; } + /* The row's own plate. Same recessed square, same border, same monogram treatment as the game page's — one vocabulary at two sizes, so a reader who has seen the page recognises the row. */ .row-main .plate { @@ -683,9 +1498,9 @@ ul.games .name, ul.archive .name { font-size: 15px; font-weight: 600; text-decor ul.games .name:hover { text-decoration: underline; text-underline-offset: 3px; } ul.archive .name { color: var(--dim); } -/* The majority state of the catalogue, so it is the quiet word rather than the third pill. A badge - every row wears distinguishes no row from any other, and at 700 rows it is chrome. */ -ul.games .unclaimed { font: 10px/1 var(--kick); letter-spacing: 0.14em; text-transform: uppercase; color: var(--faint); } +/* No rule for "unclaimed": it is not on a row any more. The majority state of the catalogue said on + every row is an administrative fact about our records, repeated once per game at a reader looking + for somewhere to play. It stays on the game page, where it arrives with the way to change it. */ .row-text .tagline { margin: 2px 0 0; @@ -699,70 +1514,75 @@ ul.games .unclaimed { font: 10px/1 var(--kick); letter-spacing: 0.14em; text-tra white-space: nowrap; } -ul.games .meta, ul.archive .meta { margin: 3px 0 0; font-size: 11.5px; color: var(--faint); } +ul.games .meta, ul.archive .meta { margin: 3px 0 0; font-size: 12.5px; color: var(--dim); } ul.games .meta .protocol { white-space: nowrap; } +/* The protocols this row did not print. A count rather than a name, and the names are in its title + for a pointer and on the game's page for everybody. */ +ul.games .meta .protocol.more { color: var(--dim); } + /* A provenance chip on a listing or archive row. The chip's own age size is set for body copy, and these contexts are already smaller than that — so the age rides the row's size rather than growing larger than the text it qualifies. Everything else about the chip is unchanged, deliberately: the row, the archive and the game page have to be one vocabulary and not three that resemble each other. `.meta` is the class both lists give that line, so both are covered by naming it once. */ -.meta .chip .age, .row-figure .chip .age { font-size: inherit; } +.meta .chip .age { font-size: inherit; } + +/* ── the two measured columns ────────────────────────────────────────────── + Tabular figures so the counts line up down the page without a table, and right-aligned so the + units place is the edge the eye runs down. + + The count carries no unit and no provenance glyph. "on" after every number was one English word + repeated 515 times — the column is named once, above, which is also one string per locale rather + than five hundred — and where a number came from is a fact you weigh when choosing between two + games rather than while scanning five hundred, so it lives on the game page and on the facet + badges instead. */ -/* The measured column. Tabular figures so the counts line up down the page without a table. */ -.row-figure { +.row-count { margin: 0; + grid-column: 2; text-align: right; - font-size: 12px; white-space: nowrap; + font-size: 15px; font-variant-numeric: tabular-nums; - display: grid; - gap: 2px; - justify-items: end; + color: var(--text); } -/* ── the count, which is the fact most readers came for ──────────────────── - Players on now is a fact like any other and is labelled like any other — the glyph beside it means - measured or declared exactly as it does in every other column, because a second meaning for the - accent dot would cost it the first one. What sets it apart is scale and position, which carry no - meaning to lose: the number is 17px and nothing else in a row is above 15, so it is what the eye - lands on, and it sits at the head of a column that lines up down the page. - - The number's own weight then does the rest of the work. A count with somebody in it is at full - text colour; a measured zero is dimmed one step — still a measurement, still glyphed, still above - the break, and visibly not somewhere to go tonight. An unknown says so in words and is dimmer - again. Three states, three weights, no new colours. */ +/* Somebody is on. The one place a live count is marked as such, and the glow follows the digits + rather than a pip beside them. */ +.row-count.live { color: var(--accent); } -.row-figure .players { color: var(--text); display: block; } -.row-figure .players .count { font-size: 17px; font-weight: 600; letter-spacing: -0.01em; } -.row-figure .players .pip { margin-right: 5px; } +/* A measured zero: dimmed one step, never faint. We got in and nobody was there is a result, and + it is not the same fact as a count we could not read. */ +.row-count.empty { color: var(--dim); } -/* A count of nobody, measured. Dimmed rather than hidden — we got in and nobody was there is a - result, and the row above the break is where it belongs. */ -.row-figure .players.empty .count { color: var(--dim); font-weight: 400; } +/* Not a number at all, so it is not set as one: smaller, faint, and in words. */ +.row-count.unknown { color: var(--faint); font-size: 13.5px; font-variant-numeric: normal; } -/* The one place a live count is marked as such: a soft glow on the pip where somebody is actually on - right now. A text-shadow rather than a ring, because a ring is drawn on the glyph's inline box — - which is a tall narrow rectangle, not the dot — and rendered as a lozenge round a circle. This - follows the glyph itself at any font and any size. - - Static. Five hundred of these animating is a page that cannot be read, and the motion budget is - spent on the "came back" card. It is a reinforcement and never the carrier: the count, the glyph - and the word behind it all say the same thing without it. */ -.row-figure .players.live .pip.state-present { - text-shadow: 0 0 7px color-mix(in srgb, var(--accent) 60%, transparent); +.row-seen { + margin: 0; + grid-column: 3; + text-align: right; + white-space: nowrap; + font-size: 12.5px; + color: var(--faint); + font-variant-numeric: tabular-nums; } -/* The count's own chip rides beside the figure. Spaced in CSS and not by a space in the markup, - because the renderer trims whitespace around a conditional and "219 on" ran into its own glyph. */ -.row-figure .players .chip { margin-left: 6px; } -.row-figure .players .unit { color: var(--faint); } -.row-figure .players.unknown { color: var(--faint); } -.row-figure .seen { color: var(--faint); font-size: 11.5px; } - -/* What a window sort ranked this row on, printed on the row it ranked. Faint and small: it is the - basis for the order rather than a fact the row is about, and the count above it stays the figure. */ -.row-figure .window { color: var(--faint); font-size: 11.5px; } +/* + Claimed: a ring with a tick in it and no visible word. It was a pill the width of "claimed" on the + handful of rows that carry it — furniture for a fact a browsing reader did not come for. The name + is on the element, where a screen reader and a tooltip both reach it. +*/ +.claim-ring { + display: inline-flex; align-items: center; justify-content: center; + width: 16px; height: 16px; flex: none; + border-radius: 99px; + border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent); + background: color-mix(in srgb, var(--accent) 12%, transparent); + color: var(--accent); + font: 10px/1 var(--mono); +} /* Archived: dimmed one step, and that is the whole treatment. No red, no strikethrough, no "dead" — the entry is a library record for a periodical that ceased publication. */ @@ -788,26 +1608,84 @@ li.unranked-break { li.unranked-break:hover { background: var(--surface); } -dl.archive-facts { margin: 0; font-size: 12px; min-width: 220px; } +/* + The archive row is two zones, not the listing's three — it has no count column, and its facts + panel is a definition list rather than two cells. It shares .row-main with the listing for the + gap and the baseline, so it names its own columns rather than inheriting a third it has nothing + to put in. +*/ +ul.archive .row-main { grid-template-columns: minmax(0, 1fr) auto; } + +dl.archive-facts { margin: 0; font-size: 12px; min-width: 200px; } dl.archive-facts dd { margin: 0 0 6px; color: var(--dim); } +/* Below the width where a 200px facts panel and a game's metadata both fit, they stack — the panel + was holding its minimum and clipping the name and codebase beside it. */ +@media (max-width: 620px) { + ul.archive .row-main { grid-template-columns: minmax(0, 1fr); } + dl.archive-facts { min-width: 0; } +} + /* ── the listing's own header ────────────────────────────────────────────── */ -.listing-head { margin-bottom: var(--cpad); } -.listing-head p { margin: 0; max-width: 62ch; } +/* The card's own header band: padded like the drawing, with a rule under it running the full + width of the card rather than stopping at a content measure. */ +.listing-head { + margin: 0; + padding: 22px var(--gutter) 18px; + border-bottom: 1px solid var(--line-soft); + display: flex; flex-direction: column; gap: 7px; +} +.listing-head h1 { margin: 0; } +.listing-head p { margin: 0; max-width: 62ch; font-size: 14px; } + +/* ── the listing's toolbar ───────────────────────────────────────────────── + What order this is in on the left, the control that changes it and the two column names on the + right, over the columns they name. */ .listing-bar { display: flex; align-items: baseline; - gap: var(--cpad); + justify-content: space-between; + gap: 12px 20px; flex-wrap: wrap; - margin: calc(var(--cpad) * 1.5) 0 4px; + margin: 0; + padding: 11px var(--gutter); + border-bottom: 1px solid var(--line-soft); + font-size: 13px; + color: var(--dim); } -.listing-bar .result-count { margin: 0; font-size: 13px; } -.listing-bar .result-count strong { font-size: 19px; font-weight: 600; } .listing-bar .listing-order { margin: 0; } -.listing-bar .listing-order a { color: var(--faint); } +.listing-bar .listing-order .order-name { color: var(--text); } +.listing-bar .listing-order .sep { margin: 0 6px; color: var(--faint); } +.listing-bar .listing-order a { color: var(--dim); } + +.listing-tools { display: flex; align-items: baseline; gap: 20px; } +.listing-tools .column-head { margin: 0; } + +/* ── a pressed-state toggle group ────────────────────────────────────────── + Not links to the page you are already reading. The one in force is a with aria-current, so + there is no keystroke that lands on a control whose only effect is to re-fetch this page — the + same fix the trend range and shape selectors already carry. */ + +.switch { display: flex; align-items: baseline; gap: 8px; font-size: 12.5px; } +.switch a { color: var(--dim); text-decoration: none; } +.switch a:hover { color: var(--text); text-decoration: underline; text-underline-offset: 3px; } +.switch .on { color: var(--accent); } +/* A rule, not a wider gap: the two halves of the switch are different kinds of order — three that + change which measurement the column shows, and two that only change the stacking. */ +.switch .switch-rule { + width: 1px; height: 13px; align-self: center; margin: 0 2px; background: var(--line); +} + +/* The window under the statistic it belongs to, quieter than the switch that produced it. */ +.switch.windows { + padding: 8px var(--gutter); + border-bottom: 1px solid var(--line-soft); + font-size: 12px; + justify-content: flex-end; +} /* Nothing matched. A plate rather than a paragraph, because an empty listing is a state and not an @@ -829,9 +1707,25 @@ dl.archive-facts dd { margin: 0 0 6px; color: var(--dim); } /* ── one column below 900px; no horizontal scroll except inside the frame ── */ @media (max-width: 900px) { - .two-up, .feeds { grid-template-columns: minmax(0, 1fr); } - .game-head { flex-direction: column; } - header.site { flex-wrap: wrap; } + .feeds { grid-template-columns: minmax(0, 1fr); } + + /* + The theme control is the one thing in the bar that goes nowhere, so it is what the bar gives + up its words for first: three buttons become their initials' worth of room. + + Taken out of the drawing and left in the accessibility tree, because the form names itself + with `aria-labelledby="theme-label"` and this is that label. `display: none` removed it from + both, so below 900px a screen reader met three buttons in a group with no name at all — the + one width where the surrounding words that would have explained them are gone too. + */ + header.site form.theme .kick { + position: absolute; + width: 1px; height: 1px; + padding: 0; margin: -1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + } /* All 168 cells at 12px rather than a scrolling grid — the whole week at a glance is the reason @@ -933,7 +1827,22 @@ ul.sole-use-names { ul.sole-use-names li { color: var(--dim); } /* Wide content scrolls inside its own box; the page body never scrolls sideways. */ -.table-wrap { overflow-x: auto; margin: var(--cpad) 0; } +/* + A scroller contains its own absolutely positioned descendants — and until it did, the game page + scrolled sideways. + + `.sr-only` is `position: absolute`, and with no positioned ancestor its containing block was the + initial one: the text a screen reader reads out of the capability matrix was laid out at the + matrix's own width, 463px in German, *outside* this box and outside `.shell`'s `overflow-x: clip`. + So five spans nobody can see pushed the document 59px wider than a 360px phone, and the whole page + scrolled to reveal nothing. Visually-hidden is not the same as out of the layout, and one + `position: relative` is what makes the difference invisible in both directions. +*/ +.table-wrap { position: relative; overflow-x: auto; margin: var(--cpad) 0; } + +/* Inside a section that already spaces its children, the wrapper carries no margin of its own — + and it keeps the table's rounded frame from being clipped square by the scroll box. */ +.game-body .table-wrap { margin: 0; border-radius: var(--radius); } table.protocols, table.ranking { border-collapse: collapse; width: 100%; } table.protocols th, table.protocols td, @@ -981,13 +1890,12 @@ table.ranking tbody th { font-weight: 400; } is the only reason they are there. ═══════════════════════════════════════════════════════════════════════════ */ -form.facet-form { - border: 1px solid var(--line); - border-radius: 10px; - background: var(--surface); - padding: var(--cpad); - margin: 0 0 var(--cpad); -} +/* + No box. It was a panel on a page — border, radius, its own surface — and inside the card it became + a box within a box, fighting the hairline that already separates it from the rows. The column's + own edge is the separation; the form is just its contents. +*/ +form.facet-form { border: 0; background: none; padding: 0; margin: 0; } /* ── the bar ─────────────────────────────────────────────────────────────── */ @@ -995,7 +1903,58 @@ form.facet-form { The archive's own search box is the same control with a legend on it, so it is styled here rather than growing a second look for one question asked on two pages. */ -.filter-bar, fieldset.facets { +/* ── the panel, as one disclosure below 900px ────────────────────────────── + Above that width the summary is hidden and the content is forced open, so the same markup is a + column on a laptop and a disclosure on a phone with no second rendering and no script. + + Guarded on ::details-content: where a browser does not have it, forcing a closed open + from CSS is not possible, so the summary stays visible at every width — a "filters" disclosure at + the top of the column rather than an unreachable panel behind a hidden control. */ + +.facet-collapse { min-width: 0; } +.facet-collapse > summary { + display: flex; align-items: center; gap: 8px; + cursor: pointer; list-style: none; + padding: 9px 8px; margin-bottom: 4px; + border: 1px solid var(--line); border-radius: var(--radius); + font: 10px/1 var(--kick); letter-spacing: 0.16em; text-transform: uppercase; + color: var(--dim); +} +.facet-collapse > summary::-webkit-details-marker { display: none; } +.facet-collapse > summary:hover { color: var(--text); border-color: var(--dim); } +.facet-collapse > summary::after { + content: "▾"; margin-left: auto; font-size: 11px; letter-spacing: normal; +} +.facet-collapse[open] > summary::after { content: "▴"; } +.facet-collapse .summary-count { + font-family: var(--mono); font-size: 11px; letter-spacing: normal; color: var(--accent); +} +.facet-body { display: flex; flex-direction: column; gap: 24px; } + +@supports selector(::details-content) { + @media (min-width: 901px) { + .facet-collapse > summary { display: none; } + .facet-collapse::details-content { content-visibility: visible; block-size: auto; } + } +} + +/* One field, and nothing else — the sort and the two inclusion switches that used to share this row + are a toolbar switch and two facet rows now. */ +.filter-bar { display: block; margin: 0; } +.filter-bar .search-field { padding: 0 8px 0 12px; } +.filter-bar .search-field input[type="search"] { padding: 7px 0; font-size: 13px; } + +/* The submit, inside the field. A 268px column has no room for a second box on that row, and a + submit that exists only as the return key is one a pointer-only reader cannot press. */ +.search-go { + flex: none; display: flex; align-items: center; justify-content: center; + width: 22px; height: 22px; padding: 0; + background: none; border: 0; border-radius: 6px; + color: var(--faint); font-size: 13px; cursor: pointer; +} +.search-go:hover { color: var(--text); background: var(--raised); } + +fieldset.facets { display: flex; gap: 10px; align-items: center; @@ -1004,7 +1963,7 @@ form.facet-form { fieldset.facets { border: 1px solid var(--line); - border-radius: 10px; + border-radius: var(--radius-lg); background: var(--surface); padding: var(--cpad); margin: 0 0 var(--cpad); @@ -1017,7 +1976,8 @@ fieldset.facets legend { color: var(--faint); } -.filter-bar input[type="search"], fieldset.facets input[type="search"] { flex: 1 1 16rem; min-width: 0; } +.filter-bar input[type="search"] { min-width: 0; width: 100%; } +fieldset.facets input[type="search"] { flex: 1 1 16rem; min-width: 0; } .bar-field { display: inline-flex; align-items: center; gap: 6px; } .bar-label { font: 10px/1 var(--kick); letter-spacing: 0.16em; text-transform: uppercase; color: var(--faint); } @@ -1030,39 +1990,125 @@ fieldset.facets legend { auto-fit at a narrow floor, so eight facets pack four to a row on a laptop, two on a tablet and one on a phone without a breakpoint being written for any of them. */ -.facet-grid { +/* ── the listing's two columns ───────────────────────────────────────────── + What you are asking on the left, what came back on the right. `stretch` so the filter column's + own edge runs the full height of the card: aligned to its content it stops under a short panel + and leaves a well beside the rows. */ + +.listing-grid { display: grid; - grid-template-columns: repeat(auto-fit, minmax(178px, 1fr)); - gap: 10px var(--cpad); - margin-top: var(--cpad); + grid-template-columns: minmax(0, 268px) minmax(0, 1fr); + align-items: stretch; + gap: 0; + margin: 0; } -/* The control sits at the bottom of its cell, so a label that wraps grows upward and every select in - the row stays on one line. Left to itself the grid aligns the tops, and one long facet name puts - one control half a row below its neighbours. */ +/* + Placed explicitly, and that is load-bearing: the panel renders TWO siblings into this grid — the + form, and the row of active-filter chips that sits outside it because a chip is a link and not a + control. Left to auto-placement the chips took the second column and the listing dropped to the + first, under a filter column a thousand pixels tall — so applying a filter looked like a page that + had lost its results. +*/ +/* + The filter column is a surface a step below the card, so it reads as a column even where its rows + run out — the border alone left it as an unmarked area of the same fill as the results beside it. +*/ +.listing-grid > .facet-form { + grid-column: 1; + grid-row: 1 / span 99; + background: var(--recessed); + border-right: 1px solid var(--line-soft); + + /* Less 8px than the gutter, because every row inside pads itself by that — which is what puts + a facet label on exactly the same left edge as the wordmark above it. */ + padding: 20px max(6px, calc(var(--gutter) - 8px)); + display: flex; + flex-direction: column; + gap: 24px; +} +.listing-grid > * { grid-column: 2; min-width: 0; } +.listing-side { min-width: 0; } +.listing-grid > .active-filters { padding: 12px var(--gutter) 0; margin: 0; } + +/* + ── the filter column, narrow ────────────────────────────────────────────── + A disclosure above the listing rather than eleven hundred pixels of open panel to scroll past + before the first game. Closed by default and open whenever the reader has actually asked + something, because a filter in force that nothing shows is the defect this control replaces. +*/ +@media (max-width: 900px) { + .listing-grid { grid-template-columns: minmax(0, 1fr); } + .listing-grid > * { grid-column: 1; } + .listing-grid > .facet-form { + grid-column: 1; grid-row: auto; + border-right: 0; + border-bottom: 1px solid var(--line-soft); + } + .listing-grid > .active-filters { padding: 12px var(--gutter) 0; } + + /* Two columns of groups where there is room for them, so the opened panel is a band rather + than a column a reader has to scroll the length of. */ + .facet-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 24px; } +} + +/* In a 268px column the facets stack, one group per row. Twenty-four pixels between groups, which + is the panel's own rhythm and twice the gap between a group's header and its rows. */ +.facet-grid { + display: flex; + flex-direction: column; + gap: 24px; + margin: 0; +} + +/* A facet is a heading, a note and a list of rows. */ .facet { min-width: 0; - display: grid; - grid-template-rows: 1fr auto; - align-content: end; + display: flex; + flex-direction: column; + gap: 9px; + border: 0; + padding: 0; + margin: 0; } +/* + The header block sits at the rows' own left padding, so the group name, the note and every row + label share one left edge — which is the whole reason the rows are padded rather than the panel. +*/ .facet > label, .facet > legend { display: flex; - gap: 6px; - align-items: baseline; - flex-wrap: wrap; - margin-bottom: 3px; - padding: 0; + flex-direction: column; + gap: 3px; + margin: 0; + padding: 0 8px; + width: 100%; + box-sizing: border-box; +} + +.facet .facet-head { + display: flex; align-items: center; justify-content: space-between; gap: 8px; } .facet .facet-name { font: 10px/1.4 var(--kick); - letter-spacing: 0.14em; + letter-spacing: 0.16em; text-transform: uppercase; color: var(--dim); } +/* + The one sentence a group needs at the moment somebody uses it, under its own heading rather than + once at the foot of the panel — the three groups say three different things and the line that + used to serve all of them contradicted the first. +*/ +.facet-note { + margin: 0; + font-size: 12px; + line-height: 1.5; + color: var(--faint); +} + select { background: var(--recessed); border: 1px solid var(--line); @@ -1074,9 +2120,139 @@ select { .facet select { width: 100%; max-width: 100%; } -/* A facet that is doing something says so at rest, and not only in colour — the chip row above the - results names it in words, and the select's own text is the value it is set to. */ -.facet select.chosen { border-color: color-mix(in srgb, var(--accent) 45%, var(--line)); } +/* ── the three-state facet row ───────────────────────────────────────────── + Off, only, anything but — one row per value, and the state legible without opening anything. The + two radios are hidden and the glyph is drawn, but they are real radios: the browser's own group + behaviour is what makes this work with no script, and every state is a word in the accessible + name as well as a shape and a tint. */ + +.facet .rows { display: grid; gap: 2px; margin: 0; } + +/* + The row's geometry, and it is a grid rather than a flex run for one reason: every column has to + land in the same place on every row of every group. As a flex run the widest label in a group + pushed that group's count and exclude columns ten pixels right of every other group's — and past + the panel's own edge, which is what the audit measured as a 253px row inside a 243px box. + + 15px the state glyph + 1fr the value, which is the one cell allowed to give up width + auto the count, right-aligned against the 1fr's slack so every count shares an edge + 18px the exclusion, where a "remove" belongs +*/ +.facet-row { + display: grid; + grid-template-columns: minmax(0, 1fr) 18px; + align-items: center; + gap: 9px; + min-height: 30px; + padding: 0 8px; + border-radius: 9px; + font-size: 13.5px; + color: var(--dim); + border: 1px solid transparent; +} + +/* One ordered scale: no exclusion, so no column for one. */ +.facet-row.single { grid-template-columns: minmax(0, 1fr); } + +.facet-row .pick { + display: grid; + grid-template-columns: 15px minmax(0, 1fr) auto; + align-items: center; + gap: 9px; + min-width: 0; + cursor: pointer; +} +.facet-row .tick { text-align: center; color: var(--faint); font-family: var(--mono); font-size: 12px; } +.facet-row .row-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +/* + `--dim` rather than `--faint`, because the panel is not the page. + + `--faint` was raised to 4.8:1 once already, measured against the page background — and the facet + panel is a more recessed surface than that, where the same token falls to 4.09:1 in dark and + 4.37:1 in light. Both are under the 4.5:1 this site holds itself to, and both are carried by a + 12px number, which is the smallest text in the panel and the one a reader most needs to read. + Raising the token instead would have moved every age and provenance line on the site to fix a + contrast the page itself does not have. +*/ +.facet-row .count { + font-family: var(--mono); font-size: 12px; font-variant-numeric: tabular-nums; color: var(--dim); +} + +/* The exclusion affordance, at the far edge where a "remove" belongs. Quiet until it is the state + the row is in. */ +.facet-row .drop { + display: block; text-align: center; cursor: pointer; + color: var(--faint); font-family: var(--mono); font-size: 12.5px; +} +.facet-row .drop:hover { color: var(--text); } +/* Links, so a click applies the filter rather than arming a control that waits for a button. */ +.facet-row a { text-decoration: none; color: inherit; border-radius: var(--radius); } + +.facet-row:hover { background: var(--raised); } + +/* + A rung of a scale that this filter leaves empty. It stays — a bounded facet is four named + thresholds a reader picks between, and deleting the empty ones meant narrowing the codebase + silently removed two of activity's four rows and shifted the panel under the reader's pointer. It + is dimmed rather than disabled, because 0 is an answer: "no Evennia game was active this week" is + a fact about the catalogue, and clicking it lands on the listing's own empty state. +*/ +.facet-row.empty { opacity: 0.55; } +.facet-row.empty:hover { opacity: 1; } + +/* Included: the accent as a tint and a ring, and the count goes accent with the label — the number + is the part of the row that says what the filter will return, so it is the part that has to look + chosen. */ +.facet-row.on { + color: var(--accent-ink); + background: color-mix(in srgb, var(--accent) 12%, transparent); + border-color: color-mix(in srgb, var(--accent) 40%, transparent); +} +.facet-row.on .tick, .facet-row.on .count { color: var(--accent-ink); } +.facet-row.on:hover { background: color-mix(in srgb, var(--accent) 16%, transparent); } + +/* + Excluded: the danger tint, ring and strikethrough the handoff draws. + + Red is not a provenance colour here and never becomes one — it marks a state the reader put the + control into, not a state of the data. That is why nothing else on the site is red: the accent + means measured, amber means declared, and this means refused. +*/ +.facet-row.excluded { + color: var(--danger); + background: color-mix(in srgb, var(--danger) 10%, transparent); + border-color: color-mix(in srgb, var(--danger) 35%, transparent); +} +.facet-row.excluded .tick, .facet-row.excluded .count { color: var(--danger); } +.facet-row.excluded .row-name { + text-decoration: line-through; + text-decoration-color: color-mix(in srgb, var(--danger) 50%, transparent); +} +.facet-row.excluded .drop { color: var(--danger); } +.facet-row.excluded:hover { background: color-mix(in srgb, var(--danger) 14%, transparent); } + +/* The radios are hidden, so the focus ring belongs to the row that holds them. */ +.facet-row a:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } + +/* The tail. A summary is a control, so it gets the row's own height and hit area. */ +.facet .facet-tail { margin-top: 2px; } +.facet .facet-tail summary { + cursor: pointer; padding: 4px 8px; font-size: 12px; color: var(--faint); border-radius: 9px; +} +.facet .facet-tail summary:hover { color: var(--text); background: var(--raised); } + +/* Said once, under the groups it applies to. */ +.facet-hint { margin: 12px 0 0; font-size: 11.5px; color: var(--dim); } + +/* The facets a reader goes looking for, rather than the three they arrive with. */ +.facet-more-groups { margin: 0; border-top: 1px solid var(--line-soft); padding-top: 14px; } +.facet-more-groups > summary { + cursor: pointer; font: 10px/1 var(--kick); letter-spacing: 0.16em; text-transform: uppercase; + color: var(--faint); padding: 4px 8px; border-radius: var(--radius); +} +.facet-more-groups > summary:hover { color: var(--text); background: var(--raised); } +.facet-more-groups .facet-grid { margin-top: 14px; } /* ── the presence facet ──────────────────────────────────────────────────── */ @@ -1088,11 +2264,15 @@ select { fieldset.facet.presence { border: 0; padding: 0; - margin: var(--cpad) 0 0; + margin: 0; min-width: 0; } -fieldset.facet.presence .checks { display: flex; flex-wrap: wrap; gap: 6px; } +fieldset.facet.presence .checks { + display: flex; flex-wrap: wrap; gap: 6px; min-width: 0; padding: 0 8px; +} +fieldset.facet.presence .check { max-width: 100%; } +fieldset.facet.presence .check .check-name { overflow-wrap: anywhere; min-width: 0; } fieldset.facet.presence .check { display: inline-flex; @@ -1110,9 +2290,6 @@ fieldset.facet.presence .check:hover { border-color: var(--dim); } fieldset.facet.presence .check:has(:checked) { border-color: color-mix(in srgb, var(--accent) 45%, var(--line)); } fieldset.facet.presence .check .count { color: var(--faint); font-variant-numeric: tabular-nums; } -/* Where the unticked-is-not-a-no sentence lives: inside the fieldset it is about, at the moment of - ticking, rather than in a key at the bottom of the panel. */ -fieldset.facet.presence .hint { margin: 6px 0 0; font-size: 11.5px; color: var(--faint); max-width: 62ch; } /* Measured, declared, and the one thing we concluded ourselves — three registers, never the only carrier of the difference, because the chip spells each out in words as well and colour is not a @@ -1137,25 +2314,28 @@ fieldset.facet.presence .hint { margin: 6px 0 0; font-size: 11.5px; color: var(- a title attribute: this is the difference the whole site exists to publish, and it stays in the document whether or not a pointer ever hovers anything. */ +/* + One line and one disclosure. The three-row provenance legend that used to stand here is behind the + disclosure now: every group wears its own evidence badge, which is where the word is actually + needed — before a filter is applied rather than after the panel has been read to the bottom. It + also wrapped its descriptions right-aligned, which is what a flex row does to a text node it did + not expect to break. +*/ .facet-key { display: flex; - flex-wrap: wrap; - gap: 4px 18px; - align-items: baseline; - margin-top: var(--cpad); - padding-top: 10px; - border-top: 1px solid var(--line); - font-size: 11.5px; + flex-direction: column; + gap: 8px; + margin: 0 8px; + padding-top: 14px; + border-top: 1px solid var(--line-soft); + font-size: 12px; color: var(--faint); + text-align: left; } -.facet-key .key-item { display: inline-flex; gap: 5px; align-items: baseline; } -.facet-key .facet-more { margin-left: auto; } -.facet-key summary { cursor: pointer; color: var(--dim); } - -.facet-key .facet-more[open] { flex: 1 0 100%; margin-left: 0; margin-top: 6px; } -.facet-key .facet-more ul { margin: 6px 0 0; padding-left: 1.2em; max-width: 72ch; } -.facet-key .facet-more li { margin-bottom: 5px; color: var(--dim); } +.facet-key summary { cursor: pointer; color: var(--dim); font-size: 11.5px; } +.facet-key .facet-more ul { margin: 8px 0 0; padding-left: 1.2em; } +.facet-key .facet-more li { margin-bottom: 6px; color: var(--dim); line-height: 1.5; } /* ── what the query is currently asking for ──────────────────────────────── */ @@ -1202,18 +2382,16 @@ fieldset.facet.presence .hint { margin: 6px 0 0; font-size: 11.5px; color: var(- /* ── the panel and the rows, narrow ──────────────────────────────────────── */ +/* + Narrow rows keep all three columns and all three stay aligned. They used to collapse into a flex + run under the name, which put the age beside the count on some rows and under it on others — the + same list drawn two ways down one screen. The name gives up width first, which is the one cell + that can spare it, and the two right-hand columns hold their edge. +*/ @media (max-width: 620px) { - /* The bar stacks: a search box sharing a row with a sort and a toggle has room for none of them. */ - .filter-bar input[type="search"] { flex: 1 0 100%; } - .filter-bar button { margin-left: auto; } - - /* The measured column goes under the name rather than fighting it for a width neither has — - under the name and not under the plate, so the row keeps the left edge the plate gives it and - the figure lines up with the text it belongs to. */ - .row-main { grid-template-columns: auto minmax(0, 1fr); } - .row-main .plate { grid-row: span 2; } - .row-figure { text-align: left; justify-items: start; margin-top: 4px; } - .row-figure { display: flex; gap: 12px; flex-wrap: wrap; align-items: baseline; } + .row-main { gap: 3px 12px; } + .row-count { font-size: 14px; } + .row-count.unknown { font-size: 12.5px; } } /* ── reference and orientation pages ─────────────────────────────────────── The hand-written section (spec §9). Two things it has to communicate that no @@ -1375,3 +2553,283 @@ ol.events .faint { margin-right: 8px; } details.resign form { margin-top: 6px; } details.resign input[type="text"] { display: block; margin: 6px 0; max-width: 16ch; } + +/* ── find a game ─────────────────────────────────────────────────────────── + The questions on the left, the count on the right, and the count still on + screen when the last question is answered — which is the whole premise of + this page. Until this block existed the page had no rule of its own at all: + it was the one surface left on user-agent form styling, drawn full-bleed + with 2px groove borders and 5px of padding. */ + +.find-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 310px); + align-items: stretch; + gap: 0; + margin: 0; + + /* + `clip`, never `hidden`. An ancestor with overflow: hidden is a scroll + container, and a sticky element can only travel inside its own — so the + panel would pin for a few hundred pixels and then scroll away for the + rest, leaving the count invisible while questions four to six were + answered. That is the exact failure this page was redrawn to fix, and + `clip` clips without establishing the container. + */ + overflow: clip; +} + +.find-questions { min-width: 0; grid-column: 1; } + +/* ── one question ───────────────────────────────────────────────────────── + 24 above the heading, 14 under it, 24 to the next rule. Symmetric outside, + tighter inside, so the heading binds to the options it labels and the + questions separate from each other. The numbers are on the elements + themselves rather than on a container: a renders on the border + edge and outside the padding box, which is why the drawing's own first + attempt produced 13px above the heading and 2px below it. */ + +.find-q { + padding: 24px var(--gutter); + border-top: 1px solid var(--line-soft); +} + +.find-q-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + margin: 0 0 14px; +} + +/* The question is a heading, so a screen reader can jump between the six and + the page has an outline. h2's own rhythm belongs to prose sections and + would fight the 24/14 above. */ +.find-q-head h2 { font-size: 16px; font-weight: 600; margin: 0; color: var(--text); } + +.find-opts { display: flex; flex-wrap: wrap; gap: 10px 8px; } + +/* + An option chip. 40px is the floor, not the height: a long gloss wraps and + the chip grows rather than clipping it. The row gap is deliberately larger + than the column gap — equal gaps make wrapped rows read as a grid instead of + as rows. +*/ +.find-opt { + display: inline-flex; + align-items: center; + gap: 9px; + min-height: 40px; + padding: 8px 14px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--raised); + color: var(--dim); + font-size: 13.5px; + text-decoration: none; + transition: background var(--dur) var(--ease), border-color var(--dur) var(--ease); +} + +.find-opt .tick { font-family: var(--mono); font-size: 12px; color: var(--faint); } +.find-opt .count { font-size: 12px; color: var(--faint); } +.find-opt:hover { background: color-mix(in srgb, var(--accent) 8%, var(--raised)); } +.find-opt:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } + +/* Chosen: the tint, the ring and the count in the accent, because the number + is the part of the chip that says what answering will return. */ +.find-opt.on { + color: var(--accent); + background: color-mix(in srgb, var(--accent) 12%, transparent); + border-color: color-mix(in srgb, var(--accent) 45%, transparent); +} +.find-opt.on .tick, .find-opt.on .count { color: var(--accent); } + +/* The long tail. A summary is a control, so it gets a control's hit area. */ +.find-tail { margin-top: 10px; } +.find-tail > summary { + cursor: pointer; + display: inline-block; + padding: 6px 10px; + font-size: 12px; + color: var(--faint); + border-radius: var(--radius); +} +.find-tail > summary:hover { color: var(--text); background: var(--raised); } +.find-tail .find-opts { margin-top: 10px; } + +/* ── the name field ─────────────────────────────────────────────────────── + Label above the field, not beside it: beside it the field lost half the + column and a typed name could not be read back. */ + +.find-name { padding: 0 var(--gutter); margin: 0; } +.find-name label { display: block; margin-bottom: 8px; } +.find-name .search-field { max-width: 460px; } + +/* ── the count ──────────────────────────────────────────────────────────── + The outer cell carries the fill and the edge at full row height; the inner + wrapper is what sticks. Splitting the two is what lets the column read as a + column even where the panel is short. */ + +.find-panel { + grid-column: 2; + background: var(--recessed); + border-left: 1px solid var(--line-soft); + min-width: 0; +} + +.find-panel-inner { + position: sticky; + top: 20px; + display: flex; + flex-direction: column; + gap: 14px; + /* 24 at the top so the first line lands on the first question's heading. */ + padding: 24px 22px; +} + +.find-count { display: flex; align-items: baseline; gap: 8px; margin: 0; } + +.find-n { + font-size: 40px; + font-weight: 600; + line-height: 1; + color: var(--accent); + font-variant-numeric: tabular-nums; +} + +.find-noun { font-size: 13.5px; color: var(--dim); } +.find-basis { margin: 0; font-size: 12px; color: var(--faint); } + +.find-chips { display: flex; flex-wrap: wrap; gap: 6px; } + +.find-chip { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 5px 11px; + border: 1px solid var(--line); + border-radius: var(--radius-pill); + background: var(--raised); + color: var(--dim); + font-size: 12px; + text-decoration: none; + max-width: 100%; +} +.find-chip .chip-value { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.find-chip .chip-x { color: var(--faint); } +.find-chip:hover { color: var(--text); border-color: var(--dim); } + +/* + The way out of a combination that returned almost nothing. Quiet on purpose: + no amber, no warning box, no sentence about the result being narrow. The + reader can see the count; this is the one thing they cannot work out. +*/ +.find-loosen { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 8px 12px; + border: 1px dashed var(--line); + border-radius: var(--radius); + color: var(--dim); + font-size: 12.5px; + text-decoration: none; +} +.find-loosen:hover { color: var(--text); border-color: var(--dim); background: var(--raised); } + +/* The one accented control on the page, and it is a link: it navigates to the + listing with this question asked, and nothing here is a form to fire. */ +.find-go { + display: block; + padding: 11px 16px; + border-radius: var(--radius); + border: 1px solid color-mix(in srgb, var(--accent) 55%, transparent); + background: color-mix(in srgb, var(--accent) 14%, transparent); + box-shadow: var(--glow); + color: var(--accent); + font-size: 14px; + font-weight: 600; + text-align: center; + text-decoration: none; +} +.find-go:hover { background: color-mix(in srgb, var(--accent) 22%, transparent); } +.find-go:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } + +.find-clear { margin: 0; font-size: 12px; } +.find-clear a { color: var(--faint); } +.find-clear a:hover { color: var(--text); } + +/* + ── the panel, narrow ───────────────────────────────────────────────────── + The drawing has no responsive treatment and cannot be followed here: at + 430px its own grid crushes the question column to 139px while the panel + keeps all 310. So the panel stacks — above the questions, because the count + is the thing the page exists to show and putting it under six questions + would mean scrolling past every one of them to find out what they returned. + It stays sticky at the top of the viewport, which is the same promise the + side column makes at a width that has room for one. +*/ +@media (max-width: 860px) { + .find-grid { grid-template-columns: minmax(0, 1fr); } + .find-questions, .find-panel { grid-column: 1; } + + /* + Below the questions in the visual order because it is below them in the + DOM, and a column that disagrees with the tab order is a worse answer + than a long page. It sticks to the *bottom* instead of the top, which + keeps the same promise from the other end: the count and the submit are + on screen while the last question is being answered, without a reader + having to find them again afterwards. + */ + .find-panel { + border-left: 0; + border-top: 1px solid var(--line-soft); + position: sticky; + bottom: 0; + } + + /* + Laid across rather than down, because a bar pinned to the bottom of a + phone spends every pixel it takes: short controls share a line, so the + whole panel costs four rows with two answers given rather than seven. + + A wrapping row *in document order*, and that part is not cosmetic. This + was a two-column bar with the call to action pinned right and spanning + three rows, which put it first to the eye and fourth to the keyboard: + tabbing ran chips, loosen, then 114px back up the screen to the button. + Flex-wrap packs the same controls in the order they are written, so the + focus ring only ever travels forwards. + */ + .find-panel-inner { + position: static; + display: flex; + /* The base rule stacks; this is the one place the direction changes, and + leaving it out gives a centred column that reads as a broken card. */ + flex-direction: row; + flex-wrap: wrap; + align-items: center; + gap: 8px 12px; + padding: 12px var(--gutter); + } + + /* + The kicker labels a column, and this is a bar. The sub-line beside the + count says the same thing in more words, and the sentence a screen reader + is given — "2 games match every answer" — is untouched, so nothing is + lost that was only being carried here. + */ + .find-panel-inner > .kicker { display: none; } + + .find-chips { flex: 0 1 auto; } + .find-go { padding: 9px 14px; } + .find-n { font-size: 28px; } + + /* + The bar is pinned over the foot of the viewport, and a browser scrolling a + newly focused option into view does not know it is there — so tabbing down + the last question lands the focus ring behind the panel. `scroll-padding` + is the scriptless way to tell it, and `:has` is what keeps the rule on the + one page that has a bar rather than on every scroll on the site. + */ + html:has(.find-page) { scroll-padding-bottom: 200px; } +} diff --git a/tests/MUI.Catalog.Tests/AdultListingTests.cs b/tests/MUI.Catalog.Tests/AdultListingTests.cs index d8b76a0..0a0ed14 100644 --- a/tests/MUI.Catalog.Tests/AdultListingTests.cs +++ b/tests/MUI.Catalog.Tests/AdultListingTests.cs @@ -33,7 +33,13 @@ private static GameFacetRow Row(string slug, string? genre = null, bool adult = Codebase: "Evennia", Family: null, Genre: genre, - IsAdult: adult); + IsAdult: adult, + + // Every game here answered and was counted. The adult switch is what this suite is + // about, and a row that was also uncounted would put a second reason in front of the + // one being asserted. + Uncounted: false, + Unreachable: false); private static readonly GameFacetRow[] Catalogue = [ diff --git a/tests/MUI.Catalog.Tests/FacetPolarityTests.cs b/tests/MUI.Catalog.Tests/FacetPolarityTests.cs index d1988ce..fe6c4a3 100644 --- a/tests/MUI.Catalog.Tests/FacetPolarityTests.cs +++ b/tests/MUI.Catalog.Tests/FacetPolarityTests.cs @@ -173,7 +173,9 @@ private static GameListing Search(GameFilter filter) => Codebase: game.Codebase, Family: null, Genre: null, - IsAdult: false); + IsAdult: false, + Uncounted: false, + Unreachable: false); private static GameSummary Game(string slug, string? codebase) => new( Guid.NewGuid(), slug, slug, null, LifecycleState.Active, IsClaimed: false, diff --git a/tests/MUI.Catalog.Tests/FacetedSearchTests.cs b/tests/MUI.Catalog.Tests/FacetedSearchTests.cs index ea5e2ed..5a26de2 100644 --- a/tests/MUI.Catalog.Tests/FacetedSearchTests.cs +++ b/tests/MUI.Catalog.Tests/FacetedSearchTests.cs @@ -24,7 +24,9 @@ private static GameFacetRow Row( string? charset = null, bool tls = false, DateTimeOffset? lastReachableAt = null, - string[]? protocols = null) + string[]? protocols = null, + bool uncounted = false, + bool unreachable = false) { var summary = new GameSummary( Guid.NewGuid(), slug, slug, Tagline: null, LifecycleState.Active, IsClaimed: false, @@ -43,7 +45,12 @@ private static GameFacetRow Row( // Adult content has its own suite (AdultListingTests). Every row here declares none, so // these counts are taken over the whole set and are not quietly shaped by that default. - IsAdult: false); + IsAdult: false, + + // Both default to false, so a row is a game we reached and counted unless a test says + // otherwise — the ordinary case, and the one every count in this file is taken over. + uncounted, + unreachable); } private static FacetGroup Group(GameListing listing, string key) => @@ -213,7 +220,12 @@ public async Task NeverReachedIsItsOwnBandAndNotTheOldestOne() null), ActivityBand.Dark, FacetedSearch.LastSeenOf(null, Now), - false, null, null, null, null, null, false), + false, null, null, null, null, null, false, + + // Never once reached, so unreachable — and NOT uncounted, because we hold no + // presence row for it at all. Naming a cause for that is what rule 2 forbids. + Uncounted: false, + Unreachable: true), ]; await Assert.That(FacetedSearch.LastSeenOf(null, Now)).IsEqualTo(LastSeenBand.Never); @@ -371,10 +383,145 @@ public async Task OneGamesCapitalisationDoesNotNameAValue() await Assert.That(codebase.Count).IsEqualTo(3); } + // ── what we could measure ──────────────────────────────────────────────────────────────── + + /// + /// The set these four rows describe, once, so every assertion below is about the same catalogue. + /// + /// + /// The first two are the whole point. zero is a game we got into and counted + /// nobody in, and unreadable is a game we got into and could not count at all. Both sit in + /// — the band cannot tell them apart, which is why these facets + /// exist — and only the second is uncounted. gone is the third state twice over: never + /// reached, and with nothing measured to be uncounted about. + /// + private static GameFacetRow[] Measured() => + [ + Row("zero", band: ActivityBand.Quiet, genre: "Fantasy"), + Row("unreadable", band: ActivityBand.Quiet, genre: "Fantasy", uncounted: true), + Row("busy", genre: "Historical"), + Row("gone", band: ActivityBand.Dark, genre: "Fantasy", + lastReachableAt: Now.AddDays(-90), unreachable: true), + ]; + + [Test] + public async Task AGameMeasuredAtZeroIsNotUncounted() + { + // The failure this facet was written to make impossible. A measured nought is a count — we + // got in and nobody was there (rule 2) — and it shares an activity band with a game whose + // every WHO was past our parser. A filter that returned both under a word meaning the second + // would publish our own parser's limits as somebody's empty game. + var rows = Measured(); + + var uncounted = FacetedSearch.Search(rows, new GameFilter { Uncounted = FacetChoice.Of(FacetTokens.Yes) }); + + await Assert.That(uncounted.Games.Select(g => g.Slug)).IsEquivalentTo(new[] { "unreadable" }); + + // And both are still in the band, which is the band being right rather than the facet being + // redundant with it. + var quiet = FacetedSearch.Search(rows, new GameFilter { Band = ActivityBand.Quiet }); + + await Assert.That(quiet.Games.Select(g => g.Slug)).IsEquivalentTo(new[] { "zero", "unreadable" }); + } + + [Test] + public async Task AGameWeHaveNotMeasuredIsNeitherUncountedNorCounted() + { + // §5.4's third state, which names no cause. `gone` has no presence rows at all, so it is not + // uncounted — the facet says "we tried and could not read", and claiming that of a game we + // never got into would be the same fabrication in the other direction. + var rows = Measured(); + + var uncounted = FacetedSearch.Search(rows, new GameFilter { Uncounted = FacetChoice.Of(FacetTokens.Yes) }); + var unreachable = FacetedSearch.Search(rows, new GameFilter { Unreachable = FacetChoice.Of(FacetTokens.Yes) }); + + await Assert.That(uncounted.Games.Any(g => g.Slug == "gone")).IsFalse(); + await Assert.That(unreachable.Games.Select(g => g.Slug)).IsEquivalentTo(new[] { "gone" }); + } + + [Test] + public async Task TheTwoSwitchesComposeWithEachOtherAndWithAnUnrelatedFacet() + { + // The reason they are two FacetChoices rather than two values of `band`: a reader narrowing + // by genre has to be able to drop both kinds of unmeasured game without spending the one + // selection `band` has. Three questions at once, and all three applied. + var rows = Measured(); + + var listing = FacetedSearch.Search(rows, new GameFilter + { + Genre = FacetChoice.Of("Fantasy"), + Uncounted = FacetChoice.Not(FacetTokens.Yes), + Unreachable = FacetChoice.Not(FacetTokens.Yes), + }); + + // Fantasy, minus the one we could not read and the one we could not reach — leaving the + // measured nought, which is a game we counted and must survive both exclusions. + await Assert.That(listing.Games.Select(g => g.Slug)).IsEquivalentTo(new[] { "zero" }); + } + + [Test] + public async Task ExcludingBothStillLeavesAListingThatExplainsItself() + { + // Hiding is a decision about the listing, so the controls that made it have to stay + // reachable — a selection whose only affordance has vanished is the defect the whole panel + // was rebuilt to remove. Both rows survive at the count their own selection returns. + var rows = Measured(); + + var listing = FacetedSearch.Search(rows, new GameFilter + { + Uncounted = FacetChoice.Not(FacetTokens.Yes), + Unreachable = FacetChoice.Not(FacetTokens.Yes), + }); + + await Assert.That(listing.Games.Select(g => g.Slug)).IsEquivalentTo(new[] { "zero", "busy" }); + + foreach (var key in new[] { FacetKeys.Uncounted, FacetKeys.Unreachable }) + { + var value = Value(listing, key, FacetTokens.Yes); + + await Assert.That(value.State).IsEqualTo(FacetState.Excluded); + await Assert.That(value.Count).IsEqualTo(1); + } + } + + [Test] + public async Task TheCountBesideEachSwitchIsWhatChoosingItReturns() + { + // The panel's own promise, made over the set that has both hard states in it. The generic + // walk above covers this too; this one names the facets, so a change that made the counts + // fall out of a wider denominator fails here with the right words on it. + var rows = Measured(); + var listing = FacetedSearch.Search(rows, new GameFilter()); + + foreach (var key in new[] { FacetKeys.Uncounted, FacetKeys.Unreachable }) + { + var value = Value(listing, key, FacetTokens.Yes); + + await Assert.That(value.Count).IsEqualTo(1); + await Assert.That(FacetedSearch.Search(rows, Choose(key, value.Token)).Games.Count) + .IsEqualTo(value.Count); + } + } + + [Test] + public async Task ASwitchNothingMatchesIsNotDrawnAtAll() + { + // A bounded facet keeps every rung of a scale, because a scale with a rung missing is not + // the same scale. This is not a scale — it is one fact, held or not — so a catalogue we + // could count and reach in full offers no control, which is the honest rendering of a + // measurement with nothing in it. + var listing = FacetedSearch.Search([Row("busy"), Row("also")], new GameFilter()); + + await Assert.That(listing.Facets.Any(f => f.Key == FacetKeys.Uncounted)).IsFalse(); + await Assert.That(listing.Facets.Any(f => f.Key == FacetKeys.Unreachable)).IsFalse(); + } + private static GameFilter Choose(string key, string token) => key switch { FacetKeys.Band => new GameFilter { Band = Band(token) }, FacetKeys.LastSeen => new GameFilter { LastSeen = Seen(token) }, + FacetKeys.Uncounted => new GameFilter { Uncounted = FacetChoice.Parse(token) }, + FacetKeys.Unreachable => new GameFilter { Unreachable = FacetChoice.Parse(token) }, FacetKeys.Protocol => new GameFilter { MeasuredProtocols = [token] }, FacetKeys.Tls => new GameFilter { Tls = true }, FacetKeys.Charset => new GameFilter { Charset = FacetChoice.Parse(token) }, diff --git a/tests/MUI.Catalog.Tests/Persistence/FacetQueriesPostgresTests.cs b/tests/MUI.Catalog.Tests/Persistence/FacetQueriesPostgresTests.cs index 3ebf947..53ea769 100644 --- a/tests/MUI.Catalog.Tests/Persistence/FacetQueriesPostgresTests.cs +++ b/tests/MUI.Catalog.Tests/Persistence/FacetQueriesPostgresTests.cs @@ -280,10 +280,116 @@ await Assert.That(games.Count) } } + [Test] + public async Task AMeasuredZeroIsNotUncountedAndTheBandCannotTellThemApart() + { + // The one this facet exists for, against the reader that actually computes it. + // + // Both games are reachable, both have no count above nought this week, and both therefore + // land in `quiet` — the band is one threshold and cannot say why. `empty` was probed and + // answered nought, which is a measurement of theirs; `unreadable` was probed and produced no + // number, which is a limit of ours. Returning the first under a word meaning the second is + // rules 2, 4 and 5 in one control, and the digest threw the distinction away until now. + await using var db = await PostgresFixture.MigratedAsync(); + var empty = await Seed.GameAsync(db, "empty", "Measured empty", lastReachableAt: Now); + var unreadable = await Seed.GameAsync(db, "unreadable", "Unreadable", lastReachableAt: Now); + var writer = new PresenceWriter(new NpgsqlPresenceStore(db.DataSource)); + + // Three probes each, over the same hours, differing only in what came back. + foreach (var hours in new[] { 1, 20, 60 }) + { + await writer.WriteAsync( + empty, PresenceReading.Counted(0, FieldSource.Who), Now.AddHours(-hours)); + await writer.WriteAsync( + unreadable, + PresenceReading.Unmeasurable(UnmeasurableReason.WhoUnparseable), + Now.AddHours(-hours)); + } + + var queries = QueriesOn(db); + + var quiet = await queries.ListAsync(new GameFilter { Band = ActivityBand.Quiet }); + await Assert.That(quiet.Select(g => g.Slug)).IsEquivalentTo(new[] { "empty", "unreadable" }); + + var uncounted = await queries.ListAsync( + new GameFilter { Uncounted = FacetChoice.Of(FacetTokens.Yes) }); + await Assert.That(uncounted.Select(g => g.Slug)).IsEquivalentTo(new[] { "unreadable" }); + + // And the count each game's row carries agrees: nought is a number, and the other is null. + var listed = await queries.ListAsync(new GameFilter()); + await Assert.That(listed.Single(g => g.Id == empty).PlayersNow).IsEqualTo(0); + await Assert.That(listed.Single(g => g.Id == unreadable).PlayersNow).IsNull(); + } + + [Test] + public async Task AGameWithNoPresenceRowsAtAllIsNotUncounted() + { + // §5.4's third state. A probe that failed writes no presence row (`PresenceWriter`), so a + // game with nothing stored is one we did not measure — and "we tried and could not read it" + // is a cause, which that state may not be given. It is unreachable instead, from the + // availability series, which can tell the two apart. + await using var db = await PostgresFixture.MigratedAsync(); + await Seed.GameAsync(db, "never", "Never answered", lastReachableAt: null); + var probed = await Seed.GameAsync(db, "probed", "Probed", lastReachableAt: Now); + var writer = new PresenceWriter(new NpgsqlPresenceStore(db.DataSource)); + + await writer.WriteAsync( + probed, PresenceReading.Unmeasurable(UnmeasurableReason.WhoLoginPrompt), Now.AddHours(-2)); + + var queries = QueriesOn(db); + + await Assert.That( + (await queries.ListAsync(new GameFilter { Uncounted = FacetChoice.Of(FacetTokens.Yes) })) + .Select(g => g.Slug)) + .IsEquivalentTo(new[] { "probed" }); + + await Assert.That( + (await queries.ListAsync(new GameFilter { Unreachable = FacetChoice.Of(FacetTokens.Yes) })) + .Select(g => g.Slug)) + .IsEquivalentTo(new[] { "never" }); + } + + [Test] + public async Task TheTwoSwitchesNarrowTogetherAndTheCountsSayWhatTheyReturn() + { + // Orthogonal, and orthogonal to the rest of the panel: excluding both leaves the games we + // measured, with the two controls still drawn so the reader can undo what they did. + await using var db = await PostgresFixture.MigratedAsync(); + var counted = await Seed.GameAsync(db, "counted", "Counted", lastReachableAt: Now); + var unreadable = await Seed.GameAsync(db, "unreadable", "Unreadable", lastReachableAt: Now); + await Seed.GameAsync(db, "gone", "Gone", lastReachableAt: Now.AddDays(-90)); + var writer = new PresenceWriter(new NpgsqlPresenceStore(db.DataSource)); + + await writer.WriteAsync(counted, PresenceReading.Counted(4, FieldSource.Who), Now.AddHours(-1)); + await writer.WriteAsync( + unreadable, PresenceReading.Unmeasurable(UnmeasurableReason.I3NoReply), Now.AddHours(-1)); + + var queries = QueriesOn(db); + + var kept = await queries.SearchAsync(new GameFilter + { + Uncounted = FacetChoice.Not(FacetTokens.Yes), + Unreachable = FacetChoice.Not(FacetTokens.Yes), + }); + + await Assert.That(kept.Games.Select(g => g.Slug)).IsEquivalentTo(new[] { "counted" }); + + foreach (var key in new[] { FacetKeys.Uncounted, FacetKeys.Unreachable }) + { + var value = Group(kept, key)!.Values.Single(v => v.Token == FacetTokens.Yes); + + await Assert.That(value.State).IsEqualTo(FacetState.Excluded); + await Assert.That((await queries.ListAsync(Choose(key, value.Token))).Count) + .IsEqualTo(value.Count); + } + } + private static GameFilter Choose(string key, string token) => key switch { FacetKeys.Band => new GameFilter { Band = Band(token) }, FacetKeys.LastSeen => new GameFilter { LastSeen = Seen(token) }, + FacetKeys.Uncounted => new GameFilter { Uncounted = FacetChoice.Parse(token) }, + FacetKeys.Unreachable => new GameFilter { Unreachable = FacetChoice.Parse(token) }, FacetKeys.Protocol => new GameFilter { MeasuredProtocols = [token] }, FacetKeys.Tls => new GameFilter { Tls = true }, FacetKeys.Charset => new GameFilter { Charset = FacetChoice.Parse(token) }, diff --git a/tests/MUI.Web.Tests/AboutPageTests.cs b/tests/MUI.Web.Tests/AboutPageTests.cs index 2f6d462..9e8b1b2 100644 --- a/tests/MUI.Web.Tests/AboutPageTests.cs +++ b/tests/MUI.Web.Tests/AboutPageTests.cs @@ -10,6 +10,7 @@ using MUI.Discovery; using MUI.Web.Api; using MUI.Web.Components; +using MUI.Web.Localization; namespace MUI.Web.Tests; @@ -34,6 +35,21 @@ public class AboutPageTests private static string Plain => PlainText.RenderAbout(Page); + /// + /// The English a message id carries, which is what this page is asserted against. + /// + /// + /// The copy lives in now, so a sentence pasted into a test would be a + /// third copy of it — and the one nothing checks. Asking the bundle asserts the fact this page + /// makes a claim about ("the archive-grace limitation is stated") rather than the spelling of + /// it, and still fails if the id stops reaching the page. Where a claim is a rule + /// rather than a sentence — the refusal to say "no automated opt-out" — the literal stays, + /// because there the wording is the rule. A rule about the site's vocabulary is the one + /// case where a literal on this page is wrong: it belongs over the whole bundle in every locale, + /// which is where puts it. + /// + private static string Says(string id) => Messages.For(Locales.SourceTag, id); + [Test] public async Task EveryDirectoryTheBackfillReadIsCreditedByNameAndByAddress() { @@ -59,13 +75,17 @@ public async Task ASourceWeChoseNotToFetchIsCreditedAndSaidToBeUnread() var mudverse = Page.Sources.Single(s => s.Name == "MudVerse"); await Assert.That(mudverse.State).IsEqualTo(ImportSourceState.Withheld); - await Assert.That(Plain).Contains("not read — awaiting permission"); + await Assert.That(Plain).Contains(Says("about.source.withheld")); + + // And the badge for one we did read is a different string, not a negation of this one: a + // reader meets the difference between "we chose not to" and "we could not" only here. + await Assert.That(Says("about.source.read")).IsNotEqualTo(Says("about.source.withheld")); } [Test] public async Task TheAttributionSaysAddressesOnlyAndSaysWhy() { - await Assert.That(Render.Words(Plain)).Contains("We take addresses. Nothing else."); + await Assert.That(Render.Words(Plain)).Contains(Says("about.sources.addresses.lead")); await Assert.That(Render.Words(Plain)).Contains("No player counts"); await Assert.That(Render.Words(Plain)).Contains("no reachability history"); } @@ -131,12 +151,13 @@ 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(Render.Words(Plain)).Contains("is a placeholder and answers nobody"); + await Assert.That(Render.Words(Plain)).Contains(Says("about.identity.placeholder.plain")); var configured = PlainText.RenderAbout(AboutPage.Build( new ProbeOptions { InfoUrl = "https://example.test/crawler" }, new DatasetLicenceOptions())); - await Assert.That(Render.Words(configured)).DoesNotContain("is a placeholder and answers nobody"); + await Assert.That(Render.Words(configured)) + .DoesNotContain(Says("about.identity.placeholder.plain")); } [Test] @@ -182,7 +203,7 @@ public async Task ThePoliteAndSecurityFactsAboutAProbeSurvive() { var text = Render.Words(Plain); - await Assert.That(text).Contains("CRAWL DELAY wins."); + await Assert.That(text).Contains(Says("about.crawler.delay.lead")); await Assert.That(text).Contains("resolved before anything is dialled"); await Assert.That(text).Contains("globally routable"); } @@ -203,27 +224,87 @@ 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(Says("about.measures.declared.lead")); 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"); } + /// + /// The page explains what reachability is, in whatever language it is being read in. + /// + /// + /// + /// This used to count the English word "uptime" and require exactly two of it. That was a + /// reasonable guard while the page was a C# string and an unreasonable one the moment it became + /// a translation: a Japanese or Chinese rendering of the same two refusals contains the token + /// zero times and would fail, and a German one contains it twice by a coincidence of loanwords + /// rather than because the rule held. Worse, the assertion made the *presence* of the forbidden + /// word the thing under test, so the page's vocabulary rule was guarded by requiring the + /// vocabulary to be broken. + /// + /// + /// The rule survives, split from the spelling. Here: the two refusal ids reach the page, asked + /// of the bundle, so this holds in every locale. And in + /// : the word appears in no other + /// message, in no locale — which is the rule itself ("reachable, never uptime", in copy), + /// checked over the whole bundle rather than inferred from one page's word count. + /// + /// [Test] - public async Task ReachableIsExplainedAndNeverCalledUptimeExceptToRefuseTheWord() + public async Task ReachableIsExplainedInWhateverLanguageThePageIsRead() { var text = Render.Words(Plain); - await Assert.That(text).Contains("Reachable, never uptime."); + await Assert.That(text).Contains(Says("about.measures.reachable.lead")); + await Assert.That(text).Contains(Says("about.measures.reachable.body")); - // 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); + // The substance of the refusal, which is what the rule is for: the measurement is of our + // socket from our host, and an unreachable game may be perfectly alive. await Assert.That(text).Contains("unreachable and perfectly alive"); await Assert.That(text).Contains("nothing here measured it"); } + /// + /// "Reachable, never uptime" — over the bundle, in every locale, rather than over one page. + /// + /// + /// The two about-page ids are the whole exemption: they are the sentences that name the word in + /// order to refuse it, and they are the only place on the site allowed to. Every other id is + /// checked in every bundle a reader can be served, so a translator who reaches for the loanword + /// in a reachability string fails this rather than shipping it — which a rendered-English word + /// count could never have caught. + /// + [Test] + public async Task NoMessageOutsideTheseTwoRefusalsUsesTheWordUptime() + { + string[] refusals = ["about.measures.reachable.lead", "about.measures.reachable.body"]; + + var tags = Locales.All + .Select(locale => locale.Tag) + .Append(Locales.SourceTag) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + await Assert.That(tags).IsNotEmpty(); + + foreach (var tag in tags) + { + foreach (var id in Messages.Ids.Except(refusals, StringComparer.Ordinal)) + { + var pattern = Messages.Pattern(tag, id) ?? string.Empty; + + await Assert.That(pattern.Contains("uptime", StringComparison.OrdinalIgnoreCase)) + .IsFalse() + .Because($"{id} says \"uptime\" in {tag}; the word here is reachable"); + } + } + + // And the exemption is real rather than vacuous: the English refusals do use the word, which + // is what makes them refusals and not silence. + await Assert.That(Says("about.measures.reachable.lead").ToLowerInvariant()).Contains("uptime"); + } + [Test] public async Task TheThingsThisSiteWillNotDoAreStatedRatherThanImplied() { @@ -231,10 +312,10 @@ public async Task TheThingsThisSiteWillNotDoAreStatedRatherThanImplied() // 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."); + await Assert.That(text).Contains(Says("about.never.votes.lead")); + await Assert.That(text).Contains(Says("about.never.forums.lead")); + await Assert.That(text).Contains(Says("about.never.names.lead")); + await Assert.That(text).Contains(Says("about.never.population.lead")); } [Test] @@ -242,7 +323,7 @@ public async Task TheDataLicenceIsPresentedAsUndecidedRatherThanAsSettled() { var text = Render.Words(Plain); - await Assert.That(text).Contains("The code is MIT."); + await Assert.That(text).Contains(Says("about.licence.code.lead")); await Assert.That(text).Contains("licence for the data is an open question"); await Assert.That(text).Contains("not yet taken"); @@ -295,6 +376,58 @@ public async Task TheGraphicalPageCarriesEverySentenceThePlainOneDoes() } } + /// + /// Every sentence on this page comes out of the bundle, and the machine voice does not. + /// + /// + /// + /// The pseudolocale accents and brackets every string that reached a reader through + /// , so anything still legible as English here is a sentence typed into a + /// page — which is what the whole of this page was until it was moved. This was the largest + /// untranslated surface on the site, and the one it would be worst to leave: it is where the + /// site explains what a measurement here proves, to a reader who by definition does not yet + /// trust it. + /// + /// + /// The directories' names and addresses go the other way. They are somebody else's name and + /// somebody else's URL, they are the credit §7.6 owes, and translating either would destroy the + /// acknowledgement rather than localize it — so they are asserted to be unchanged. + /// + /// + [Test] + public async Task EverySentenceComesFromTheBundleAndTheDirectoriesOwnNamesDoNot() + { + var page = AboutPage.Build(new ProbeOptions(), new DatasetLicenceOptions(), "qps-ploc"); + var pseudo = PlainText.RenderAbout(page, "qps-ploc"); + + await Assert.That(pseudo).Contains("⟦"); + + foreach (var section in Page.Sections) + { + await Assert.That(pseudo) + .DoesNotContain(section.Heading) + .Because($"the {section.Id} heading never went through the message pipeline"); + + foreach (var point in section.Points) + { + await Assert.That(pseudo) + .DoesNotContain(point.Lead) + .Because($"a point in {section.Id} is hard-coded English"); + } + } + + // The crawler's own name and the licence a deployment configured are machine voice too, and + // are read off the objects that own them rather than out of the bundle. + await Assert.That(pseudo).Contains(page.Sections.Single(s => s.Id == "crawler").Identity!.Name); + await Assert.That(pseudo).Contains(new DatasetLicenceOptions().LicenceName); + + foreach (var source in Page.Sources) + { + await Assert.That(pseudo).Contains(source.Name); + await Assert.That(pseudo).Contains(source.Url); + } + } + [Test] public async Task TheGraphicalPageIsReachableWithoutScriptingAndSaysHowToReadItPlainly() { diff --git a/tests/MUI.Web.Tests/AccountSurfaceTests.cs b/tests/MUI.Web.Tests/AccountSurfaceTests.cs index 173d0b8..c8df4ef 100644 --- a/tests/MUI.Web.Tests/AccountSurfaceTests.cs +++ b/tests/MUI.Web.Tests/AccountSurfaceTests.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Security.Claims; using System.Text.RegularExpressions; @@ -15,8 +16,10 @@ using MUI.Catalog.Persistence; using MUI.Web; using MUI.Web.Accounts; +using MUI.Web.Components; using MUI.Web.Components.Pages; using MUI.Web.Fixtures; +using MUI.Web.Localization; namespace MUI.Web.Tests; @@ -64,11 +67,123 @@ public async Task WithNoDatabaseThePageSaysAccountsNeedOne() var body = Render.Words(await host.Client.GetStringAsync("/account")); - await Assert.That(body).Contains("Accounts need a database"); - await Assert.That(body).DoesNotContain("Sign in"); - await Assert.That(body).DoesNotContain("give up this claim"); + // The fact, through the bundle, rather than a pasted sentence: the claim is that this page + // says the one thing and offers neither the way in nor a write surface, and it goes on + // being that claim when somebody rewords the copy. + await Assert.That(body).Contains(En("account.noDatabase")); + await Assert.That(body).DoesNotContain(En("account.signInButton")); + await Assert.That(body).DoesNotContain(En("account.resign.summary")); } + /// One message in the source language, as it reads once rendered. + private static string En(string id) => Render.Words(Messages.For(Locales.SourceTag, id)); + + /// + /// A placed sentence in the source language, with its slots filled as the page fills them. + /// + /// + /// hands back runs and markers so the markup can choose an element + /// per slot; a test wants the sentence a reader ends up with. Reassembling it here means these + /// assertions read the same bundle the page does — the alternative is a pasted English string, + /// which is exactly what this pass exists to remove. + /// + private static string EnPlaced( + string id, + IReadOnlyDictionary fills, + params (string Key, object? Value)[] args) => + Render.Words(string.Concat(Sentence + .Place(Locales.SourceTag, id, [.. fills.Keys], args) + .Select(part => part.Slot is null ? part.Text : fills[part.Slot]))); + + /// + /// Every word of the sign-in page comes out of the message bundle. + /// + /// + /// + /// Both branches, because they are two different pages: over the demo fixture it is one sentence + /// saying there is nothing to sign in to, and behind a database it is the whole passkey + /// explanation — and the second was never rendered by any test at all, so it could have drifted + /// into English without anything noticing. + /// + /// + /// The pseudolocale is the instrument: it accents and brackets anything that reached a reader + /// through , so an English sentence surviving here is one typed into the + /// markup. Asserted against the English render rather than against pasted strings, so this keeps + /// holding when the copy is edited. + /// + /// + [Test] + [Arguments(true)] + [Arguments(false)] + public async Task EveryWordOfSigningInComesFromTheBundle(bool withDatabase) + { + var english = Render.Words(await SignInAsync(Locales.SourceTag, withDatabase)); + var pseudo = Render.Words(await SignInAsync("qps-ploc", withDatabase)); + + await Assert.That(pseudo).Contains("⟦"); + + // Which page this is, asserted rather than assumed: a harness that failed to register + // identity would render the one-sentence branch twice and the loop below would pass on a + // page nobody had looked at. + await Assert.That(english.Contains( + Messages.For(Locales.SourceTag, "account.store.heading"), StringComparison.Ordinal)) + .IsEqualTo(withDatabase); + + await Assert.That(english.Contains( + Messages.For(Locales.SourceTag, "account.signIn.noDatabase"), StringComparison.Ordinal)) + .IsEqualTo(!withDatabase); + + // Every sentence of the English page, absent from the same page in another language. + // + // Argument-free ids only, and that costs this sweep nothing: the sign-in page says no + // message that takes one. The `account.` prefix now also covers the owner dashboard, whose + // sentences name a game, a date or a count — and a pattern cannot be rendered at all + // without its arguments, so including them here would throw rather than assert. + foreach (var id in Sayable("account.")) + { + var sentence = Render.Words(Messages.For(Locales.SourceTag, id)); + + if (!english.Contains(sentence, StringComparison.Ordinal)) + { + continue; + } + + await Assert.That(pseudo) + .DoesNotContain(sentence) + .Because($"{id} is rendered as English whatever language the page was asked for"); + } + } + + /// + /// The sign-in page, in one locale, with or without a database behind it. + /// + /// + /// The locale arrives the way the middleware leaves it — in HttpContext.Items — because + /// that is what the page reads. Identity is registered only for the second case, which is + /// exactly the condition the page itself branches on. + /// + private static Task SignInAsync(string tag, bool withDatabase) => + Render.ComponentAsync([], services => + { + services.AddLogging(); + services.AddSingleton(new MUI.Web.Data.CatalogueSource(IsMeasured: withDatabase)); + + var context = new DefaultHttpContext(); + context.Items[LocaleRouting.ItemKey] = + new LocaleContext(Locales.Find(tag)!, FromPath: tag != Locales.SourceTag); + services.AddCascadingValue(_ => context); + + if (!withDatabase) + { + return; + } + + services.AddHttpContextAccessor(); + services.AddAuthentication(); + services.AddSingleton>(new Accounts([], FixtureGameQueries.Now)); + services.AddIdentityCore().AddSignInManager(); + }); + /// A signed-out visitor is offered the way in and nothing else. [Test] public async Task ASignedOutVisitorIsOfferedSignInAndNoWriteSurface() @@ -77,7 +192,7 @@ public async Task ASignedOutVisitorIsOfferedSignInAndNoWriteSurface() await Assert.That(markup).Contains("/account/sign-in"); await Assert.That(markup).DoesNotContain("An account with nothing claimed is told where to start.
and no other element that closes with , so the + // first after it is its own — and the disclosure has to come after that. + var quote = html.IndexOf("class=\"quote\"", StringComparison.Ordinal); + var closes = html.IndexOf("", quote, StringComparison.Ordinal); + var disclosure = html.IndexOf("class=\"screen-text\"", StringComparison.Ordinal); + + await Assert.That(quote).IsGreaterThanOrEqualTo(0); + await Assert.That(disclosure).IsGreaterThan(closes); + + // And it is still inside the figure, which is what makes the two one block. + var figure = html.IndexOf("", StringComparison.Ordinal); + await Assert.That(disclosure).IsLessThan(figure); + } + + [Test] + public async Task TheCaptionStatesTheWidestRowInCellsRatherThanAHalvedGrid() + { + // Seventy-eight ASCII characters and one Han glyph: eighty cells, the widest a row of an + // eighty-column terminal can be. The caption said forty, because one wide rune anywhere + // halved the whole screen. + var html = Render.Words(await Render.ComponentAsync(new() + { + ["Screen"] = Ansi.Parse(new string('x', 78) + "漢\nplain\nplain", suppressedByOwner: false), + })); + + await Assert.That(html).Contains("80×"); + await Assert.That(html).DoesNotContain("40×"); } [Test] diff --git a/tests/MUI.Web.Tests/CapabilityMatrixTests.cs b/tests/MUI.Web.Tests/CapabilityMatrixTests.cs index 6a9180f..3b36606 100644 --- a/tests/MUI.Web.Tests/CapabilityMatrixTests.cs +++ b/tests/MUI.Web.Tests/CapabilityMatrixTests.cs @@ -42,13 +42,29 @@ public async Task ADisagreementIsMarkedInTheRowAndNotOnlyByATint() } [Test] - public async Task TheCountIsInTheSectionHeadSoItSurvivesCollapse() + public async Task TheTallyIsTheTablesCaptionAndIsSaidExactlyOnce() { + // It used to be the section's heading text and, verbatim, the table's sr-only caption — so a + // screen reader heard the same sentence twice on the way to the first row. As the caption it + // is the table's own description, it is on screen for everybody, and it is there once. + // The wording is the short form the handoff's copy table asks for. "capabilities" was the + // word the heading directly above already says, so the caption no longer repeats it. var html = await MatrixAsync(); var head = html[..html.IndexOf("(new() { ["Slug"] = "m-u-s-h" }); - await Assert.That(page).Contains("Everything here was measured, not entered by an owner"); + // One sentence, where the badge and a paragraph three blocks below used to say the same + // thing twice. The invitation is the half that depends on there being anywhere to sign in. + await Assert.That(Render.Words(page)).Contains("Unclaimed — everything here was measured."); await Assert.That(page).DoesNotContain("Claim this game"); } @@ -95,4 +106,311 @@ public async Task OnlyChannelsAProbeCanReadAreClaimChannels() { await Assert.That(Enum.GetNames()).IsEquivalentTo(new[] { "Mssp", "ConnectScreen" }); } + + // ── the language the claim page is answered in ──────────────────────────── + + /// + /// Every word of the claim page comes out of the message bundle. + /// + /// + /// + /// Driven on the state that renders the most of it: a signed-in operator with a pending token, + /// which is the page an owner actually works from — the instructions, both channels, the + /// expiry and the recheck button. The degraded states are covered below, because those are the + /// ones a reader of the demo site meets. + /// + /// + /// The pseudolocale is the instrument: it brackets anything that reached a reader through + /// , so an English sentence surviving here is one typed into the markup. + /// + /// + [Test] + public async Task EveryWordOfClaimingComesFromTheBundle() + { + var english = Render.Text(await ClaimWorld.Pending(Locales.SourceTag)); + var pseudo = Render.Text(await ClaimWorld.Pending("qps-ploc")); + + await Assert.That(pseudo).Contains("⟦"); + + // The page really is the token-bearing one rather than a guard branch rendered twice. + await Assert.That(english).Contains(Render.Words( + Messages.For(Locales.SourceTag, "claim.either.heading"))); + + foreach (var id in Sayable("claim.")) + { + var sentence = Render.Words(Messages.For(Locales.SourceTag, id)); + + if (!english.Contains(sentence, StringComparison.Ordinal)) + { + continue; + } + + await Assert.That(pseudo) + .DoesNotContain(sentence) + .Because($"{id} is rendered as English whatever language the page was asked for"); + } + } + + /// + /// A German request gets German wherever German exists, and the token survives it. + /// + /// + /// Gated on rather than on a list of ids: this page's own ids are + /// new and the satellites are translated in one round afterwards, so most of them fall back to + /// English today — the designed behaviour. The parts that must never move are asserted + /// directly, because every one of them is something an operator pastes into a config file. + /// + [Test] + public async Task AGermanRequestGetsGermanOnTheClaimPage() + { + var english = Render.Text(await ClaimWorld.Pending(Locales.SourceTag)); + var german = Render.Text(await ClaimWorld.Pending("de")); + + foreach (var id in Sayable("claim.").Where(i => Messages.HasOwn("de", i))) + { + var de = Render.Words(Messages.For("de", id)); + var en = Render.Words(Messages.For(Locales.SourceTag, id)); + + if (string.Equals(de, en, StringComparison.Ordinal) + || !english.Contains(en, StringComparison.Ordinal)) + { + continue; + } + + await Assert.That(german).Contains(de).Because($"{id} is not answered in German"); + } + + // A fallback shows the English and never the id. + foreach (var id in Sayable("claim.").Where(i => !Messages.HasOwn("de", i))) + { + await Assert.That(german).DoesNotContain(id).Because($"{id} reached a reader as its id"); + } + + // The machine voice, which is the half of this page that must be byte-identical in every + // language: the token itself, the MSSP variable and the connect-screen prefix are what a + // probe looks for, and a translated one is a claim that could never verify. + await Assert.That(german).Contains(ClaimWorld.Token); + await Assert.That(german).Contains(ClaimTokenBeacon.MsspVariable); + await Assert.That(german).Contains(ClaimTokenBeacon.ConnectScreenPrefix); + + // And the accepted spellings are read from the parser rather than retyped into the page. + foreach (var accepted in ClaimTokenBeacon.AcceptedMsspVariables) + { + await Assert.That(german).Contains(accepted); + } + } + + /// + /// The states a reader of the demo site can actually reach, in the language they asked for. + /// + /// + /// There is no database behind the fixture, so claiming is absent rather than present and + /// broken — and "absent" is itself a sentence, which has to be in the reader's language like + /// any other. Both branches, because a slug that names nothing and a slug that names a game we + /// cannot claim are different answers. + /// + [Test] + [Arguments(Locales.SourceTag)] + [Arguments("qps-ploc")] + [Arguments("de")] + public async Task TheDegradedClaimPageAnswersInTheReadersLanguage(string tag) + { + var missing = Render.Text(await ClaimWorld.NoGame(tag)); + + await Assert.That(missing).Contains(Render.Words(Messages.For(tag, "claim.noGame"))); + + var unclaimable = Render.Text(await ClaimWorld.NoDatabase(tag)); + + await Assert.That(unclaimable).Contains(Render.Words(Messages.For(tag, "claim.noDatabase"))); + + // The heading names the game, and the game's name is its own bytes in every language. + await Assert.That(unclaimable).Contains(Render.Words( + Messages.Say(tag, "claim.title", ("game", ClaimWorld.GameName)))); + } + + /// Ids under a prefix that can be rendered without arguments. + private static IEnumerable Sayable(string prefix) => + Messages.Ids + .Where(i => i.StartsWith(prefix, StringComparison.Ordinal)) + .Where(i => !IcuMessage.Compile(Messages.Pattern(Locales.SourceTag, i)!).Arguments().Any()); + + /// + /// The claim page, in one locale, in each state it can be reached in. + /// + /// + /// At component level with an cascaded in, for the reason the + /// dashboard's harness is: §8.2 makes passkeys the only way in, so a loopback host cannot + /// produce an authenticated session without an authenticator. The page's own guards still run — + /// which game, which account, and whether there is a claim — against a real + /// over in-memory stores. + /// + private static class ClaimWorld + { + public const string Slug = "ashen-court"; + + public const string GameName = "Ashen Court"; + + /// A token of the right shape. Machine voice, and identical in every language. + public const string Token = "muidx-t3kn2wxy7q4m8dgh6prs"; + + private static readonly DateTimeOffset Now = FixtureGameQueries.Now; + + private static readonly Guid GameId = Guid.Parse("aaaaaaaa-0000-0000-0000-000000000007"); + + /// A signed-in operator with a token outstanding: the page an owner works from. + public static Task Pending(string tag) => RenderAsync(tag, withGame: true, withClaims: true); + + /// A slug that names nothing. + public static Task NoGame(string tag) => RenderAsync(tag, withGame: false, withClaims: true); + + /// A real game on a site with no database behind it — the demo fixture's state. + public static Task NoDatabase(string tag) => + RenderAsync(tag, withGame: true, withClaims: false); + + private static Task RenderAsync(string tag, bool withGame, bool withClaims) + { + var user = new MuiUser { DisplayName = "corvid-admin", CreatedAt = Now }; + + var claim = new GameClaim + { + Id = Guid.CreateVersion7(), + GameId = GameId, + UserId = user.Id, + Token = Token, + IssuedAt = Now.AddDays(-2), + ExpiresAt = Now.AddDays(12), + }; + + return Render.ComponentAsync( + new Dictionary { ["Slug"] = Slug }, + services => + { + var games = new Games(withGame + ? new GameRecord(GameId, Slug, GameName, null, LifecycleState.Active, false, Now) + : null); + + var claims = new Claims([claim]); + + services.AddLogging(); + services.AddSingleton(new Frozen(Now)); + services.AddSingleton(new MUI.Web.Data.CatalogueSource(IsMeasured: withClaims)); + services.AddSingleton(); + services.AddSingleton(games); + + // Registered together, because that is how the site registers them: claiming + // exists only when a connection string does, and the page's "no database" + // branch is exactly the absence of this service. + if (withClaims) + { + services.AddSingleton(claims); + services.AddSingleton(new ClaimService(claims, games, new Frozen(Now))); + } + + services.AddSingleton>(new Accounts([user], Now)); + services.AddIdentityCore(); + + var context = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity( + [new System.Security.Claims.Claim( + ClaimTypes.NameIdentifier, user.Id.ToString())], + "Test")), + }; + + context.Items[LocaleRouting.ItemKey] = + new LocaleContext(Locales.Find(tag)!, FromPath: tag != Locales.SourceTag); + + services.AddCascadingValue(_ => context); + }); + } + + private sealed class Games(GameRecord? game) : IGameStore + { + public Task ByIdAsync(Guid id, CancellationToken ct = default) => + Task.FromResult(game?.Id == id ? game : null); + + public Task BySlugAsync(string slug, CancellationToken ct = default) => + Task.FromResult(game?.Slug == slug ? game : null); + + public Task InsertAsync(GameRecord g, CancellationToken ct = default) => + throw new NotSupportedException(); + + public Task ExcludeAsync(Guid id, string reason, DateTimeOffset at, CancellationToken ct = default) => + throw new NotSupportedException(); + + public Task IncludeAsync(Guid id, DateTimeOffset at, CancellationToken ct = default) => + throw new NotSupportedException(); + + public Task UnlistAsync(Guid id, Guid by, DateTimeOffset at, CancellationToken ct = default) => + throw new NotSupportedException(); + + public Task RelistAsync(Guid id, DateTimeOffset at, CancellationToken ct = default) => + throw new NotSupportedException(); + + public Task SetStateAsync(Guid id, LifecycleState s, DateTimeOffset at, CancellationToken ct = default) => + throw new NotSupportedException(); + + public Task MarkReachableAsync(Guid id, DateTimeOffset at, CancellationToken ct = default) => + throw new NotSupportedException(); + + public Task RenameAsync( + Guid id, + string name, + string slug, + DateTimeOffset at, + CancellationToken ct = default) => + throw new NotSupportedException(); + + public Task SetClaimedAsync(Guid id, bool isClaimed, CancellationToken ct = default) => + throw new NotSupportedException(); + + public Task CorroborateAsync( + Guid id, + DateTimeOffset at, + IReadOnlyList signals, + CancellationToken ct = default) => + throw new NotSupportedException(); + + public Task> UnarchivedAsync(CancellationToken ct = default) => + throw new NotSupportedException(); + } + + private sealed class Claims(List claims) : IClaimStore + { + public Task FindAsync(Guid id, CancellationToken ct = default) => + Task.FromResult(claims.FirstOrDefault(c => c.Id == id)); + + public Task> ForGameAsync(Guid game, CancellationToken ct = default) => + Task.FromResult>([.. claims.Where(c => c.GameId == game)]); + + public Task> ForUserAsync(Guid user, CancellationToken ct = default) => + Task.FromResult>([.. claims.Where(c => c.UserId == user)]); + + public Task FindPendingByTokenAsync(Guid g, string t, CancellationToken ct = default) => + Task.FromResult(null); + + public Task InsertAsync(GameClaim claim, CancellationToken ct = default) + { + claims.Add(claim); + return Task.CompletedTask; + } + + public Task UpdateAsync(GameClaim claim, CancellationToken ct = default) => Task.CompletedTask; + + public Task RecordEventAsync(ClaimEvent e, CancellationToken ct = default) => Task.CompletedTask; + + public Task> EventsAsync(Guid claim, CancellationToken ct = default) => + Task.FromResult>([]); + } + + private sealed class Frozen(DateTimeOffset at) : TimeProvider + { + public override DateTimeOffset GetUtcNow() => at; + } + + private sealed class NoAntiforgery : AntiforgeryStateProvider + { + public override AntiforgeryRequestToken? GetAntiforgeryToken() => null; + } + } } diff --git a/tests/MUI.Web.Tests/CrawlerStripTests.cs b/tests/MUI.Web.Tests/CrawlerStripTests.cs index 2d8a571..5337a44 100644 --- a/tests/MUI.Web.Tests/CrawlerStripTests.cs +++ b/tests/MUI.Web.Tests/CrawlerStripTests.cs @@ -1,3 +1,5 @@ +using MUI.Web.Localization; + using MUI.Catalog; using MUI.Web.Components; @@ -29,7 +31,8 @@ public async Task ARecentProbeReadsAsWorking() var pulse = Pulse(TimeSpan.FromSeconds(40)); await Assert.That(pulse.State(Now)).IsEqualTo(CrawlState.Working); - await Assert.That(CrawlerCopy.State(pulse, Now)).IsEqualTo("crawler working — last probe just now"); + await Assert.That(CrawlerCopy.State(Locales.SourceTag, pulse, Now)) + .IsEqualTo("crawler live · last probe " + Messages.For(Locales.SourceTag, "age.ago.now")); } /// @@ -46,10 +49,11 @@ public async Task ARecentProbeReadsAsWorking() public async Task AStalePulseIsQuietAndNamesNoCause() { var pulse = Pulse(TimeSpan.FromHours(4)); - var copy = CrawlerCopy.State(pulse, Now); + var copy = CrawlerCopy.State(Locales.SourceTag, pulse, Now); await Assert.That(pulse.State(Now)).IsEqualTo(CrawlState.Quiet); - await Assert.That(copy).IsEqualTo("crawler quiet — last probe 4h ago"); + await Assert.That(copy).IsEqualTo( + "crawler quiet · last probe " + Relative.Ago(Locales.SourceTag, TimeSpan.FromHours(4))); foreach (var diagnosis in new[] { "stopped", "down", "crashed", "stalled", "failed", "error", "offline" }) { @@ -65,10 +69,10 @@ public async Task TheStripNeverSaysUptimeAndNeverDescribesAGame() { var rendered = string.Join( "\n", - CrawlerCopy.State(Pulse(TimeSpan.FromSeconds(10)), Now), - CrawlerCopy.State(Pulse(TimeSpan.FromDays(2)), Now), - CrawlerCopy.LastCycle(Pulse(TimeSpan.Zero, Cycle(8, 6, 2))) ?? string.Empty, - CrawlerCopy.Registry(Pulse(TimeSpan.Zero))); + CrawlerCopy.State(Locales.SourceTag, Pulse(TimeSpan.FromSeconds(10)), Now), + CrawlerCopy.State(Locales.SourceTag, Pulse(TimeSpan.FromDays(2)), Now), + CrawlerCopy.LastCycle(Locales.SourceTag, Pulse(TimeSpan.Zero, Cycle(8, 6, 2))) ?? string.Empty, + CrawlerCopy.Registry(Locales.SourceTag, Pulse(TimeSpan.Zero))); foreach (var word in new[] { "uptime", "unreachable", "reachable" }) { @@ -85,10 +89,10 @@ public async Task TheStripNeverSaysUptimeAndNeverDescribesAGame() public async Task AnUnmeasuredPulseRendersNothing() { await Assert.That(CrawlerPulse.Unknown.State(Now)).IsEqualTo(CrawlState.NotYet); - await Assert.That(CrawlerCopy.LastCycle(CrawlerPulse.Unknown)).IsNull(); + await Assert.That(CrawlerCopy.LastCycle(Locales.SourceTag, CrawlerPulse.Unknown)).IsNull(); var html = await Render.PageAsync([]); - await Assert.That(html).DoesNotContain("crawler working"); + await Assert.That(html).DoesNotContain("crawler live"); await Assert.That(html).DoesNotContain("crawler quiet"); } @@ -103,11 +107,11 @@ public async Task AnUnmeasuredPulseRendersNothing() [Test] public async Task ACycleWithNothingDueSaysSoRatherThanPrintingZeroes() { - await Assert.That(CrawlerCopy.LastCycle(Pulse(TimeSpan.Zero, Cycle(0, 0, 0)))) - .IsEqualTo("last cycle: nothing was due"); + await Assert.That(CrawlerCopy.LastCycle(Locales.SourceTag, Pulse(TimeSpan.Zero, Cycle(0, 0, 0)))) + .IsEqualTo("nothing due this cycle"); - await Assert.That(CrawlerCopy.LastCycle(Pulse(TimeSpan.Zero, Cycle(8, 6, 2)))) - .IsEqualTo("last cycle: 8 due · 6 answered · 2 failed"); + await Assert.That(CrawlerCopy.LastCycle(Locales.SourceTag, Pulse(TimeSpan.Zero, Cycle(8, 6, 2)))) + .IsEqualTo("8 due · 6 answered · 2 failed"); } /// diff --git a/tests/MUI.Web.Tests/EcosystemSurfaceTests.cs b/tests/MUI.Web.Tests/EcosystemSurfaceTests.cs index 7790ef9..ab610f6 100644 --- a/tests/MUI.Web.Tests/EcosystemSurfaceTests.cs +++ b/tests/MUI.Web.Tests/EcosystemSurfaceTests.cs @@ -1,6 +1,7 @@ using MUI.Catalog; using MUI.Web.Components; using MUI.Web.Fixtures; +using MUI.Web.Localization; namespace MUI.Web.Tests; @@ -24,6 +25,18 @@ private static async Task EcosystemAsync() => private static async Task RankingsAsync() => PlainText.RenderRankings(await Queries.RankingsAsync(), Now); + /// + /// One message, as the source locale renders it. + /// + /// + /// These assertions are about what the two surfaces claim, not about the English they + /// happen to claim it in. Reading the claim out of the bundle keeps the guard exactly as strong + /// — a page that stopped saying it fails here — while leaving the wording free to be translated, + /// which is the whole reason it moved into the bundle. + /// + private static string Say(string id, params (string Key, object? Value)[] args) => + Messages.For("en", id, args.ToDictionary(a => a.Key, a => a.Value, StringComparer.Ordinal)); + [Test] public async Task NoAbsolutePlayerFigureIsEmittedByEitherSurface() { @@ -43,7 +56,7 @@ public async Task NoAbsolutePlayerFigureIsEmittedByEitherSurface() await Assert.That(text).DoesNotContain("across all games"); // And it says out loud that the omission is deliberate rather than an oversight. - await Assert.That(Render.Words(text)).Contains("Shares, never totals"); + await Assert.That(Render.Words(text)).Contains(Say("ecosystem.noTotals")); } [Test] @@ -73,9 +86,14 @@ public async Task BothDenominatorsAreNamedRatherThanImplied() // talkative and calling the difference adoption. var text = Render.Words(await EcosystemAsync()); - await Assert.That(text).Contains("whose handshake we completed"); - await Assert.That(text).Contains("whose MSSP report we hold"); - await Assert.That(text).Contains("Two sets of games, so two denominators"); + var dashboard = await Queries.EcosystemAsync(); + + await Assert.That(text).Contains(EcosystemCopy.Handshakes("en", dashboard.Handshakes)); + await Assert.That(text).Contains(EcosystemCopy.MsspReports("en", dashboard.MsspReports)); + await Assert.That(text).Contains(Say( + "ecosystem.plain.denominators", + ("measured", EcosystemCopy.Handshakes("en", dashboard.Handshakes)), + ("declared", EcosystemCopy.MsspReports("en", dashboard.MsspReports)))); } [Test] @@ -85,12 +103,15 @@ public async Task AProtocolWithNoMeasurementSaysSoRatherThanShowingNoughtPerCent // this, which is a fact about our reach; "0.0%" is a claim about everybody else's servers. var never = new ProtocolAdoption("TLS", Offered: null, Declined: 0, Handshakes: 400, Declared: 12, MsspReports: 300); - await Assert.That(EcosystemCopy.Measured(never)).Contains("not measured"); - await Assert.That(EcosystemCopy.Measured(never)).DoesNotContain("0.0%"); - await Assert.That(EcosystemCopy.Measured(never)).DoesNotContain("0 of 400"); + await Assert.That(EcosystemCopy.Measured("en", never)) + .IsEqualTo(Say("ecosystem.measured.never")); + await Assert.That(EcosystemCopy.Measured("en", never)).DoesNotContain("0.0%"); + await Assert.That(EcosystemCopy.Measured("en", never)).DoesNotContain("0 of 400"); // The declared side is unaffected and still carries its own set. - await Assert.That(EcosystemCopy.Declared(never)).IsEqualTo("12 of 300 (4.0%)"); + await Assert.That(EcosystemCopy.Declared("en", never)).IsEqualTo( + Say("ecosystem.share", ("count", 12), ("total", 300), ("fraction", 12d / 300))); + await Assert.That(EcosystemCopy.Declared("en", never)).IsEqualTo("12 of 300 (4.0%)"); } [Test] @@ -100,8 +121,9 @@ public async Task AnEmptyDenominatorIsNothingMeasuredAndNotNoughtPerCent() var nothing = new MeasuredShare("GMCP", 0, 0); await Assert.That(nothing.Fraction).IsNull(); - await Assert.That(EcosystemCopy.Share(nothing)).Contains("nothing measured yet"); - await Assert.That(EcosystemCopy.Share(nothing)).DoesNotContain("%"); + await Assert.That(EcosystemCopy.Share("en", nothing)) + .IsEqualTo(Say("ecosystem.share.nothing", ("count", 0), ("total", 0))); + await Assert.That(EcosystemCopy.Share("en", nothing)).DoesNotContain("%"); } [Test] @@ -112,8 +134,7 @@ public async Task TheMeasuredColumnIsSaidToBeAFloor() // saying so publishes our own instrumentation as a fact about somebody's game. var text = Render.Words(await EcosystemAsync()); - await Assert.That(text).Contains("may support a protocol without ever offering it"); - await Assert.That(text).Contains("as a floor"); + await Assert.That(text).Contains(Say("ecosystem.protocols.floor")); } [Test] @@ -124,8 +145,8 @@ public async Task TheDashboardCallsItselfASnapshotAndNotATrend() // crawl reaching more games and nothing about anybody adopting anything. var text = Render.Words(await EcosystemAsync()); - await Assert.That(text).Contains("A snapshot of what we can measure now"); - await Assert.That(text).Contains("nothing to plot"); + await Assert.That(text).Contains(Say("ecosystem.snapshot")); + await Assert.That(text).Contains(Say("ecosystem.transitions.none")); await Assert.That(text).DoesNotContain("growth"); } @@ -138,7 +159,10 @@ public async Task AGameWithNoCodebaseIsOutsideTheDenominatorAndSaidToBe() await Assert.That(dashboard.Codebases.NotIdentified).IsGreaterThan(0); await Assert.That(dashboard.Codebases.Families.Sum(f => f.Count)) .IsEqualTo(dashboard.Codebases.Identified); - await Assert.That(text).Contains("left out of the denominator, never counted as something else"); + await Assert.That(text).Contains(Say( + "ecosystem.codebases.basis", + ("listed", dashboard.Codebases.Identified + dashboard.Codebases.NotIdentified), + ("identified", dashboard.Codebases.Identified))); } [Test] @@ -155,8 +179,13 @@ public async Task TheCodebasePanelNamesTheListingBesideTheGamesThatAnswered() await Assert.That(listed).IsEqualTo(dashboard.ListedGames); await Assert.That(dashboard.Codebases.Identified).IsNotEqualTo(listed); - await Assert.That(text).Contains($"Of the {listed} games listed, {dashboard.Codebases.Identified} told us what they run"); - await Assert.That(text).Contains($"every share below is over those {dashboard.Codebases.Identified}"); + // Both numbers in one sentence, and each unmistakable for the other. + await Assert.That(text).Contains(Say( + "ecosystem.codebases.basis", + ("listed", listed), + ("identified", dashboard.Codebases.Identified))); + await Assert.That(text).Contains($"{listed} games listed"); + await Assert.That(text).Contains($"over those {dashboard.Codebases.Identified}"); } /// The one-game codebases are folded out of the chart and listed under it. @@ -177,7 +206,8 @@ public async Task ACodebaseOnlyOneGameRunsIsFoldedOutOfTheChartAndStillPrinted() await Assert.That(text).Contains( $"{codebases.SoleUseTotal.Count} of {codebases.Identified}"); - await Assert.That(text).Contains("no other listed game runs"); + await Assert.That(text).Contains( + Say("ecosystem.soleUse", ("share", EcosystemCopy.Share("en", codebases.SoleUseTotal)))); foreach (var alone in codebases.SoleUse) { @@ -201,7 +231,8 @@ public async Task TheMsspRowStaysBecauseItIsTheOnlyMeasurementThatIsNotAFloor() await Assert.That(mssp.Measured).IsNotNull(); await Assert.That(mssp.Declined).IsGreaterThan(0); await Assert.That(text).Contains("MSSP"); - await Assert.That(text).Contains("is the one row below that is not a floor"); + await Assert.That(text).Contains( + Say("ecosystem.mssp.instrument", ("instrument", EcosystemProtocols.Instrument))); } [Test] @@ -218,9 +249,9 @@ public async Task OnlyMsspHasNoDeclaredFigureAndTheReasonIsItsDenominator() var blank = dashboard.Protocols.Where(p => p.DeclaredShare is null).Select(p => p.Protocol); await Assert.That(blank).IsEquivalentTo(new[] { "MSSP" }); - await Assert.That(EcosystemCopy.Declared(dashboard.Protocols.Single(p => p.Protocol == "MSSP"))) + await Assert.That(EcosystemCopy.Declared("en", dashboard.Protocols.Single(p => p.Protocol == "MSSP"))) .DoesNotContain("%"); - await Assert.That(text).Contains("every report here is the answer"); + await Assert.That(text).Contains(Say("ecosystem.declared.none")); } [Test] @@ -236,13 +267,14 @@ public async Task TwoCountsOfMsspAreReconciledRatherThanLeftToSubtract() var withGap = new ProtocolAdoption("MSSP", Offered: 123, Declined: 295, Handshakes: 418, Declared: 1, MsspReports: 131); - await Assert.That(EcosystemCopy.MsspBasis(withGap, 131)).Contains("We hold 131 reports"); - await Assert.That(EcosystemCopy.MsspBasis(withGap, 131)).Contains("the other 8"); + await Assert.That(EcosystemCopy.MsspBasis("en", withGap, 131)).Contains( + Say("ecosystem.mssp.gap", ("reports", 131), ("offered", 123), ("gap", 8))); // And no gap means no sentence about one, rather than "the other 0". var level = withGap with { Offered = 131 }; - await Assert.That(EcosystemCopy.MsspBasis(level, 131)).DoesNotContain("the other"); + await Assert.That(EcosystemCopy.MsspBasis("en", level, 131)) + .IsEqualTo(Say("ecosystem.mssp.instrument", ("instrument", EcosystemProtocols.Instrument))); } [Test] @@ -250,9 +282,15 @@ public async Task TheRankingsStateWhatTheyRankOnAndOverWhatWindow() { var text = Render.Words(await RankingsAsync()); - await Assert.That(text).Contains("Median of the player counts we measured over the last 7 days"); - await Assert.That(text).Contains("counted samples a median needs"); - await Assert.That(text).Contains("A measured zero counts; an unreadable count does not"); + await Assert.That(text).Contains(Say("rankings.basis.median", ("days", 7))); + + // The threshold, as a sentence rather than as arithmetic. Where nothing qualifies the page + // said "0 of 519 games listed produced the 24 counted samples a median needs, on at least 4 + // days of the window", which is a subtraction the reader was left to do. + await Assert.That(text).Contains("24 samples across 4 days"); + + // Rule 4 on the surface that most invites a zero to be read as an absence. + await Assert.That(text).Contains(Say("rankings.basis.zero")); } [Test] @@ -261,7 +299,7 @@ public async Task TheRankingsOfferNoVoteAndClaimNoBest() // §2's permanent non-goal, said on the surface a reader would look for it on. var text = Render.Words(await RankingsAsync()); - await Assert.That(text).Contains("No votes, stars or ratings, ever"); + await Assert.That(text).Contains(Say("rankings.noVote")); await Assert.That(text.ToLowerInvariant()).DoesNotContain("rate this"); await Assert.That(text.ToLowerInvariant()).DoesNotContain("top rated"); } diff --git a/tests/MUI.Web.Tests/FacetSurfaceTests.cs b/tests/MUI.Web.Tests/FacetSurfaceTests.cs index 6cf11d6..1f6e01c 100644 --- a/tests/MUI.Web.Tests/FacetSurfaceTests.cs +++ b/tests/MUI.Web.Tests/FacetSurfaceTests.cs @@ -1,3 +1,4 @@ +using MUI.Web.Localization; using System.Reflection; using MUI.Catalog; @@ -39,6 +40,12 @@ .. typeof(FacetKeys) FacetKeys.Tls => "true", FacetKeys.Band => "quiet", FacetKeys.LastSeen => "week", + + // One word each, like the two bands above and unlike the open-ended facets: their + // vocabularies are a single value, so the binding refuses anything else rather than + // narrowing a listing to nothing on a typo. + FacetKeys.Uncounted => "yes", + FacetKeys.Unreachable => "yes", FacetKeys.Protocol => "GMCP", // Not the default. The point of the check below is that the parameter changed something, // and asking for the order the filter already arrives in changes nothing by construction. @@ -216,9 +223,16 @@ public async Task ThePanelIsAPlainGetFormWithAControlPerFacet() await Assert.That(html).Contains("action=\"/games\""); await Assert.That(html).DoesNotContain("