feat(marketing): phase 4 — the marketing pages
All checks were successful
PR checks / checks (pull_request) Successful in 9m9s
All checks were successful
PR checks / checks (pull_request) Successful in 9m9s
PLAN.md §13 phase 4: /features/, /architecture/, /modules/, /integrations/, and /community/ — plus the two scope items the phase table never assigned to anyone. Six decisions taken by the org lead before coding, recorded in PLAN.md §10 as D20-D25: - D20 /features/ is the homepage's list with a `detail` line, not a second list. One data file, two renderings, so they cannot disagree about what exists. - D21 /architecture/ draws reasons, not reference: three new inline SVGs, one per boundary. No endpoint tables, no config keys — those are phase 8's and stay canonical in docs/. - D22 The deliberate absences of §2 become one tagged data file, rendered on the three pages that promise them. - D23 Phase 4 absorbs /community/ (specified in §10 and §14 N3, linked from the header since phase 1, built by no phase) and checkLinks.mjs. - D24 `needsModule`: writing the Teams detail exposed a false claim phase 3 shipped. Teams are module-sourced only — teams.module_id is NOT NULL, there is no create route, sync is gated on providerModuleId() — so the Community group no longer says a bare core does all of it. - D25 The per-capability demo affordance brand.json had promised since phase 2 is a deep link, filled at boot from data-demo-path. checkLinks.mjs reads the built HTML rather than src/, because half these links are assembled from data files and template literals. Its PLANNED_ROUTES list is checked in both directions, so it cannot rot into a permanent exemption. applyBrand.mjs gained a pass that recomputes deep links from their immutable path, making it idempotent and reversible; checkBrand.mjs lifts that pattern out and runs it against the stock markup so the two cannot drift. Both proved against a real mount, in both directions. Fixes a cascade bug the checks could not see: [data-demo-url=''] and a scoped component class are both specificity 0,1,0, so .demo-link's `display` beat the hide rule and twelve links to a nonexistent demo rendered, each resolving to the current page. The rule is now !important. The four diagrams' shared SVG vocabulary moved to src/styles/diagram.css. Verified from a clean checkout: npm ci, all five checks, astro check (0 errors), production build, and a live browser pass at desktop and 390px. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -4,10 +4,16 @@
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHY THIS IS DATA AND NOT MARKUP
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* The homepage names these groups, `/features/` (phase 4) 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.
|
||||
* 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
|
||||
@@ -27,11 +33,74 @@
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Community — core, game-agnostic. Everything here works on a deployment with no game
|
||||
* module installed at all.
|
||||
* 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',
|
||||
@@ -43,20 +112,66 @@ const community = {
|
||||
*/
|
||||
moduleSupplied: false,
|
||||
title: 'Community',
|
||||
summary: 'The site your players actually use, none of which knows what game you run.',
|
||||
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' },
|
||||
{ label: 'Team forums' },
|
||||
{ label: 'Notifications' },
|
||||
{ label: 'Wiki' },
|
||||
{ label: 'News and newsletter' },
|
||||
{ label: 'Player self-service' },
|
||||
{
|
||||
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:
|
||||
'Web, push and email, chosen per stream by each person rather than per person by ' +
|
||||
'you. Push arrives by default and can be switched off; email only ever arrives if ' +
|
||||
'it was asked for.',
|
||||
},
|
||||
{
|
||||
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',
|
||||
@@ -66,59 +181,225 @@ const gameIntelligence = {
|
||||
'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'] },
|
||||
{ label: 'Economy and activity', caps: ['shard'] },
|
||||
{ label: 'Character sheets', caps: ['shard'] },
|
||||
{ label: 'Points and loyalty boards', caps: ['shard'] },
|
||||
{ label: 'Player-vendor marketplace', caps: ['market', 'cliloc'] },
|
||||
{ label: 'Houses and IDOC decay', caps: ['houses'] },
|
||||
{ label: 'Spawn atlas', caps: ['atlas'] },
|
||||
{ label: 'Champion boards', caps: ['champs'] },
|
||||
{ label: 'Guilds', caps: ['guilds'] },
|
||||
{ label: 'City governors', caps: ['governors'] },
|
||||
{
|
||||
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' },
|
||||
{ label: 'Moderation and appeals' },
|
||||
{ label: 'Content reports' },
|
||||
{ label: 'Append-only audit log' },
|
||||
{ label: 'Bot scoring and IP bans' },
|
||||
{ label: 'Module management' },
|
||||
{ label: 'The game-server connection' },
|
||||
{
|
||||
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: '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' },
|
||||
{ label: 'The sidecar bridge' },
|
||||
{ label: 'Discord: slash commands, notifications, voice' },
|
||||
{ label: 'Mobile and push' },
|
||||
{ label: 'SSO over OAuth2 / OIDC' },
|
||||
{
|
||||
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' },
|
||||
{ label: 'Docker, with prebuilt pull-only images' },
|
||||
{ label: 'Branding as data, not a rebuild' },
|
||||
{ label: 'OpenAPI 3.0 for the whole API' },
|
||||
{
|
||||
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.',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -130,6 +411,27 @@ export const capabilityGroups = [
|
||||
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.
|
||||
*
|
||||
@@ -170,3 +472,32 @@ export function assertCapabilityCoverage(declared) {
|
||||
`\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`
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user