Files
runicgateway.com/src/data/capabilities.mjs
wtclaude 769d213c80
Some checks failed
PR checks / checks (pull_request) Failing after 2m16s
feat(demo): list the demo's sign-ins on /beta and link into it per game
The public demo (demo.runicgateway.com) is live, so the site's demo slot
can be switched on. Two decisions of record, PLAN.md §15:

- D82: /beta reads the demo's GET /api/v1/public/settings demo.logins on
  the server, caches it for a minute, and lists the published sign-ins
  with the next wipe. If the demo does not answer, /beta links to its
  sign-in page instead. Gate two reads as in place.
- D83: a deep link into a game module's pages carries data-demo-game, and
  applyBrand.mjs fills it only for games in the new mounted demoGames
  list. The Rust features gain links into /rust and /rust/servers/demo.
  UO's stay hidden on a Rust demo.

The public-demo not-built entry is removed, because its resolvedBy was met.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-28 15:32:34 -05:00

463 lines
20 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 ONE GROUP CORE DOES NOT SUPPLY, IN CORE'S WORDS
* ---------------------------------------------------------------------------------------
* "What a game module adds" is the only group core does not supply — it comes from
* whichever module is installed. Until phase 17 it was "Game intelligence" and listed the
* Ultima Online module's features in that game's words, on the homepage and `/features/`.
* Two modules exist now, and D63 keeps a game's vocabulary on its own pages, so the group
* says what BOTH modules add, in core's words, and each game's real list lives in
* `src/data/games/` and on its page under `/modules/` (D75).
*
* The capability check moved with the lists: `assertGameCoverage()` in `games/index.mjs`
* holds each game's features to the capabilities its module declares, in both directions.
*
* ---------------------------------------------------------------------------------------
* `needsModule` — THE THIRD STATE, WHICH PHASE 3 DID NOT HAVE
* ---------------------------------------------------------------------------------------
* 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 each module calls them what its game calls them and builds the pages
* on the same primitive.
*
* So `needsModule` marks an item that is core machinery a module has to populate. On a bare
* core it is present, correct and permanently empty. `/` renders the requalified group
* summary; `/features/` renders the marker and says why (D24).
*
* ---------------------------------------------------------------------------------------
* `demoPath` — DEEP LINKS THAT ONLY EXIST WHEN A DEMO DOES
* ---------------------------------------------------------------------------------------
* §15/D12 kept 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. A module's public pages are namespaced under its own id, so a route into the
* Ultima Online module is `/uo/…`. A link into a module's pages names that game
* (`demoGame` here, the `game` prop of `GameFeatures` for the lists in `games/`), and it
* shows only when the mounted `demoGames` lists it (D83): the demo that exists runs Rust,
* so the Teams item's `/uo/guilds` stays hidden there and Rust's clans link stands in.
*/
import { games } from './games/index.mjs';
/**
* The shape of a capability, written out because TypeScript otherwise infers it per group
* from whichever fields that group's items happen to use — and then `/features/` cannot
* 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 a game's own items (`games/`) only.
* @property {boolean} [needsModule] Core machinery a module has to populate (D24).
* @property {string} [demoPath] A stable public route, deep-linked into a demo (D25).
* @property {string} [demoGame] The game module whose pages `demoPath` is in, if any (D83).
*
* @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',
demoGame: 'uo',
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 group the game already has becomes a Team on the site — and why ' +
'each game keeps its own name for one without core learning a new word.',
},
{
label: 'Team forums',
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.',
},
],
};
/**
* What a game module adds — module-supplied, and written in core's words (D63, D75). Each
* item is something BOTH modules supply, each in its own game's way; the game's own list,
* in its own words, is on its page under `/modules/`, which `/features/` and the homepage
* link to. No `caps` here: they belong to a game's own items in `src/data/games/`, where
* `assertGameCoverage()` checks them.
*
* @type {CapabilityGroup}
*/
const gameModule = {
id: 'game-module',
title: 'What a game module adds',
moduleSupplied: true,
summary:
'Supplied by the installed game module, not by the core site. Two modules exist, for ' +
'Ultima Online and for Rust, and each adds its own set from a live game server — these ' +
'are the parts they share.',
items: [
{
label: 'Live server status',
detail:
'Whether each game server is up, who is on it, and how long ago the site last heard ' +
'from it. When the game is down the site is the thing that says so — it does not go ' +
'down with it.',
},
{
label: 'A live feed of the world',
detail:
'What is happening in the game, as it happens. Every kind of event passes an ' +
'allowlist the module holds before it can reach a public page; staff read a second ' +
'stream carrying the rest.',
},
{
label: 'Leaderboards',
detail:
'The numbers the game already keeps, published without anyone exporting a ' +
'spreadsheet on a Sunday — and kept by the site after the game has moved on.',
},
{
label: 'Maps of the world',
detail:
'Where things are, drawn from the server rather than from someone else’s idea of the ' +
'game: one module maps what spawns where, the other shows what is moving now.',
},
{
label: 'Teams, from the game',
detail:
'The groups players already form in the game arrive as Teams on the site, with the ' +
'forum, notifications and voice channel core attaches to one.',
},
{
label: 'Players’ own game accounts',
detail:
'A player links their game identity to their site account and sees their own side ' +
'of the game on it. Who may see another player at all is the operator’s choice.',
},
{
label: 'Game steps for scheduled events',
detail:
'What an event may do inside the world comes from the module: core runs the ' +
'calendar, the budget and the undo, and the module brings the steps.',
},
],
};
/** @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 Discord server you already have. Commands answer from your site, ' +
'notifications bridge into channels, and a Team can be granted a voice channel ' +
'that maintains its own membership.',
},
{
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,
gameModule,
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`
);
}
/*
* The capability check that lived here — "Game intelligence" against the module's declared
* capabilities — moved to `assertGameCoverage()` in `games/index.mjs` in phase 17, with the
* lists it checks.
*/
/**
* 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 `assertGameCoverage` is called from the
* homepage: the build error should name the page that would have shipped wrong. It also
* reads each game's own list, which its page under `/modules/` renders with the detail too.
*/
export function assertDetailCoverage() {
const missing = [];
for (const group of capabilityGroups) {
for (const item of group.items) {
if (!item.detail?.trim()) missing.push(`${group.title} → ${item.label}`);
}
}
for (const game of games) {
for (const item of game.features) {
if (!item.detail?.trim()) missing.push(`${game.name} → ${item.label}`);
}
}
if (!missing.length) return;
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`
);
}