Files
runicgateway.com/src/data/capabilities.mjs
wtclaude 4e1ce2316a
All checks were successful
PR checks / checks (pull_request) Successful in 1m41s
docs(site): the Asset Bridge — protocol 8, a screen for client files, and pictures in the screenshots (Phase 9c)
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
2026-09-15 05:45:38 -05:00

527 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 deployments 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`
);
}