` is the page's own name, not the product's, and `Base` appends the site name to
+ * the document title — so a page sets a short `title` and gets "Features — Runic Gateway"
+ * in the tab and "Features" on the page.
+ */
+interface Props {
+ /** Small uppercase line above the title. What kind of page this is. */
+ eyebrow: string;
+ title: string;
+}
+
+const { eyebrow, title } = Astro.props;
+---
+
+
+ {eyebrow}
+ {title}
+
+
+
+
+
+
diff --git a/src/components/architecture/Allowlist.astro b/src/components/architecture/Allowlist.astro
new file mode 100644
index 0000000..ee86a58
--- /dev/null
+++ b/src/components/architecture/Allowlist.astro
@@ -0,0 +1,142 @@
+---
+/**
+ * "What reaches the public" — the second of `/architecture/`'s three diagrams (D21).
+ *
+ * The homepage states the split in one sentence inside the data-path walk ("a public one
+ * carrying an allowlist of safe events, and a staff-only one carrying the rest… that split
+ * is a security boundary, not a preference"). This is the page where that sentence has to
+ * become a picture, because it is the single design decision a technical evaluator is most
+ * entitled to be suspicious of: a live feed of a game world contains things that must never
+ * be published, and "we filter it" is a claim, not a mechanism.
+ *
+ * So the diagram draws the shape of the mechanism — one stream in, one decision, two streams
+ * out — and the notes say where the decision lives and what happens when it is wrong in
+ * either direction. What it deliberately does NOT do is enumerate event kinds: that is the
+ * catalog's job in the docs, it changes with the protocol, and a marketing page holding a
+ * copy of it would be a copy that goes stale (§1).
+ *
+ * The rings sit behind the filter rather than behind the whole picture, on the phase-3
+ * principle that they mark the one place the argument actually happens.
+ */
+---
+
+
+
+
What reaches the public
+
One feed in, two feeds out
+
+ A live game world emits things that are fine on a front page and things that are not:
+ who logged in from which address, what the cheat detector flagged, what a staff member
+ did to whom. Both arrive on the same connection, so something has to divide them.
+
+
+
+
+
+
+
+
+ It is an allowlist, not a blocklist
+
+ The public stream carries the kinds of event that are named as safe; everything else
+ goes to the staff stream by default. That ordering is the whole point. A blocklist
+ fails open — the day the game emits something new, it is already published — and an
+ allowlist fails closed, so the worst case is a page that is missing something rather
+ than a page that has published an address.
+
+
+
+
+ The decision lives on your server
+
+ Not in the sidecar and not in the game. The bridge is a deliberately dumb forwarder:
+ it moves what the game emits and makes no judgements about audience. Everything
+ about who may see what is decided by the site you run, in one place, where you can
+ read it — and where changing it does not mean redeploying anything on the game host.
+
+
+
+
+ More than two audiences, in practice
+
+ Two streams is the transport. Above it sits a configurable audience model — logged
+ out, signed in, linked to a game account, staff — that decides how much of a given
+ surface each of those sees. The public stream is the floor of that, and it is the
+ one that is a boundary rather than a setting.
+
+
+
+
+ When the game is down
+
+ Nothing arrives, and the site carries on. Live surfaces say the server is offline
+ and everything that does not depend on it — the wiki, the news, accounts, the forums
+ — is unaffected. A site that goes down with the game it reports on is not much of a
+ status page.
+
+
+
+
+
+
+
diff --git a/src/components/architecture/ModuleSeam.astro b/src/components/architecture/ModuleSeam.astro
new file mode 100644
index 0000000..f8f90b7
--- /dev/null
+++ b/src/components/architecture/ModuleSeam.astro
@@ -0,0 +1,166 @@
+---
+import platform from '../../data/platform.json';
+
+/**
+ * "Where the game stops and the platform starts" — the third of `/architecture/`'s diagrams
+ * (D21).
+ *
+ * The other two draw runtime shapes. This one draws a code boundary, and it is here because
+ * it is the claim the whole project rests on: that a community platform can be built once
+ * and pointed at any game. An evaluator has every reason to read that as marketing, so the
+ * page draws the seam and then says plainly what does and does not prove it — one module
+ * exists, the second is a paper exercise, and the exit criterion for calling the contract
+ * proven is written down (§2, and the entries `/modules/` renders from `notBuilt.mjs`).
+ *
+ * The Module API version is read from `platform.json` like every other number on this site
+ * (§12). It is the one place a version genuinely belongs in this diagram: the seam is
+ * literally a version check, and a module whose declared range does not match refuses to
+ * load rather than half-loading.
+ */
+---
+
+
+
+
Where the game stops
+
A seam, with a version on it
+
+ The core site does not know what a shard is, what a guild is, or that Ultima Online
+ exists. Everything that does lives in an installable module on the other side of a
+ declared interface — which is what makes "put your game on it" a shape rather than a
+ slogan.
+
+
+
+
+
+
+
+
+ The module brings its own everything
+
+ Not just screens: its routes, its database tables, its navigation rows, its slice of
+ the OpenAPI spec and its own prebuilt client bundle. Installing it is a paste in the
+ admin panel or a line in your environment — never a build step, because production
+ runs an image you pulled, and an operator who has to compile something has been
+ handed a maintenance job rather than a feature.
+
+
+
+
+ Failure is contained by design
+
+ A module that will not load is marked as failed and the site starts without it.
+ Disabling one is a kill switch, not a visibility flag — its routes stop answering
+ and its live connections close. Uninstalling keeps the data, and destroying the data
+ is a separate, deliberate choice made in its own dialog.
+
+
+
+
+ Teams is the shape of the contract
+
+ Core owns the Teams primitive — the roster, the forum, the notifications, the voice
+ channel — and does not own the word. A Team cannot be created in core at
+ all; it arrives from the module, which is why the UO module calls them guilds and
+ builds those pages itself. That is the pattern the whole interface is built on: core
+ supplies the machinery, the module supplies the meaning.
+
+
+
+
+ What this does not yet prove
+
+ One module exists and it is Ultima Online. A second, for a different game, is a
+ written dry-run that was deliberately never implemented — it exists to test whether
+ the contract generalises on paper. Until somebody builds the second one, the seam is
+ a well-argued design rather than a demonstrated one, and this site says so wherever
+ it comes up.
+
+
+
+
+
+
+
diff --git a/src/components/architecture/TwoHosts.astro b/src/components/architecture/TwoHosts.astro
new file mode 100644
index 0000000..06101a3
--- /dev/null
+++ b/src/components/architecture/TwoHosts.astro
@@ -0,0 +1,122 @@
+---
+/**
+ * "What you actually deploy" — the first of `/architecture/`'s three diagrams (D21).
+ *
+ * This one exists because of a specific, repeated misunderstanding that §10 names and the
+ * homepage's CTA already spends two sentences on: a Runic Gateway install is two
+ * independent installs, on two machines, and neither installs the other. The homepage says
+ * it; this page draws it, because an evaluator deciding whether to run the software is
+ * doing capacity planning, and "how many machines is this" is the first question they have.
+ *
+ * Drawn generically for the same reason the homepage's diagram is (D17) — "your game host",
+ * not "your ServUO box" — with the prose beside it naming the real components. The boundary
+ * is the one drawn argument: everything above it is reachable because you published it, and
+ * everything below it is not reachable at all.
+ *
+ * The vocabulary and the layout are `src/styles/diagram.css`; only the geometry is here.
+ */
+---
+
+
+
+
What you deploy
+
Two hosts, two installs
+
+ Almost everyone gets this wrong once. The website and the game-side bridge are separate
+ deployments on separate machines, and neither one installs the other — so a "Runic
+ Gateway install" is really two, done in that order.
+
+
+
+
+
+
+
+
+ The web host
+
+ A Docker Compose deployment: the site, its database, and whichever game module you
+ installed. Images are pulled rather than built, so nothing compiles here and an
+ upgrade is a pull and a restart. This is the only machine anybody points a browser
+ at, and the only one that needs a certificate.
+
+
+
+
+ The game host
+
+ The machine your game server is already on. One installer binary puts the plugin
+ into the server's tree, installs the sidecar beside it and registers the service —
+ then prints four values. It never contacts your website; you paste those four
+ values into the admin panel yourself, and that is the moment the two halves meet.
+
+
+
+
+ Why they share a host
+
+ The game talks to the sidecar over loopback, on the same machine, and dials
+ out to do it. That is what lets the game server open no port at all — and it
+ is also why there is no macOS installer build. The pair has to sit together, and no
+ game server anybody runs is on one.
+
+
+
+
+ What crosses between them
+
+ One authenticated connection, in both directions: a WebSocket carrying the live feed
+ up, and REST calls going down for point-in-time questions. Nothing else on either
+ machine talks to the other, and the sidecar answers your site and nobody else.
+
+
+
+
+
diff --git a/src/components/home/DataPath.astro b/src/components/home/DataPath.astro
index 2583546..9461072 100644
--- a/src/components/home/DataPath.astro
+++ b/src/components/home/DataPath.astro
@@ -159,12 +159,6 @@ import platform from '../../data/platform.json';
top: calc(var(--header-h) + 1.5rem);
}
- .flow {
- display: block;
- width: 100%;
- max-width: 380px;
- }
-
.datapath__caption {
margin: 1rem 0 0;
max-width: 380px;
@@ -172,75 +166,14 @@ import platform from '../../data/platform.json';
font-size: 0.85rem;
}
- /* ---- The drawing ------------------------------------------------------
- SVG presentation attributes cannot take a var(), so every colour here is
- set as a CSS property on a class instead. That is also what keeps
- checkTokens.mjs satisfied: no literal reaches the markup. */
- .node {
- fill: var(--panel-b);
- stroke: var(--line);
- stroke-width: 1;
- }
+ /* The SVG vocabulary this diagram draws with -- .node, .spine, .arrow,
+ .boundary, .rings -- now lives in src/styles/diagram.css, shared with
+ /architecture/'s three. It was duplicated in four files the moment the
+ second diagram existed, and the rules it holds are decisions about what a
+ diagram on this site looks like rather than about this one.
- .node--self {
- fill: var(--panel-a);
- stroke: var(--gold-deep);
- }
-
- .node-title {
- fill: var(--head);
- font-family: var(--sans);
- font-size: 17px;
- font-weight: 600;
- }
-
- .node-sub {
- fill: var(--dim);
- font-family: var(--sans);
- font-size: 12.5px;
- }
-
- .spine {
- fill: none;
- stroke: var(--gold-deep);
- stroke-width: 2;
- }
-
- .spine--live {
- stroke: var(--portal);
- filter: drop-shadow(0 0 6px var(--portal-deep));
- }
-
- .arrow {
- fill: var(--gold-deep);
- stroke: none;
- }
-
- .arrow--live {
- fill: var(--portal);
- }
-
- .boundary {
- fill: none;
- stroke: var(--line);
- stroke-width: 1;
- stroke-dasharray: 4 5;
- }
-
- .boundary-label {
- fill: var(--dim);
- font-family: var(--sans);
- font-size: 11px;
- letter-spacing: 0.09em;
- text-transform: uppercase;
- }
-
- .rings {
- fill: none;
- stroke: var(--gold-deep);
- stroke-width: 1;
- opacity: 0.16;
- }
+ The layout below stays here: the right-hand column is a numbered walk,
+ not the notes column .diagram__body assumes. */
/* ---- The list ---------------------------------------------------------- */
.datapath__steps {
diff --git a/src/data/capabilities.mjs b/src/data/capabilities.mjs
index 041b354..ba1d000 100644
--- a/src/data/capabilities.mjs
+++ b/src/data/capabilities.mjs
@@ -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`
+ );
+}
diff --git a/src/data/notBuilt.mjs b/src/data/notBuilt.mjs
new file mode 100644
index 0000000..545b363
--- /dev/null
+++ b/src/data/notBuilt.mjs
@@ -0,0 +1,165 @@
+/**
+ * notBuilt.mjs — the deliberate absences of PLAN.md §2, as data (D22).
+ *
+ * ---------------------------------------------------------------------------------------
+ * WHY THIS IS A LIST AND NOT A PARAGRAPH
+ * ---------------------------------------------------------------------------------------
+ * §2 calls its absent-features list "as load-bearing as the rest", and the homepage already
+ * promises a reader they will find it on both `/features/` and `/integrations/`. Two pages
+ * each writing their own version of "what we did not build" is how the inconvenient half
+ * quietly stops being mentioned on one of them — the same failure `capabilities.mjs` exists
+ * to prevent, pointed the other way.
+ *
+ * So: one list, tagged with the pages that show it. `/modules/` reads it too, because the
+ * three absences a module author most needs to know about are all here.
+ *
+ * ---------------------------------------------------------------------------------------
+ * THE RULE FOR ADDING ONE
+ * ---------------------------------------------------------------------------------------
+ * An entry belongs here when a reasonable reader would assume the thing exists. That is a
+ * higher bar than "we have not built it" — the site is not an inventory of everything
+ * absent from it — and a lower bar than "someone asked for it". Matrix is here because the
+ * original brief for this site listed it as a feature; the installer's missing platforms
+ * are here because every other tool in the world ships a macOS build.
+ *
+ * Each entry says what it is, and then why not. The "why not" is the point: an absence with
+ * a reason reads as a decision, and an absence without one reads as a gap. Where the
+ * reasoning was written down somewhere in the open, the entry links to it on a BRANCH path
+ * — `scripts/checkLinks.mjs` fails a commit permalink, because a permalink is a fact frozen
+ * at a sha while the document keeps moving.
+ *
+ * `resolvedBy` is not decoration. D8 gives the Integration Kit's draft status a defined
+ * removal condition, and stating the exit condition on the others too is what stops this
+ * file becoming a list of permanent apologies.
+ */
+
+const GITEA = 'https://gitea.whitlocktech.com/RunicGateway';
+
+/**
+ * `scope` — which pages render the entry.
+ *
+ * `features` /features/, under the capability groups
+ * `integrations` /integrations/, under the integrations that do exist
+ * `modules` /modules/, where a module author is deciding whether to start
+ *
+ * Typed rather than inferred, for the same reason `capabilities.mjs` is: `link` is present
+ * on four entries out of six, and an inferred union makes `entry.link` unreadable on the
+ * page that renders all of them.
+ *
+ * @typedef {object} Absence
+ * @property {string} id
+ * @property {string[]} scope
+ * @property {string} title
+ * @property {string} body
+ * @property {string} resolvedBy What would make this entry go away. Never optional.
+ * @property {{ href: string, label: string }} [link]
+ *
+ * @type {Absence[]}
+ */
+export const notBuilt = [
+ {
+ id: 'matrix',
+ scope: ['integrations'],
+ title: 'Matrix',
+ body:
+ 'Researched properly and then declined. Matrix has no channel-with-overwrites, no ' +
+ 'role object, no voice channel of its own — voice is an RTC session needing a media ' +
+ 'server the homeserver does not ship — and no way to register a slash command. Of ' +
+ 'the five things a shared chat interface would have to name, an honest Matrix ' +
+ 'implementation could provide two. What came out of that work was a capability ' +
+ 'contract rather than an integration.',
+ resolvedBy:
+ 'Nothing planned. If the protocol grows the missing four, the contract is already ' +
+ 'the shape a second platform would plug into.',
+ link: { href: `${GITEA}/docs/src/branch/main/website/TEAMS.md`, label: 'The research, in full' },
+ },
+ {
+ id: 'multi-module',
+ scope: ['features', 'integrations', 'modules'],
+ title: 'More than one game module at a time',
+ body:
+ 'One active module per deployment. The database columns that would scope data to a ' +
+ 'module exist and are populated, so the door is not nailed shut, but nothing ' +
+ 'exercises them and no interface offers it. A community running two games runs two ' +
+ 'deployments.',
+ resolvedBy:
+ 'Someone needing it. The schema was shaped to keep it possible, which is a different ' +
+ 'thing from planning it.',
+ },
+ {
+ id: 'second-module',
+ scope: ['integrations', 'modules'],
+ title: 'A second game module',
+ body:
+ 'There is exactly one, and it is Ultima Online. A paper dry-run for a Rust module ' +
+ 'exists and is deliberately unimplemented — it was written to test whether the ' +
+ 'module contract generalises, not to ship. Until a second one exists, "any game" is ' +
+ 'an argument about a shape rather than a demonstration.',
+ resolvedBy: 'The first module built for a game that is not Ultima Online.',
+ link: { href: `${GITEA}/docs/src/branch/main/modules/rust-dryrun.md`, label: 'The dry-run' },
+ },
+ {
+ id: 'integration-kit-draft',
+ scope: ['integrations', 'modules'],
+ title: 'A finished Integration Kit',
+ body:
+ 'The kit that teaches you to put a different game on this platform describes itself ' +
+ 'as a draft, and it is right to. It has four chapters, a working template and a CI ' +
+ 'job that builds that template against a pinned core — but nobody outside this ' +
+ 'project has yet followed it to a working module, which is the only test of a set of ' +
+ 'instructions that counts.',
+ resolvedBy:
+ 'Someone outside this project building a working module for a new game by following ' +
+ "it alone. That is the kit's own stated condition, not one invented here.",
+ link: { href: `${GITEA}/Integration-kit/src/branch/main/README.md`, label: 'The kit' },
+ },
+ {
+ id: 'installer-platforms',
+ scope: ['features'],
+ title: 'A macOS or Windows-on-ARM installer',
+ body:
+ 'Linux and Windows, on x86-64, plus Linux on arm64. The missing builds are missing ' +
+ 'on purpose: the installer runs on the machine the game server lives on, because the ' +
+ 'game and the bridge have to share a host, and no game server anybody runs is on ' +
+ 'either of those platforms.',
+ resolvedBy: 'A game server that runs there.',
+ link: { href: `${GITEA}/docs/src/branch/main/installer/INSTALL.md`, label: 'The operator guide' },
+ },
+ {
+ id: 'public-demo',
+ scope: ['features'],
+ title: 'A public demo you can click through',
+ body:
+ 'Planned and out of scope today: a virtual machine running the whole stack including ' +
+ 'a game server, with settings locked down and an hourly reset. Until it exists this ' +
+ 'site does not link to one, and there is no screenshot here of something that is not ' +
+ 'running somewhere.',
+ resolvedBy:
+ 'The machine being stood up. The site is already built to gain it by way of one line ' +
+ 'in a configuration file, rather than a rebuild.',
+ },
+];
+
+/** The entries a given page renders, in file order. */
+export function notBuiltFor(scope) {
+ return notBuilt.filter((entry) => entry.scope.includes(scope));
+}
+
+/**
+ * Fails the build when a scope renders nothing.
+ *
+ * The homepage tells a reader in as many words that the absences are listed "on features
+ * and integrations". A tag typo, or an entry removed without checking who was showing it,
+ * turns that sentence into a promise the site does not keep — and an empty section is the
+ * one defect that looks deliberate, because a page with nothing under a heading reads as a
+ * page with nothing to admit.
+ */
+export function assertScopeNonEmpty(scope) {
+ if (notBuiltFor(scope).length) return;
+
+ throw new Error(
+ `src/data/notBuilt.mjs has no entry tagged "${scope}", but a page is rendering that scope.\n` +
+ `\nThe homepage promises this list appears on /features/ and /integrations/ (§2, D22).\n` +
+ `Tag an entry with "${scope}", or take the section off the page that asks for it.\n`
+ );
+}
diff --git a/src/pages/architecture.astro b/src/pages/architecture.astro
new file mode 100644
index 0000000..086f4d6
--- /dev/null
+++ b/src/pages/architecture.astro
@@ -0,0 +1,172 @@
+---
+import Base from '../layouts/Base.astro';
+import PageHeader from '../components/PageHeader.astro';
+
+import TwoHosts from '../components/architecture/TwoHosts.astro';
+import Allowlist from '../components/architecture/Allowlist.astro';
+import ModuleSeam from '../components/architecture/ModuleSeam.astro';
+
+import platform from '../data/platform.json';
+import { brand } from '../lib/brand.mjs';
+
+/**
+ * `/architecture/` — PLAN.md §13 phase 4, built to D21.
+ *
+ * ---------------------------------------------------------------------------------------
+ * WHAT THIS PAGE IS FOR, AND WHAT IT DELIBERATELY IS NOT
+ * ---------------------------------------------------------------------------------------
+ * §10 gives it one audience: "a technical evaluator deciding whether to run it". That is a
+ * narrower job than "explain the system", and the narrowness is what keeps this page from
+ * becoming a worse copy of the Architecture section in the documentation, which phases 7
+ * and 8 write.
+ *
+ * So the page answers four questions an evaluator actually has, in the order they have
+ * them — what am I deploying, what leaves my server, what is core and what is a module,
+ * and what happens when a part of it dies — and it answers them with drawings and reasons.
+ * It carries no endpoint tables, no configuration keys, no schema and no event catalog.
+ * Those exist, they are canonical elsewhere, and a second copy here would be a copy that
+ * goes stale (§1). Every one of them is a link out.
+ *
+ * The three diagrams are §11's motif doing actual work rather than decoration: each one
+ * draws a boundary, and the boundary is the argument in all three cases. The vocabulary
+ * they share lives in `src/styles/diagram.css`.
+ *
+ * ---------------------------------------------------------------------------------------
+ * LINKS OUT GO TO `/docs/`, NOT TO A GUESSED SLUG
+ * ---------------------------------------------------------------------------------------
+ * The same convention phase 3 set for the homepage: phases 7 and 8 own the documentation
+ * slugs, so linking `/docs/architecture/the-bridge/` today would put a URL in this file
+ * that nothing checks and a later phase would have to remember to fix. Links into the
+ * repositories are different — those are real paths that exist now, and `checkLinks.mjs`
+ * holds them to a branch path rather than a commit permalink.
+ */
+const title = 'Architecture';
+const description =
+ 'How Runic Gateway is put together: what you deploy, what crosses the network, and where ' +
+ 'the game-specific half stops.';
+
+const docs = `${platform.gitea.base}/${platform.gitea.org}/docs/src/branch/main`;
+---
+
+
+
+
+ Three boundaries decide almost everything about how this software behaves: the one
+ between your two machines, the one between what the public sees and what staff see, and
+ the one between the platform and the game. Each is drawn below, with the reasoning
+ rather than the reference.
+
+
+ Nothing here is a specification. Where a real one exists it is linked — the protocol,
+ the module contract and the operator guide are all documents in the open, and they are
+ the authority when this page and one of them disagree.
+
+
+
+
+
+
+
+
+
+
Going deeper
+
The documents this page is a summary of
+
+ Everything above is an argument about shapes. These are the things that specify them,
+ and they are what a module author, an integrator or an operator should be reading.
+
+
+
+ -
+
+ The bridge protocol
+
+ What the game and the sidecar say to each other, and what the sidecar publishes.
+ Protocol {platform.protocol} today, and versioned so a mismatched pair is refused
+ rather than misread.
+
+ -
+
+ The module contract
+
+ The normative interface between core and a module — currently
+ {platform.moduleApi}. This is the document that decides whether your module
+ loads.
+
+ -
+
+ The operator guide
+
+ Setting the game side up end to end, including the failure modes and what each
+ step should look like when it worked.
+
+ -
+ The documentation on this site
+ The same ground as a guided path rather than a specification, starting from an
+ empty server.
+
+
+
+
+
+
+
+
+
diff --git a/src/pages/community.astro b/src/pages/community.astro
new file mode 100644
index 0000000..1a92edd
--- /dev/null
+++ b/src/pages/community.astro
@@ -0,0 +1,288 @@
+---
+import Base from '../layouts/Base.astro';
+import PageHeader from '../components/PageHeader.astro';
+
+import platform from '../data/platform.json';
+import { brand } from '../lib/brand.mjs';
+
+/**
+ * `/community/` — PLAN.md §10 and §14 N3, built in phase 4.
+ *
+ * ---------------------------------------------------------------------------------------
+ * WHY IT IS IN THIS PHASE AT ALL
+ * ---------------------------------------------------------------------------------------
+ * §13's phase table never assigned it one. §10 specifies the page and §14 N3 specifies its
+ * contents, and the header nav and footer have both linked it since phase 1 — so it was a
+ * page the site pointed at and no phase built. The org lead folded it into phase 4 on
+ * 2026-08-20 rather than leaving it to be discovered by the link checker (D23). It is a
+ * marketing page with no new machinery, so this is where it fits.
+ *
+ * ---------------------------------------------------------------------------------------
+ * THE HONEST SPLIT, WHICH IS THE WHOLE POINT OF THE PAGE
+ * ---------------------------------------------------------------------------------------
+ * §14 N3 is explicit: this page describes a split rather than a single channel, because the
+ * obvious sentence — "found a bug? open an issue" — is currently false. Gitea registration
+ * is disabled on this instance, so the code is publicly readable and nobody outside the org
+ * can file anything against it. Discord is therefore the front door in fact, not just in
+ * preference (D10), and saying so is cheaper for a reader than letting them find the
+ * sign-up page and its refusal.
+ *
+ * N3 also records that this page is written the same way whether or not registration is
+ * later reopened — only one sentence changes. That sentence is marked below, so whoever
+ * changes the Gitea configuration can find it without rereading the page.
+ *
+ * The security address comes from `brand.json` and appears nowhere in this file. D13
+ * publishes a personal address on the understanding that moving to a role address later is
+ * an edit to a mounted file, and `checkFacts.mjs` fails the build if an address is typed
+ * into any source file — including this one, which is the file most likely to want to.
+ */
+const title = 'Community';
+const description =
+ 'Where to ask, where the code is, and how to report a security problem.';
+
+const gitea = `${platform.gitea.base}/${platform.gitea.org}`;
+---
+
+
+
+
+ This is a small project run by people with day jobs. There is no support desk and no
+ ticket queue, which is worth knowing before you choose where to put a question — one of
+ these channels answers in minutes and one of them may not answer at all.
+
+
+
+
+
+ -
+
+
Discord
+ The front door
+
+
+ Questions, bug reports, help getting an install working, and where the Android beta
+ is announced. No account with us to make, nothing to be approved for, and the
+ fastest way to reach somebody who has run this software.
+
+
+ Use it for
+ Anything you would otherwise open an issue for, and everything you would not.
+
+
+ Join the Discord
+
+
+
+ -
+
+
The code
+ Read freely
+
+
+ Every repository is public and readable without signing in to anything — the
+ website, the bridge, the game plugin, the installer, the module, the app and all of
+ the documentation. Clone it, read it, run it.
+
+
+ One caveat
+ {/*
+ THE SENTENCE §14 N3 SAYS WILL CHANGE. If Gitea registration is reopened —
+ manual confirm, Turnstile, no repository creation by default — this becomes
+ "issues and pull requests are open to anyone with an account", and nothing else
+ on the page moves.
+ */}
+ Registration on our Gitea is closed at the moment, so filing an issue needs an
+ account we would have to create for you. Ask on Discord and it will reach the same
+ place.
+
+ Browse the source
+
+
+ -
+
+
Security
+ Private
+
+
+ If you have found something that should not be discussed in a public channel, email
+ it. You will get a human, not a form, and there is no bounty programme to game —
+ just an acknowledgement and a fix.
+
+
+ Use it for
+ Anything that would let somebody reach a deployment, an account or a game server
+ they should not.
+
+ {brand.contactEmail}
+
+
+
+
+
+
+
Contributing
+
What is useful, in order
+
+
+ -
+
Run it and say what broke
+
+ The install path is the priority of this whole project, and the most valuable
+ thing anyone outside it can do is walk it on a machine we have never seen and
+ report where it stopped making sense.
+
+
+ -
+
Build a module for another game
+
+ There is one module and it is Ultima Online, so the claim that this platform is
+ game-agnostic is currently an argument rather than a demonstration. The
+ Integration Kit exists to be followed by somebody outside
+ this project — and it stays marked draft until it has been.
+
+
+ -
+
Fix the documentation
+
+ Documentation is versioned alongside the code it describes and a change is not
+ finished until the docs match it. If something you read was wrong, that is a bug
+ of the same kind as any other.
+
+
+
+
+
+ All of it is free software under the GPL-3.0-or-later, and contributions carry one
+ house rule worth knowing before you start: work done with AI assistance has to say so
+ — a box on the pull request and a trailer on the commit. Undisclosed AI-generated
+ contributions get closed. Every repository's CONTRIBUTING.md has the
+ details.
+
+
+
+
+
+
diff --git a/src/pages/features.astro b/src/pages/features.astro
new file mode 100644
index 0000000..09405ee
--- /dev/null
+++ b/src/pages/features.astro
@@ -0,0 +1,223 @@
+---
+import Base from '../layouts/Base.astro';
+import PageHeader from '../components/PageHeader.astro';
+import NotBuilt from '../components/NotBuilt.astro';
+
+import platform from '../data/platform.json';
+import {
+ capabilityGroups,
+ assertCapabilityCoverage,
+ assertDetailCoverage,
+} from '../data/capabilities.mjs';
+
+/**
+ * `/features/` — PLAN.md §13 phase 4, built to D20.
+ *
+ * ---------------------------------------------------------------------------------------
+ * THE SAME LIST THE HOMEPAGE HAS, WITH THE ARGUMENT ATTACHED
+ * ---------------------------------------------------------------------------------------
+ * D18 put all five groups on the homepage named only, and left the per-capability argument
+ * here. This page is therefore not a second list: it is the same `capabilities.mjs` data
+ * rendered with the `detail` line the homepage drops. That is the whole of D20, and it is
+ * what makes "the site advertises something that was removed" a build failure rather than
+ * a thing somebody has to notice.
+ *
+ * Both assertions below run at build time and both name this page in their message.
+ * `assertCapabilityCoverage` is the module contract the homepage also runs — repeated here
+ * deliberately, since either page can be built alone and each should fail on its own.
+ * `assertDetailCoverage` is this page's own: a capability with no detail renders as a
+ * heading with nothing under it, and nothing else in the repo would notice.
+ *
+ * ---------------------------------------------------------------------------------------
+ * THREE THINGS THE MARKUP SAYS THAT THE HOMEPAGE DOES NOT
+ * ---------------------------------------------------------------------------------------
+ * 1. WHERE A CAPABILITY COMES FROM. Every group states whether core supplies it or the
+ * installed module does. The homepage carries one chip on one group; here it is a full
+ * sentence on all five, because this is the page a reader arrives at wanting to know
+ * what they get on a deployment with no module at all.
+ *
+ * 2. WHAT NEEDS A MODULE TO FILL IT. Teams and Team forums are core machinery that cannot
+ * originate a Team — see the `needsModule` note in `capabilities.mjs` for what the tree
+ * actually says. That is neither "core" nor "module-supplied", and a page that offered
+ * only those two words would have to lie in one direction or the other (D24).
+ *
+ * 3. WHERE TO SEE IT RUNNING. Capabilities with a stable public route carry a deep link
+ * into the demo, hidden until a `demoUrl` is mounted (D25). The markup contract is
+ * exact and `scripts/checkBrand.mjs` enforces it:
+ *
+ * href="" data-demo-url="" data-demo-path="/uo/market"
+ *
+ * `applyBrand.mjs` recomputes all three attributes at boot. Do not reorder them, do not
+ * insert anything between them, and do not write a path into the `href` — the rewrite
+ * matches bytes, and a stock build hides every one of these links, so a mistake here is
+ * invisible until the day somebody configures a demo.
+ */
+assertCapabilityCoverage(platform.moduleUoCapabilities);
+assertDetailCoverage();
+
+const title = 'Features';
+const description =
+ 'What a Runic Gateway deployment does — core, and what the installed game module adds.';
+---
+
+
+
+
+ Grouped the way the software is actually divided, because that division is the thing
+ most worth understanding before you install it: the core site is game-agnostic and does
+ not know what a shard is, and everything that does arrives as an installable module.
+
+
+ Today there is one module and it covers Ultima Online, so the second group below is
+ what a UO deployment gets. On a deployment with no module, that group is simply absent
+ and the other four are unchanged.
+
+
+
+ {
+ capabilityGroups.map((group) => (
+
+
+
{group.title}
+
+ {group.moduleSupplied ? 'From the installed module' : 'Core'}
+
+
+
+ {group.summary}
+
+
+
+ ))
+ }
+
+
+
+
+
diff --git a/src/pages/integrations.astro b/src/pages/integrations.astro
new file mode 100644
index 0000000..39d6d54
--- /dev/null
+++ b/src/pages/integrations.astro
@@ -0,0 +1,301 @@
+---
+import Base from '../layouts/Base.astro';
+import PageHeader from '../components/PageHeader.astro';
+import NotBuilt from '../components/NotBuilt.astro';
+
+import platform from '../data/platform.json';
+
+/**
+ * `/integrations/` — PLAN.md §13 phase 4.
+ *
+ * §10 gives it Discord, mobile and push, SSO, "with an explicit 'not built' list". The
+ * explicit list is the reason this page is worth writing carefully: an integrations page is
+ * the one a reader scans for the name of the thing they already use, and the honest answer
+ * for several of those names is no. §2 calls the absent-features list as load-bearing as the
+ * rest, and `NotBuilt` at the foot of this page is where that lands.
+ *
+ * ---------------------------------------------------------------------------------------
+ * THE TRADE-OFFS ARE ON THE PAGE
+ * ---------------------------------------------------------------------------------------
+ * Each integration carries a `caveat` — the thing you would find out in week two. Discord
+ * voice channels make Team membership visible on a member's Discord profile, because they
+ * are granted by role; the mobile app has no server of ours to point at; SSO will not create
+ * an account. None of those is a defect and all three change whether someone wants the
+ * feature, so leaving them for the documentation would be the dishonest kind of brevity.
+ * That is D8's "understated honesty" doing something other than adjusting adjectives.
+ *
+ * No version numbers are typed here. The app version and the platform's own numbers come
+ * from `platform.json` (§12), which `checkFacts.mjs` re-reads from each repository's
+ * authority on every build.
+ */
+const title = 'Integrations';
+const description =
+ 'What Runic Gateway connects to — Discord, mobile push, single sign-on — and what it ' +
+ 'deliberately does not.';
+
+const integrations = [
+ {
+ id: 'discord',
+ name: 'Discord',
+ summary:
+ 'A bot for the server your community is already sitting in, doing three separate jobs.',
+ points: [
+ {
+ title: 'Slash commands',
+ body:
+ 'Commands registered with your guild that answer from your site — so the thing ' +
+ 'somebody wants to look up is available where the conversation is happening, ' +
+ 'rather than one tab away.',
+ },
+ {
+ title: 'Notifications into channels',
+ body:
+ 'News and Team activity bridged into the channels you choose, with a per-Team ' +
+ 'override so one group can route its own notifications somewhere else. Delivery ' +
+ 'is best-effort and one-shot: a Discord outage never backs anything up on your ' +
+ 'site.',
+ },
+ {
+ title: 'A voice channel per Team',
+ body:
+ 'A Team can be granted its own voice channel, with membership maintained by the ' +
+ 'bot rather than by whoever is online. The bot creates the category, and the ' +
+ 'panel reports how many roles your guild has left before Discord’s own limit.',
+ },
+ ],
+ caveat:
+ 'Voice access is granted with a Discord role, and roles are visible on a member’s ' +
+ 'profile — so a Team with a voice channel is a Team anyone in your guild can see the ' +
+ 'membership of. That was a deliberate trade for a limit that counts per guild rather ' +
+ 'than per channel, and it is the right one for most communities, but it is not private.',
+ },
+ {
+ id: 'mobile',
+ name: 'Mobile and push',
+ summary:
+ 'A native Android app against the same documented API the website uses, with push ' +
+ 'through a server you run.',
+ points: [
+ {
+ title: 'The same API, not a second one',
+ body:
+ 'The app is a client of the API your deployment already publishes, authenticated ' +
+ 'with short-lived tokens and rotated, revocable refresh tokens. There is no ' +
+ 'mobile-only backend to keep in step.',
+ },
+ {
+ title: 'Push through your own ntfy',
+ body:
+ 'Notifications are delivered by a self-hosted ntfy server rather than a vendor in ' +
+ 'the middle. Each person chooses which streams reach them; push arrives by ' +
+ 'default and can be switched off entirely.',
+ },
+ {
+ title: 'Trusted devices and two-factor',
+ body:
+ 'The app shares the site’s account model, including time-based two-factor ' +
+ 'codes, recovery codes, and devices you can mark as trusted and revoke later.',
+ },
+ ],
+ caveat:
+ 'The app points at no server of ours: the person installing it types the address of ' +
+ 'the deployment they belong to. That is what makes one app work for every community ' +
+ 'running this software, and it means the app is useless until somebody gives them a ' +
+ 'URL — which is a thing worth putting in your welcome message.',
+ },
+ {
+ id: 'sso',
+ name: 'Single sign-on',
+ summary:
+ 'OAuth2 and OIDC, against Google, Discord, or any provider you already run.',
+ points: [
+ {
+ title: 'Any OIDC provider',
+ body:
+ 'Google and Discord are configured by name; anything else that speaks OIDC is ' +
+ 'configured generically. Client secrets are encrypted at rest and never returned ' +
+ 'to any client.',
+ },
+ {
+ title: 'It signs people in, not up',
+ body:
+ 'An external identity has to be linked to an account that already exists on your ' +
+ 'site. Signing in with a provider never creates a user — which means the way ' +
+ 'someone joins your community stays a decision you make, not one Google makes.',
+ },
+ {
+ title: 'It respects the rest of the login rules',
+ body:
+ 'Two-factor, trusted devices and bans all still apply. An identity provider ' +
+ 'proves who someone is; it does not decide whether they may come in.',
+ },
+ ],
+ caveat:
+ 'Link-only is a policy, not a limitation to be worked around. If you were expecting ' +
+ 'to open registration by turning on Google sign-in, this will not do that, and it is ' +
+ 'not configurable.',
+ },
+];
+---
+
+
+
+
+ Three integrations exist and are in use. Each one below says what it does, and then the
+ thing you would otherwise discover in week two — because an integrations page that only
+ lists the good half is how somebody ends up rebuilding their community around an
+ assumption.
+
+
+ Everything here is configured on your own deployment, against services you already run
+ or already have an account with. Nothing routes through us; there is no us to route
+ through.
+
+
+
+ {
+ integrations.map((integration) => (
+
+ {integration.name}
+ {integration.summary}
+
+
+ {integration.points.map((point) => (
+ -
+
{point.title}
+ {point.body}
+
+ ))}
+
+
+
+
Worth knowing first
+
{integration.caveat}
+
+
+ ))
+ }
+
+
+ Email, deliberately quiet
+
+ Your deployment can send email — Team notifications and newsletters, through an account
+ you connect — and it only ever sends to someone who asked for it. Email is the one
+ channel that is opt-in rather than opt-out, because an unwanted push notification is an
+ annoyance and an unwanted email is a complaint to somebody’s provider.
+
+
+ This website is a separate matter: runicgateway.com sends no email at all, has
+ no mailbox behind it and no account to make. The address in the footer is a human being.
+
+
+
+
+
+
+
+
If you need another one
+
The API is the integration point
+
+ The whole backend is described by an OpenAPI 3.0 specification that ships with the
+ server, and an installed module merges its own routes into it — so whatever you build
+ against is documented by the thing that is actually running, at
+ {' '}Module API {platform.moduleApi}. The bridge to a game server is a documented wire
+ protocol on the same principle, currently protocol {platform.protocol}.
+
+
+
+
+
+
+
diff --git a/src/pages/modules.astro b/src/pages/modules.astro
new file mode 100644
index 0000000..fe2cc47
--- /dev/null
+++ b/src/pages/modules.astro
@@ -0,0 +1,404 @@
+---
+import Base from '../layouts/Base.astro';
+import PageHeader from '../components/PageHeader.astro';
+import NotBuilt from '../components/NotBuilt.astro';
+
+import platform from '../data/platform.json';
+import { capabilityGroup } from '../data/capabilities.mjs';
+
+/**
+ * `/modules/` — PLAN.md §13 phase 4.
+ *
+ * §10 gives this page four jobs: what a module is, `module-uo` as the worked example,
+ * writing your own, and the Integration Kit with its draft badge (D8). They are in that
+ * order because they are increasing commitment — a reader deciding whether to install one,
+ * a reader wondering what they get, a reader considering building one.
+ *
+ * ---------------------------------------------------------------------------------------
+ * THE WORKED EXAMPLE READS ITS OWN CAPABILITIES
+ * ---------------------------------------------------------------------------------------
+ * The `module-uo` section lists what the module publishes, and it takes that list from
+ * `capabilities.mjs` rather than retyping it — the same list the homepage names and
+ * `/features/` expands, which is already checked against the module's own manifest through
+ * `platform.json` (§12). A third hand-maintained copy on this page is exactly the failure
+ * that machinery exists to prevent, and this is the page where it would be least visible.
+ *
+ * ---------------------------------------------------------------------------------------
+ * THE DRAFT CHIP IS A DECISION, NOT A DISCLAIMER
+ * ---------------------------------------------------------------------------------------
+ * D8 marks the Integration Kit draft until a second module is successfully built against
+ * it by somebody outside this project, and requires that status to carry its removal
+ * condition. Both are here: the chip, and the sentence that says what takes it down. The
+ * same absence appears in `notBuilt.mjs`, so a reader who scrolls past the chip meets it
+ * again in the list of things that do not exist.
+ */
+const title = 'Modules';
+const description =
+ 'What a module is, what the Ultima Online module publishes, and what it takes to write ' +
+ 'one for another game.';
+
+const gitea = `${platform.gitea.base}/${platform.gitea.org}`;
+const docs = `${gitea}/docs/src/branch/main`;
+
+const gameIntelligence = capabilityGroup('game-intelligence');
+
+/** The three ways a module reaches a running deployment. None of them is a build. */
+const installPaths = [
+ {
+ name: 'From the admin panel',
+ body:
+ 'Paste the URL of a release manifest into Admin → Modules and press restart when it ' +
+ 'asks. The site downloads the artifact, verifies the checksum the manifest declares, ' +
+ 'inspects the whole archive before writing a single file, and unpacks it.',
+ fits: 'The click path, for a host you have no shell on.',
+ },
+ {
+ name: 'From your environment',
+ body:
+ 'Name the module and its version in one environment variable and the container ' +
+ 'resolves it at every start. Already at that version means no network call at all, so ' +
+ 'a restart with the internet down comes up unchanged.',
+ fits: 'A compose-managed host, where the running set should be a line you version-control.',
+ },
+ {
+ name: 'By hand',
+ body:
+ 'Unpack the tarball into the modules directory and restart. The bundle is already ' +
+ 'assembled — the client half is prebuilt and its one runtime dependency ships inside.',
+ fits: 'Development, and any host where the other two do not fit.',
+ },
+];
+---
+
+
+
+
+ A module is the entire game-specific half of a deployment, packaged: its routes, its
+ screens, its database tables, its navigation rows and its slice of the API
+ documentation. The core site holds accounts, Teams, the wiki, posts, moderation and the
+ admin panel, and knows nothing about any game at all.
+
+
+ That division is not an aspiration bolted on afterwards. The Ultima Online support was
+ extracted out of the site into a module, and every URL it had before the move it still
+ has — which is the only version of this claim worth making.
+
+
+
+
+ What you get
+ What installing one actually does
+
+
+ -
+
It brings its own everything
+
+ Server routes, React screens, tables, nav rows and an OpenAPI fragment the site
+ merges into its own spec. Nothing about it is a patch to the core site, so
+ upgrading either half does not involve reconciling the other.
+
+
+ -
+
You never build it
+
+ The client half ships prebuilt and the artifact is verified against a published
+ checksum before anything is written to disk. Production runs an image you pulled;
+ an operator who has to compile something has been handed a maintenance job.
+
+
+ -
+
It cannot take the site down
+
+ A module whose declared interface version does not match is marked failed and the
+ site starts without it — loudly, rather than half-loading. Disabling one closes its
+ connections and stops its routes answering.
+
+
+ -
+
Your data outlives it
+
+ Uninstalling removes the module and keeps its tables, so reinstalling picks up
+ exactly where it was. Destroying the data is a separate, opt-in choice made in its
+ own dialog, and it says what it is about to do.
+
+
+
+
+
+
+ Installing
+ Three ways in, and none of them is a build
+
+ Which one you use is a question about your host, not about the module. All three end
+ the same way: a restart, and the module's screens appear in the navigation.
+
+
+
+ {
+ installPaths.map((path) => (
+ -
+
{path.name}
+ {path.body}
+ {path.fits}
+
+ ))
+ }
+
+
+
+
+
+
The worked example
+
module-uo
+
{platform.releases['Module-uo']}
+
+
+
+ The Ultima Online module, and the reference every module that follows is measured
+ against. It is what turns a general-purpose community site into something that knows
+ what a shard is — and it is the proof that the seam described on
+ the architecture page is real, because the code on the far
+ side of it was moved there rather than designed there.
+
+
+
+
+
What it publishes
+
+ {gameIntelligence.items.map((item) => - {item.label}
)}
+
+
+ The same list features expands, read from one file that is
+ checked against the module's own manifest on every build.
+
+
+
+
+
+ It connects to a real server
+
+ The module talks to the sidecar beside your game server, not to the game. You
+ deploy that side with the installer and paste four values into the admin panel;
+ nothing here requires the game to exist, and with no server configured the site
+ renders normally and shows it offline.
+
+
+
+ It owns its own tables
+
+ Its schema is applied by the site on every boot and its data is its own. The
+ module declares which versions of the core interface it speaks — the site runs
+ {' '}{platform.moduleApi} — and refuses to load against one it does not.
+
+
+
+ It is a separate release
+
+ Versioned, tagged and published on its own cadence, independently of the site.
+ Upgrading one does not mean upgrading the other, as long as the declared interface
+ range still holds.
+
+
+
+
+
+
+
+
+
Writing your own
+
The Integration Kit
+
Draft
+
+
+
+ A four-chapter book on putting a different game on this platform — the module, the
+ sidecar beside your game server, the plugin inside it — plus a template module that
+ continuous integration builds against a pinned version of the core site, so the
+ instructions cannot quietly stop working.
+
+
+
+
Why it says draft
+
+ Because nobody outside this project has yet followed it to a working module, and that
+ is the only test of a set of instructions that counts. The badge comes off when
+ somebody does — that is the stated condition, not a mood, and it is written down so a
+ future reader knows when to take it down.
+
+
+ Everything it teaches is real and in use. What is untested is whether it is
+ sufficient: whether someone with no access to this project's context can get
+ from an empty repository to a running module using it alone.
+
+
+
+
+
+
+
+
+
+
diff --git a/src/styles/diagram.css b/src/styles/diagram.css
new file mode 100644
index 0000000..1de9510
--- /dev/null
+++ b/src/styles/diagram.css
@@ -0,0 +1,194 @@
+/* ============================================================================
+ The diagram vocabulary
+ ============================================================================
+ §11 makes hand-drawn SVG the site's motif, "used where it explains something".
+ Phase 3 drew the first one on the homepage; phase 4 drew three more on
+ `/architecture/`, at which point the same fifteen rules existed in four files.
+
+ Two things live here and nothing else does:
+
+ 1. The SVG vocabulary — what a node, a spine, an arrow and the boundary look
+ like. Shared by name, so a diagram is markup and the drawing is one
+ decision. `DataPath.astro` reads these too; it keeps its own layout,
+ because its right-hand column is a numbered walk rather than notes.
+
+ 2. The `.diagram` layout — figure beside prose on a wide screen, figure
+ above prose on a narrow one.
+
+ Every colour is a class rather than a presentation attribute, and that is not
+ a style preference: `var()` is only substituted in style declarations, so
+ `fill="var(--line)"` on an element parses and draws nothing at all. It is also
+ what keeps `checkTokens.mjs` green, since no literal ever reaches the markup.
+
+ The two rules every diagram here follows, learned in phase 3:
+
+ - An inline SVG cannot reflow. A tall, ~380px-wide viewBox with only node
+ titles inside it is legible on a phone AND useful at 1440px; a wide
+ horizontal diagram is neither.
+ - The picture is `aria-hidden` because the prose beside it says the same
+ thing better. The consequence is a rule: a diagram must never carry a fact
+ the prose does not.
+ -------------------------------------------------------------------------- */
+
+/* ---- Layout ------------------------------------------------------------- */
+
+.diagram__head h2 {
+ margin: 0 0 0.75rem;
+ font-size: clamp(1.6rem, 3.2vw, 2.1rem);
+}
+
+.diagram__head .prose {
+ margin: 0;
+ color: var(--muted);
+}
+
+.diagram__body {
+ display: grid;
+ gap: clamp(1.75rem, 4vw, 3rem);
+ margin-top: 2.5rem;
+ grid-template-columns: minmax(0, 380px) minmax(0, 1fr);
+ align-items: start;
+}
+
+.diagram__figure {
+ position: sticky;
+ top: calc(var(--header-h) + 1.5rem);
+}
+
+.diagram__caption {
+ margin: 1rem 0 0;
+ max-width: 380px;
+ color: var(--dim);
+ font-size: 0.85rem;
+}
+
+.diagram__notes section + section {
+ margin-top: 1.5rem;
+}
+
+.diagram__notes h3 {
+ margin: 0 0 0.4rem;
+ color: var(--gold);
+ font-size: 1.06rem;
+}
+
+.diagram__notes p {
+ margin: 0;
+ max-width: var(--measure);
+ color: var(--muted);
+}
+
+@media (max-width: 900px) {
+ .diagram__body {
+ grid-template-columns: minmax(0, 1fr);
+ }
+
+ /* Sticky is a wide-screen affordance only. Once the figure sits above the
+ prose rather than beside it, pinning it would cover the thing it explains. */
+ .diagram__figure {
+ position: static;
+ justify-self: center;
+ }
+}
+
+/* ---- The drawing -------------------------------------------------------- */
+
+.flow {
+ display: block;
+ width: 100%;
+ max-width: 380px;
+}
+
+/* An outer grouping: a machine, a process boundary, a side of a contract. Sits
+ under the nodes it contains, so it reads as the thing they are inside. */
+.host {
+ fill: var(--panel-flat);
+ stroke: var(--line-soft);
+ stroke-width: 1;
+}
+
+.host-title {
+ fill: var(--head);
+ font-family: var(--sans);
+ font-size: 16px;
+ font-weight: 600;
+}
+
+.host-sub {
+ fill: var(--dim);
+ font-family: var(--sans);
+ font-size: 11.5px;
+}
+
+.node {
+ fill: var(--panel-b);
+ stroke: var(--line);
+ stroke-width: 1;
+}
+
+/* The one node that is the reader's own site. Gold edge, because gold is
+ emphasis everywhere else on the site too. */
+.node--self {
+ fill: var(--panel-a);
+ stroke: var(--gold-deep);
+}
+
+.node-title {
+ fill: var(--head);
+ font-family: var(--sans);
+ font-size: 15px;
+ font-weight: 600;
+}
+
+.node-sub {
+ fill: var(--dim);
+ font-family: var(--sans);
+ font-size: 11.5px;
+}
+
+.spine {
+ fill: none;
+ stroke: var(--gold-deep);
+ stroke-width: 2;
+}
+
+/* Cyan is the live signal everywhere on this site — the same colour the portal
+ in the emblem is, and the same one the homepage draws the event feed in. A
+ spine in this colour means data actually moving, not a relationship. */
+.spine--live {
+ stroke: var(--portal);
+ filter: drop-shadow(0 0 6px var(--portal-deep));
+}
+
+.arrow {
+ fill: var(--gold-deep);
+ stroke: none;
+}
+
+.arrow--live {
+ fill: var(--portal);
+}
+
+.boundary {
+ fill: none;
+ stroke: var(--line);
+ stroke-width: 1;
+ stroke-dasharray: 4 5;
+}
+
+.boundary-label {
+ fill: var(--dim);
+ font-family: var(--sans);
+ font-size: 11px;
+ letter-spacing: 0.09em;
+ text-transform: uppercase;
+}
+
+/* The emblem's concentric rings, used as a ground behind the one place a
+ diagram's argument actually happens. */
+.rings {
+ fill: none;
+ stroke: var(--gold-deep);
+ stroke-width: 1;
+ opacity: 0.16;
+}
diff --git a/src/styles/global.css b/src/styles/global.css
index 3a84cf8..c20e1e8 100644
--- a/src/styles/global.css
+++ b/src/styles/global.css
@@ -8,6 +8,12 @@
@import '@fontsource-variable/cinzel';
@import '@fontsource-variable/inter';
+/* The SVG diagram vocabulary and the figure-beside-prose layout, shared by the
+ homepage's data path and `/architecture/`'s three. Its own file because it is
+ a self-contained language rather than part of the shell — see its header for
+ the two rules every diagram on this site follows. */
+@import './diagram.css';
+
*,
*::before,
*::after {
@@ -437,8 +443,25 @@ svg {
Written here, before phase 3 writes that markup, because the rule and the
rewrite have to agree and they live in different files. */
+/* `!important`, and it is earning its keep rather than papering over something.
+
+ This selector is specificity 0,1,0. So is a class — including the scoped class an
+ Astro component puts on the very same element — and a component's styles are emitted
+ AFTER this file, so any component that gives one of these elements a `display` wins on
+ source order alone. Phase 4 did exactly that: `/features/`'s `.demo-link` set
+ `display: inline-flex` for its arrow, and twelve links to a demo that does not exist
+ appeared on the page, each one pointing at `href=""` — which a browser resolves to the
+ page it is already on.
+
+ Nothing caught it. checkBrand.mjs verifies the ATTRIBUTES, and they were perfect; the
+ defect was three files away in the cascade. It was found by looking at the rendered
+ page, which is not a mechanism.
+
+ So the rule is stated as one: while there is no demo, these elements do not render, and
+ no component style may overrule that by accident. A component that genuinely needs to
+ lay one of these out sets every property except `display`. */
[data-demo-url=''] {
- display: none;
+ display: none !important;
}
/* Phase 3 writes that markup as `class="btn demo-cta"`, so the slot is a button