feat(site): phase 17 - per-game module pages, /runicnpc/, and checkTerms over the built site
All checks were successful
PR checks / checks (pull_request) Successful in 2m37s

PLAN.md §16 phase 17, built on the org lead's answers of 2026-10-22,
recorded as D75-D79 (count of record 79).

- D75: core marketing pages describe what a game module adds in core's
  words. "Game intelligence" became "What a game module adds"; each
  game's own list moved to src/data/games/ and renders only on the new
  /modules/uo/ and /modules/rust/. /modules/ is a card per game.
- The page-side capability check is per game (assertGameCoverage), the
  item phase 13 moved here.
- D76: the homepage keeps its game screenshot, labelled. screens.mjs
  entries may carry `game` (D67); Screenshot.astro and the app grid
  print the game's name and mark the figure data-game.
- D77: /app/ describes the Rust screens (released the same day).
- D78: /runicnpc/, the standalone page, is in the header and footer;
  the narrow header was re-measured so five links keep two rows.
- D79: checkTerms.mjs --built reads every built page's visible text
  after the build; game routes and data-game elements allow their
  game's words. Proven against ten plants. Still the twelfth check.
- Re-read fixes: /architecture/'s "two machines is also the maximum",
  Discord "guild" on /integrations/, and collection.mjs against what
  Steam sign-in and the Rust module keep (PLAY_DATA_SAFETY.md
  regenerated; the Play answers are unchanged).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-10-22 20:10:51 -05:00
parent ec65b2d5aa
commit 56473d4d51
32 changed files with 1924 additions and 451 deletions

View File

@@ -16,23 +16,17 @@
* the homepage without an argument to go with it.
*
* ---------------------------------------------------------------------------------------
* THE PART THAT IS A CHECK, NOT A LIST
* THE ONE GROUP CORE DOES NOT SUPPLY, IN CORE'S WORDS
* ---------------------------------------------------------------------------------------
* "Game intelligence" is the only group core does not supply — it comes from whichever
* module is installed, and today that is `module-uo`. Its items therefore carry the
* capability slugs the module actually declares in its `module.json`, and
* `assertCapabilityCoverage()` fails the build if the two lists drift apart.
* "What a game module adds" is the only group core does not supply — it comes from
* whichever module is installed. Until phase 17 it was "Game intelligence" and listed the
* Ultima Online module's features in that game's words, on the homepage and `/features/`.
* Two modules exist now, and D63 keeps a game's vocabulary on its own pages, so the group
* says what BOTH modules add, in core's words, and each game's real list lives in
* `src/data/games/` and on its page under `/modules/` (D75).
*
* That closes a real gap. `platform.json` holds `games.uo.module.capabilities` and
* `scripts/checkFacts.mjs` re-reads it from the module's manifest on every build — so the
* day `module-uo` gains a capability, the JSON goes red and someone updates it. Before this
* function, updating the JSON was the end of it and the page kept the old list. Now the
* page is what goes red next.
*
* Note that slugs are NOT one-per-item in either direction: `shard` is the source of four
* separate user-facing capabilities, and the marketplace draws on `market` and `cliloc`
* together (item names arrive as cliloc ids and are resolved against the shard's own
* string table). The check is coverage in both directions, not a bijection.
* The capability check moved with the lists: `assertGameCoverage()` in `games/index.mjs`
* holds each game's features to the capabilities its module declares, in both directions.
*
* ---------------------------------------------------------------------------------------
* `needsModule` — THE THIRD STATE, WHICH PHASE 3 DID NOT HAVE
@@ -47,8 +41,8 @@
* the tables, the roster resolver, the forums, the notification streams, the Discord bridge,
* the voice channels, the activity feed and `/admin/teams` — and cannot *originate* a Team.
* They arrive from the installed module, which is exactly the point: core does not own the
* word for a Team, so `module-uo` calls them guilds and builds the pages, and a future
* module can call them something else on the same primitive.
* word for a Team, so each module calls them what its game calls them and builds the pages
* on the same primitive.
*
* So `needsModule` marks an item that is core machinery a module has to populate. On a bare
* core it is present, correct and permanently empty. `/` renders the requalified group
@@ -68,11 +62,13 @@
* character sheets are reachable only by the account they belong to, and a Team forum lives
* behind a Team id no static page can know. Paths are read from the real route tables —
* core's `client/src/App.jsx` and `module-uo`'s `client/src/entry.jsx` on `main` — never
* guessed. Note the module's public pages are namespaced under its own id, so a UO route is
* `/uo/…`; a deployment running a different module would deep-link somewhere else, which is
* why these sit beside the `caps` slugs on the module-supplied items.
* guessed. A module's public pages are namespaced under its own id, so a route into the
* Ultima Online module is `/uo/…`: the demo §15 plans runs that game, which is why the
* Teams item points there and the module-supplied items in `games/` carry their own.
*/
import { games } from './games/index.mjs';
/**
* The shape of a capability, written out because TypeScript otherwise infers it per group
* from whichever fields that group's items happen to use — and then `/features/` cannot
@@ -83,7 +79,7 @@
* @typedef {object} Capability
* @property {string} label What it is called, on every page that lists it.
* @property {string} detail The argument for it. `/features/` only; see D20.
* @property {string[]} [caps] Module capability slugs, on module-supplied items only.
* @property {string[]} [caps] Module capability slugs, on a game's own items (`games/`) only.
* @property {boolean} [needsModule] Core machinery a module has to populate (D24).
* @property {string} [demoPath] A stable public route, deep-linked into a demo (D25).
*
@@ -124,8 +120,8 @@ const community = {
'A roster, a leader, a private forum, its own notification streams and a Discord ' +
'voice channel, all hanging off one group. Core owns every part of that machinery ' +
'and deliberately cannot create a Team: they arrive from the installed module, ' +
'which is how a guild inside the game becomes a Team on the site — and why a ' +
'different game can call them something else without core learning a new word.',
'which is how a group the game already has becomes a Team on the site — and why ' +
'each game keeps its own name for one without core learning a new word.',
},
{
label: 'Team forums',
@@ -178,102 +174,66 @@ const community = {
};
/**
* Game intelligence — module-supplied. The `caps` arrays are the contract with
* `platform.json`; see `assertCapabilityCoverage` below.
* What a game module adds — module-supplied, and written in core's words (D63, D75). Each
* item is something BOTH modules supply, each in its own game's way; the game's own list,
* in its own words, is on its page under `/modules/`, which `/features/` and the homepage
* link to. No `caps` here: they belong to a game's own items in `src/data/games/`, where
* `assertGameCoverage()` checks them.
*
* @type {CapabilityGroup}
*/
const gameIntelligence = {
id: 'game-intelligence',
title: 'Game intelligence',
const gameModule = {
id: 'game-module',
title: 'What a game module adds',
moduleSupplied: true,
summary:
'Supplied by the installed game module, not by the core site. Two modules exist, for ' +
'Ultima Online and for Rust; this is what the Ultima Online module publishes from a ' +
'live game server, and the Rust module brings its own set in its place.',
'Ultima Online and for Rust, and each adds its own set from a live game server — these ' +
'are the parts they share.',
items: [
{
label: 'Live server status',
caps: ['shard'],
demoPath: '/uo/shard',
detail:
'Whether the server is up, who is on it, and how long ago the site last heard from ' +
'it. When the game is down this page is the thing that says so — the site does not ' +
'go down with it.',
'Whether each game server is up, who is on it, and how long ago the site last heard ' +
'from it. When the game is down the site is the thing that says so — it does not go ' +
'down with it.',
},
{
label: 'Economy and activity',
caps: ['shard'],
demoPath: '/uo/shard/activity',
label: 'A live feed of the world',
detail:
'A live feed of what is happening in the world, and the economy underneath it. ' +
'Every event passes an allowlist before it can reach a public page; staff read a ' +
'second stream carrying the rest.',
'What is happening in the game, as it happens. Every kind of event passes an ' +
'allowlist the module holds before it can reach a public page; staff read a second ' +
'stream carrying the rest.',
},
{
label: 'Character sheets',
caps: ['shard'],
label: 'Leaderboards',
detail:
'Skills, stats and equipment, drawn from the live world rather than from a form ' +
'somebody filled in. Reachable by the account the character is linked to, and by ' +
'staff — not by the public.',
'The numbers the game already keeps, published without anyone exporting a ' +
'spreadsheet on a Sunday — and kept by the site after the game has moved on.',
},
{
label: 'Points and loyalty boards',
caps: ['shard'],
demoPath: '/uo/leaderboards',
label: 'Maps of the world',
detail:
'The leaderboards the game already keeps, published without anyone exporting a ' +
'spreadsheet on a Sunday.',
'Where things are, drawn from the server rather than from someone else’s idea of the ' +
'game: one module maps what spawns where, the other shows what is moving now.',
},
{
label: 'Player-vendor marketplace',
caps: ['market', 'cliloc'],
demoPath: '/uo/market',
label: 'Teams, from the game',
detail:
'Every player vendor on the server and what is on it, searchable without logging ' +
'in to the game. Item names arrive from the world as numeric ids and are resolved ' +
"against the game's own string table, so they read as names rather than numbers — " +
"beside the item's own picture, in the colour it was dyed.",
'The groups players already form in the game arrive as Teams on the site, with the ' +
'forum, notifications and voice channel core attaches to one.',
},
{
label: 'Houses and IDOC decay',
caps: ['houses'],
demoPath: '/uo/houses',
label: 'Players’ own game accounts',
detail:
'Who owns what and where it stands, including which houses are decaying — ' +
'published while it is still information rather than after it has become a rumour.',
'A player links their game identity to their site account and sees their own side ' +
'of the game on it. Who may see another player at all is the operator’s choice.',
},
{
label: 'Spawn atlas',
caps: ['atlas'],
demoPath: '/uo/atlas',
label: 'Game steps for scheduled events',
detail:
"A bestiary and spawn map built by reading your shard's own spawn tables, so it " +
"describes your server rather than someone else's idea of the game. Regions, " +
'landmarks and champion altars come with it, and each creature is shown as the ' +
'artwork your own client draws it with.',
},
{
label: 'Champion boards',
caps: ['champs'],
demoPath: '/uo/champs',
detail: 'Which altars are running, how far along they are, and what turned up.',
},
{
label: 'Guilds',
caps: ['guilds'],
demoPath: '/uo/guilds',
detail:
'Guild rosters and standings, kept in step with the game. This is also what fills ' +
'the Teams primitive above: a guild in the world becomes a Team on the site, with ' +
"the forum, the notifications and the voice channel that core attaches to one.",
},
{
label: 'City governors',
caps: ['governors'],
demoPath: '/uo/governors',
detail: 'Who holds which city, and what they did with it.',
'What an event may do inside the world comes from the module: core runs the ' +
'calendar, the budget and the undo, and the module brings the steps.',
},
],
};
@@ -372,7 +332,7 @@ const integration = {
{
label: 'Discord: slash commands, notifications, voice',
detail:
'A bot for the guild you already have. Commands answer from your site, ' +
'A bot for the Discord server you already have. Commands answer from your site, ' +
'notifications bridge into channels, and a Team can be granted a voice channel ' +
'that maintains its own membership.',
},
@@ -429,7 +389,7 @@ const infrastructure = {
export const capabilityGroups = [
community,
gameIntelligence,
gameModule,
administration,
integration,
infrastructure,
@@ -456,46 +416,11 @@ export function capabilityGroup(id) {
);
}
/**
* Fails the build when the module's declared capabilities and this page's list disagree.
*
* Called from the component rather than from a check script on purpose: the failure needs
* to reach whoever is editing the page, and an Astro build error names the component. It
* also means the rule cannot be skipped by running `astro build` without `npm run verify`.
/*
* The capability check that lived here — "Game intelligence" against the module's declared
* capabilities — moved to `assertGameCoverage()` in `games/index.mjs` in phase 17, with the
* lists it checks.
*/
export function assertCapabilityCoverage(declared) {
const claimed = new Set();
for (const item of gameIntelligence.items) {
for (const cap of item.caps || []) claimed.add(cap);
}
const known = new Set(declared);
const unlisted = declared.filter((cap) => !claimed.has(cap));
const invented = [...claimed].filter((cap) => !known.has(cap));
if (!unlisted.length && !invented.length) return;
const lines = [];
if (unlisted.length) {
lines.push(
`the installed module declares ${unlisted.map((c) => `"${c}"`).join(', ')}, which no ` +
`capability on the homepage claims — the site is under-selling what it can show.`
);
}
if (invented.length) {
lines.push(
`the homepage claims ${invented.map((c) => `"${c}"`).join(', ')}, which the module no ` +
`longer declares — the site is advertising something that is gone (§1).`
);
}
throw new Error(
`src/data/capabilities.mjs disagrees with platform.json's games.uo.module.capabilities:\n` +
lines.map((line) => ` - ${line}`).join('\n') +
`\n\nUpdate the "Game intelligence" items, or the JSON if the module itself changed.\n`
);
}
/**
* Fails the build when a capability has no `detail`.
@@ -505,8 +430,9 @@ export function assertCapabilityCoverage(declared) {
* asymmetry between the two renderings is the whole of D20, and this is what keeps the
* thinner one from being the only one anybody notices.
*
* Called from `/features/` for the same reason `assertCapabilityCoverage` is called from
* the homepage: the build error should name the page that would have shipped wrong.
* Called from `/features/` for the same reason `assertGameCoverage` is called from the
* homepage: the build error should name the page that would have shipped wrong. It also
* reads each game's own list, which its page under `/modules/` renders with the detail too.
*/
export function assertDetailCoverage() {
const missing = [];
@@ -515,6 +441,11 @@ export function assertDetailCoverage() {
if (!item.detail?.trim()) missing.push(`${group.title} → ${item.label}`);
}
}
for (const game of games) {
for (const item of game.features) {
if (!item.detail?.trim()) missing.push(`${game.name} → ${item.label}`);
}
}
if (!missing.length) return;