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