All checks were successful
PR checks / checks (pull_request) Successful in 9m34s
Phase 16c of the events plan: `runicgateway.com`'s half of the workstream, now that `main` carries the engine, the module, the app and the bundle. The checks were already red and named their own answers: * `checkFacts` — nine values had moved. Protocol 5 → 7 in all three declaration sites, `moduleApi` 1.9.0 → 1.10.0, the bundle to 2026.09.10 with sidecar v2.2.0 and overlay v1.2.0, `link` v2.2.0, `Module-uo` v1.2.2. * `checkReference` — twenty-seven `Bridge.cfg` keys the site listed nowhere: the events switch and its sweep, the ten caps, the oracle NPC, the two lease keys and the seven participation keys. They are five new groups rather than an appendix to an existing one, because `EventsEnabled` is a second consent switch and belongs beside its own ceilings. Two pages, matching the treatment Teams has: * **Scheduled events** (Administration) — where it is and who sees it, authoring and immutable versions, the switchboard that arrives off, caps as a condition on an `UPDATE` rather than a role check, the dry run, the run console, what an event owns versus what it borrows, generated cleanup, the shard's own switches, and what a player sees. * **Events architecture** — the two sentences it turns on, what is a table and what deliberately is not, budgets in SQL, the ledger's two rules, at-most-once on a wire that can lose an answer, the three layers, and the four omissions. And the rest of the surface: * `/privacy` gains **`deploy-events`** — the participation ledger is personal data and no row named it. Scores and ranks against a module-opaque member key, linked to an account where one is linked; the diagnostic log swept after 90 days on terminal runs only; the run, its steps and its participants not swept at all, because they are the record of what was done to a shared world. `deploy-game-data`'s citation moves from `link/v4.md` to `v7.md`. * **Protocol versions** — the most recent bump touched *five* repositories, and the `website` row is the interesting one: core is normally out of a protocol bump's reach and this one reached it, because what changed was not a game noun but the shape of a thing core owns the ledger for. The store-migration paragraph now says four bumps' worth rather than two. * Two capability entries, so `/`, `/features/` and `/modules/` stop omitting the subsystem — an Administration item, and an **Event calendar** under Community with `/site/events` as its deep link. Deliberately *not* `needsModule`: a bare core can author and run an event, and only the world verbs need a module. * **`reference/event-catalog` is retitled "Shard event catalog"** and says what it is not. Two things in the docs were called an event catalog; the route is unchanged, so nothing outside this repository breaks. * `canonicalDocs` gains `website/EVENTS.md` and moves `link/v4.md` → `v7.md`. No screenshots. Capturing the events surfaces means standing the whole rig back up — game server, sidecar, core, module, a published event with a live run — for two or three images that no check requires, and the engagement workstream's own site leg added none either. `npm run verify` green end to end, including `checkReference` against the protocol spec that only reached `docs` `main` in RunicGateway/docs#232 — the seventh cutover step, which 16b had left on `edge`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
525 lines
22 KiB
JavaScript
525 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.",
|
||
},
|
||
{
|
||
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.',
|
||
},
|
||
{
|
||
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`
|
||
);
|
||
}
|