All checks were successful
PR checks / checks (pull_request) Successful in 1m41s
The last leg of the Asset Bridge (docs/link/v8.md §16, phase 9c). The cutover put protocol 8 on `main` in every declaration site, which turned this repo red by construction: `checkFacts.mjs` reads the protocol from `link`'s `main`. Two checks were failing, and only one of them was expected. **checkFacts (10)** — protocol 7 → 8 in all three reads (sidecar, overlay, bundle), the bundle triple to 2026.09.15 / sidecar v2.3.0 / overlay v1.3.0, and the releases to link v2.3.0, installer v0.2.0, Module-uo v1.3.0. `moduleApi` and the capability list did not move: core's whole share of eight phases was a deletion. **checkReference (9)** — nobody had planned for this one. `Bridge.cfg` grew nine keys across phases 5, 6 and 7 and the reference page had gone on not mentioning them, which is the failure mode that check exists for. They are listed as two new groups, `Client assets` and `Spawn files`, because they are two separate consents. ## The prose the bridge changed - **New page, Administration → Client files.** `Admin → Client files` is a screen an operator has to press a button on and the site had no page for it. It carries the rule nothing else states: **nothing here happens on a restart**, so a patched client keeps serving the old artwork until somebody says so. - **Protocol versions** said the most recent bump touched five repositories and named `website`. Protocol 8 touched four and did not reach core — its absence is the interesting half, and the page now says why. The store-migration paragraph gains 8, which changed no line of the sidecar's store despite moving megabytes. - **The bridge** said "two ways in". There are three: bulk reads go over the request/reply path in pages, one in flight at a time, and the reason they must not ride the event stream is structural rather than about speed. - **Requirements** gains `libgdiplus` for Linux shard hosts, and the note that no game client has to be installed for any of this — a ServUO shard cannot boot without one. - **Troubleshooting** gains the failure mode that did not exist before: a bestiary with no pictures, or items reading as numbers. - **Maintenance** gains the button to press after patching a client, and **Verify the whole stack** the `doctor` check that landed with it. - The spawn atlas and the marketplace both carry artwork now, so both capability descriptions say so, and `canonicalDocs` points at `link/v8.md`. ## Screenshots, retaken against the real thing The atlas and marketplace shots predate the asset plane, so both were retaken on the demo deployment against a real shard and a real sidecar, plus the new admin screen: 1,095 portraits imported in 2.1 s, 67,496 names in 1.4 s, 313 item pictures in 0.8 s. Nothing here is a mock-up. The atlas shot moved 36px down its page because portraits made the rows taller. ## One defect found on the way `seedDemo.mjs` pinned the demo deployment at **protocol 4** with a comment explaining a `module-uo` debt that has since been paid. Its default now comes from `platform.json`, so the rig cannot drift two protocols behind the platform again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
527 lines
22 KiB
JavaScript
527 lines
22 KiB
JavaScript
/**
|
||
* capabilities.mjs — the five capability groups of PLAN.md §10, as data.
|
||
*
|
||
* ---------------------------------------------------------------------------------------
|
||
* WHY THIS IS DATA AND NOT MARKUP
|
||
* ---------------------------------------------------------------------------------------
|
||
* The homepage names these groups, `/features/` expands them, and `/modules/` explains the
|
||
* core/module split they encode. Three pages listing the same capabilities in three
|
||
* hand-maintained lists is how a site ends up advertising something that was removed, which
|
||
* §1 forbids. One list, read by all three.
|
||
*
|
||
* Phase 4 added the `detail` line rather than writing `/features/` as prose (D20). The two
|
||
* pages are then one list rendered twice — `/` takes the label, `/features/` takes the
|
||
* label and the detail — and they cannot disagree about what exists, only about how much
|
||
* they say. `assertDetailCoverage()` below is what stops the next capability being added to
|
||
* the homepage without an argument to go with it.
|
||
*
|
||
* ---------------------------------------------------------------------------------------
|
||
* THE PART THAT IS A CHECK, NOT A LIST
|
||
* ---------------------------------------------------------------------------------------
|
||
* "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.
|
||
*
|
||
* That closes a real gap. `platform.json` holds `moduleUoCapabilities` 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.
|
||
*
|
||
* ---------------------------------------------------------------------------------------
|
||
* `needsModule` — THE THIRD STATE, WHICH PHASE 3 DID NOT HAVE
|
||
* ---------------------------------------------------------------------------------------
|
||
* A group is `moduleSupplied` or it is not, and phase 3 shipped the Community group saying
|
||
* "everything here works on a deployment with no game module installed at all". Writing the
|
||
* `/features/` detail for Teams is what exposed that as false, and the tree says so plainly
|
||
* on `main`: `teams.module_id` is `NOT NULL`, there is no create route anywhere under
|
||
* `/api/v1/admin/teams`, and `teamSync` is gated on `teamProvider.providerModuleId()`.
|
||
*
|
||
* The truth is neither of the two states the file had. Core owns the whole Team machinery —
|
||
* 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.
|
||
*
|
||
* 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
|
||
* summary; `/features/` renders the marker and says why (D24).
|
||
*
|
||
* ---------------------------------------------------------------------------------------
|
||
* `demoPath` — DEEP LINKS THAT ONLY EXIST WHEN A DEMO DOES
|
||
* ---------------------------------------------------------------------------------------
|
||
* §15/D12 keeps the public demo out of scope while requiring the site to gain one by way of
|
||
* a line in a mounted `brand.json`. `brand.json`'s own comment promised `/features/` a
|
||
* "per-capability affordance" that had never been defined; D25 defines it as a deep link
|
||
* per capability that has a stable public route, appended to the mounted `demoUrl` by
|
||
* `applyBrand.mjs` at boot and hidden by the same `[data-demo-url='']` rule as the
|
||
* homepage's slot.
|
||
*
|
||
* Only some capabilities have one, and that asymmetry is honest rather than unfinished:
|
||
* 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.
|
||
*/
|
||
|
||
/**
|
||
* 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
|
||
* read `demoPath` off an Administration item, because no Administration item has one.
|
||
* `astro check` catches that, correctly: the union of five literal shapes is not the shape
|
||
* the page is written against.
|
||
*
|
||
* @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 {boolean} [needsModule] Core machinery a module has to populate (D24).
|
||
* @property {string} [demoPath] A stable public route, deep-linked into a demo (D25).
|
||
*
|
||
* @typedef {object} CapabilityGroup
|
||
* @property {string} id
|
||
* @property {string} title
|
||
* @property {string} summary
|
||
* @property {boolean} moduleSupplied
|
||
* @property {Capability[]} items
|
||
*/
|
||
|
||
/**
|
||
* Community — core machinery. Everything here ships with the site itself and none of it
|
||
* knows what game you run; two of the six still need a module to put anything in them,
|
||
* which is what `needsModule` says.
|
||
*
|
||
* @type {CapabilityGroup}
|
||
*/
|
||
const community = {
|
||
id: 'community',
|
||
/**
|
||
* Core, not module-supplied. Stated on every group rather than only on the one that is
|
||
* true, so the shape of a group is uniform — the homepage reads this field on all five,
|
||
* and an inferred union that carries it on one member is an error waiting for the next
|
||
* template that touches it.
|
||
*/
|
||
moduleSupplied: false,
|
||
title: 'Community',
|
||
summary:
|
||
'The site your players actually use, none of which knows what game you run — though ' +
|
||
'Teams arrive from the installed module rather than being created here.',
|
||
items: [
|
||
{
|
||
label: 'Teams',
|
||
needsModule: true,
|
||
demoPath: '/uo/guilds',
|
||
detail:
|
||
'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.',
|
||
},
|
||
{
|
||
label: 'Team forums',
|
||
needsModule: true,
|
||
detail:
|
||
'Announcements, discussion threads and replies, with an edit window, post ' +
|
||
'moderation, and abuse reports a member can raise without going through staff ' +
|
||
'first. Forums are an admin switch for the whole deployment, and image uploads ' +
|
||
'stay off until someone deliberately turns them on.',
|
||
},
|
||
{
|
||
label: 'Notifications',
|
||
detail:
|
||
'On the site, by push and by email, chosen per notification by each person rather ' +
|
||
'than per person by you. The on-site inbox arrives by default and can be switched ' +
|
||
'off; push and email only ever arrive if they were asked for.',
|
||
},
|
||
{
|
||
label: 'Event calendar',
|
||
demoPath: '/site/events',
|
||
detail:
|
||
'What is scheduled, what is happening now, what finished recently, and the ' +
|
||
'results afterwards — with arcs, so a three-part story reads as one thing rather ' +
|
||
'than three unrelated entries. A run that was cancelled says so instead of ' +
|
||
'quietly vanishing. Core owns the whole calendar and can run an event on its ' +
|
||
'own; what an event may do inside a game world comes from the installed module.',
|
||
},
|
||
{
|
||
label: 'Wiki',
|
||
demoPath: '/wiki',
|
||
detail:
|
||
'For the things that outlive a news post — rules, guides, the lore nobody wants to ' +
|
||
'retype in chat. Written in the admin panel, published on the public site.',
|
||
},
|
||
{
|
||
label: 'News and newsletter',
|
||
demoPath: '/site/news',
|
||
detail:
|
||
'Four kinds of post — news, five-on-friday, newsletter issues and screenshots — ' +
|
||
'plus CMS pages and a page builder for everything that is not a post at all.',
|
||
},
|
||
{
|
||
label: 'Player self-service',
|
||
detail:
|
||
'An account area every signed-in person gets, whatever their role: their profile, ' +
|
||
'their linked game accounts, their own characters, their devices and sessions. ' +
|
||
'Staff are players too, so it is the same area for everyone.',
|
||
},
|
||
],
|
||
};
|
||
|
||
/**
|
||
* Game intelligence — module-supplied. The `caps` arrays are the contract with
|
||
* `platform.json`; see `assertCapabilityCoverage` below.
|
||
*
|
||
* @type {CapabilityGroup}
|
||
*/
|
||
const gameIntelligence = {
|
||
id: 'game-intelligence',
|
||
title: 'Game intelligence',
|
||
moduleSupplied: true,
|
||
summary:
|
||
'Supplied by the installed game module, not by the core site. Today that module is ' +
|
||
'module-uo, and this is what it publishes from a live shard.',
|
||
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.',
|
||
},
|
||
{
|
||
label: 'Economy and activity',
|
||
caps: ['shard'],
|
||
demoPath: '/uo/shard/activity',
|
||
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.',
|
||
},
|
||
{
|
||
label: 'Character sheets',
|
||
caps: ['shard'],
|
||
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.',
|
||
},
|
||
{
|
||
label: 'Points and loyalty boards',
|
||
caps: ['shard'],
|
||
demoPath: '/uo/leaderboards',
|
||
detail:
|
||
'The leaderboards the game already keeps, published without anyone exporting a ' +
|
||
'spreadsheet on a Sunday.',
|
||
},
|
||
{
|
||
label: 'Player-vendor marketplace',
|
||
caps: ['market', 'cliloc'],
|
||
demoPath: '/uo/market',
|
||
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.",
|
||
},
|
||
{
|
||
label: 'Houses and IDOC decay',
|
||
caps: ['houses'],
|
||
demoPath: '/uo/houses',
|
||
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.',
|
||
},
|
||
{
|
||
label: 'Spawn atlas',
|
||
caps: ['atlas'],
|
||
demoPath: '/uo/atlas',
|
||
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.',
|
||
},
|
||
],
|
||
};
|
||
|
||
/** @type {CapabilityGroup} */
|
||
const administration = {
|
||
id: 'administration',
|
||
moduleSupplied: false,
|
||
title: 'Administration',
|
||
summary: 'Running the place, with a record of who did what.',
|
||
items: [
|
||
{
|
||
label: 'Roles and permissions',
|
||
detail:
|
||
'Admin, moderator and player. Admin access is re-checked against the database on ' +
|
||
'every request rather than trusted from whatever the session was issued with, so ' +
|
||
'demoting someone takes effect on their next click and not at their next login.',
|
||
},
|
||
{
|
||
label: 'Moderation and appeals',
|
||
detail:
|
||
'Decisions carry a written reason, and the person on the receiving end has a ' +
|
||
'documented way to answer rather than a direct message to whoever is awake.',
|
||
},
|
||
{
|
||
label: 'Content reports',
|
||
detail:
|
||
'Anything a member writes can be reported by another member, into a staff queue ' +
|
||
'with the context attached.',
|
||
},
|
||
{
|
||
label: 'Append-only audit log',
|
||
detail:
|
||
'Staff actions are recorded, and nothing in the panel can edit or delete the ' +
|
||
'record afterwards. That is worth having on the day you need to prove what did ' +
|
||
'not happen.',
|
||
},
|
||
{
|
||
label: 'Bot scoring and IP bans',
|
||
detail:
|
||
'Login attempts are scored on behaviour rather than on a puzzle a real person has ' +
|
||
'to solve, and a bad enough score bans the address by itself. The panel is a read ' +
|
||
'view with an emergency unban, deliberately — it is not somewhere to tune a ' +
|
||
'threshold at three in the morning.',
|
||
},
|
||
{
|
||
label: 'Scheduled world events',
|
||
detail:
|
||
'Author an event as phases and steps, publish a version, put it on the calendar ' +
|
||
'and let it run unattended — with a dry run first that prices the whole plan ' +
|
||
'against this deployment’s caps. Every action arrives switched off, every run ' +
|
||
'has a per-run budget enforced in the database rather than in a role check, and ' +
|
||
'everything an event creates or borrows is written to a ledger so the undo is ' +
|
||
'generated rather than authored. Pause, resume, skip a step or cancel with ' +
|
||
'cleanup, all logged with the person who did it.',
|
||
},
|
||
{
|
||
label: 'Module management',
|
||
detail:
|
||
'Install, disable, uninstall and purge a module from the panel. Uninstalling keeps ' +
|
||
'the data and reinstalling picks it up where it was; deleting it is a separate, ' +
|
||
'opt-in choice.',
|
||
},
|
||
{
|
||
label: 'The game-server connection',
|
||
detail:
|
||
"The bridge's address, token and protocol version live in the panel rather than in " +
|
||
'an environment file, so connecting a server is not a redeploy. The token is ' +
|
||
'encrypted at rest and write-only in the API — it is never returned to any client, ' +
|
||
'including yours.',
|
||
},
|
||
],
|
||
};
|
||
|
||
/** @type {CapabilityGroup} */
|
||
const integration = {
|
||
id: 'integration',
|
||
moduleSupplied: false,
|
||
title: 'Integration',
|
||
summary: 'The seams that let other things reach in — and one game reach out.',
|
||
items: [
|
||
{
|
||
label: 'Modules',
|
||
detail:
|
||
'The whole game-specific half of a deployment is an installable module: routes, ' +
|
||
'screens, tables and nav rows, versioned against a declared core API. Installing ' +
|
||
'one is a paste in the admin panel or a line in your environment, never a build.',
|
||
},
|
||
{
|
||
label: 'The sidecar bridge',
|
||
detail:
|
||
'A small service beside the game server, speaking a versioned wire protocol to the ' +
|
||
'site and a loopback socket to the game. It is the only part of the bridge anything ' +
|
||
'can reach over a network, and the game never listens at all.',
|
||
},
|
||
{
|
||
label: 'Discord: slash commands, notifications, voice',
|
||
detail:
|
||
'A bot for the guild 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.',
|
||
},
|
||
{
|
||
label: 'Mobile and push',
|
||
detail:
|
||
'A native Android app against the same documented API the website uses, with push ' +
|
||
'delivered through your own ntfy server rather than a vendor in the middle.',
|
||
},
|
||
{
|
||
label: 'SSO over OAuth2 / OIDC',
|
||
detail:
|
||
'Google, Discord, or any OIDC provider you run. Link-only by policy: an external ' +
|
||
'identity has to be attached to an account that already exists, and signing in ' +
|
||
'with one never creates a user.',
|
||
},
|
||
],
|
||
};
|
||
|
||
/** @type {CapabilityGroup} */
|
||
const infrastructure = {
|
||
id: 'infrastructure',
|
||
moduleSupplied: false,
|
||
title: 'Infrastructure',
|
||
summary: 'How it runs, and who it answers to.',
|
||
items: [
|
||
{
|
||
label: 'Self-hosted, start to finish',
|
||
detail:
|
||
'There is no hosted tier and no account with us. Every part of this runs on ' +
|
||
'hardware you control, which is the only arrangement under which the rest of the ' +
|
||
'claims on this page mean anything.',
|
||
},
|
||
{
|
||
label: 'Docker, with prebuilt pull-only images',
|
||
detail:
|
||
'Compose up, compose down. Images are pulled rather than built, so nothing ' +
|
||
'compiles on your server and an upgrade is a pull and a restart.',
|
||
},
|
||
{
|
||
label: 'Branding as data, not a rebuild',
|
||
detail:
|
||
'Name, colours, logo and contact address are a mounted file. The same image runs ' +
|
||
'as any community — including this site, which is built the same way.',
|
||
},
|
||
{
|
||
label: 'OpenAPI 3.0 for the whole API',
|
||
detail:
|
||
'The spec ships with the server and an installed module merges its own routes into ' +
|
||
'it, so the API you build against is the API that is actually running.',
|
||
},
|
||
],
|
||
};
|
||
|
||
export const capabilityGroups = [
|
||
community,
|
||
gameIntelligence,
|
||
administration,
|
||
integration,
|
||
infrastructure,
|
||
];
|
||
|
||
/**
|
||
* One group by id, or a build failure naming the id that was asked for.
|
||
*
|
||
* `capabilityGroups.find(...)` returns `CapabilityGroup | undefined`, so every page that
|
||
* wants one group has to either handle an impossible undefined or assert past it — and the
|
||
* assertion is what would eventually ship a blank section after somebody renamed an id.
|
||
* Failing here instead means a renamed group is caught by the first page that reads it.
|
||
*
|
||
* @param {string} id
|
||
* @returns {CapabilityGroup}
|
||
*/
|
||
export function capabilityGroup(id) {
|
||
const group = capabilityGroups.find((candidate) => candidate.id === id);
|
||
if (group) return group;
|
||
|
||
throw new Error(
|
||
`src/data/capabilities.mjs has no group with id "${id}", but a page asked for it.\n` +
|
||
`Known ids: ${capabilityGroups.map((candidate) => candidate.id).join(', ')}.\n`
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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`.
|
||
*/
|
||
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 moduleUoCapabilities:\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`.
|
||
*
|
||
* The homepage renders labels, so a capability added with nothing else still looks correct
|
||
* there — and `/features/` would silently render a heading with no argument under it. The
|
||
* 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.
|
||
*/
|
||
export function assertDetailCoverage() {
|
||
const missing = [];
|
||
for (const group of capabilityGroups) {
|
||
for (const item of group.items) {
|
||
if (!item.detail?.trim()) missing.push(`${group.title} → ${item.label}`);
|
||
}
|
||
}
|
||
|
||
if (!missing.length) return;
|
||
|
||
throw new Error(
|
||
`src/data/capabilities.mjs has ${missing.length} capabilit${missing.length === 1 ? 'y' : 'ies'} with no detail:\n` +
|
||
missing.map((entry) => ` - ${entry}`).join('\n') +
|
||
`\n\n/features/ renders the detail line (D20). A capability without one is a heading\n` +
|
||
`with nothing under it — write the sentence, or take the capability off the list.\n`
|
||
);
|
||
}
|