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