feat(site): phase 17 — per-game module pages, /runicnpc/, and checkTerms over the built site #38

Merged
whitlocktech merged 1 commits from feat/phase-17-marketing into main 2026-10-22 20:26:03 -05:00
32 changed files with 1924 additions and 451 deletions

View File

@@ -115,6 +115,17 @@ jobs:
# fails because some other host is slow is a check people learn to ignore.
run: npm run check:links
- name: Game vocabulary in the built pages
# PLAN.md §16, D79 — phase 17 widened the twelfth check to the whole site. The
# marketing pages are templates fed from src/data/, so the words a reader sees are
# mostly not in any source file: a capability label, a screenshot caption, a value
# from platform.json. This pass reads every built page's visible text, alt, title and
# aria-label, the way check:links reads its hrefs. A game's routes, and elements
# marked data-game (<GameExample>, a game-tagged screenshot), allow that game's words.
#
# Still the twelfth check, not a thirteenth: the same script, the same word list.
run: npm run check:terms:built
- name: Platform facts
# PLAN.md §12 — every version, protocol number and bundle tag is re-read from
# its authority over the Gitea API and must agree with src/data/platform.json.

109
PLAN.md
View File

@@ -237,7 +237,7 @@ Taken by the org lead (Colby Whitlock) on 2026-08-19. Recorded so they are not r
**Decisions after D13 are recorded where they were taken**, in the section describing the phase that
raised them, rather than appended here — a decision is only re-litigated when its reasoning is
somewhere other than the thing it decided. The count of record is **seventy-four**:
somewhere other than the thing it decided. The count of record is **seventy-nine**:
| # | Where | What it settled |
|---|---|---|
@@ -260,6 +260,7 @@ somewhere other than the thing it decided. The count of record is **seventy-four
| D68–D69 | §16, "How phase 14 built it" | A game's real example on a core page is a tagged, labelled block; *Verify the whole stack* stays in Getting started, game-neutral (amending D60) |
| D70–D71 | §16, "How phase 15 built it" | The Rust group is fifteen pages, one per admin screen where it can be; the Rust event kinds are checked against the module's own classification |
| D72–D74 | §16, "How phase 16 built it" | The RunicNPC subgroup is eight pages with a shared profile-field reference; the API check compares signatures and every data shape's field names; the site's API page carries the current API only, with no decision numbers and no version history |
| D75–D79 | §16, "How phase 17 built it" | Core marketing pages describe what a game module adds in core's words and link to each game's page; the homepage keeps its game screenshot, labelled; `/app/` describes the Rust screens, released the same day; `/runicnpc/` is in the header; `checkTerms.mjs` gains a pass over the built pages |
---
@@ -1675,8 +1676,10 @@ a mechanism rather than diligence:
- **`scripts/checkTokens.mjs`** — no colour literal outside the token file (§7).
- **`scripts/checkTerms.mjs`** — added in phase 14 for D63 and D64, the twelfth check. No game's
vocabulary outside that game's section: its docs group, its sidebar group, or a
`<GameExample>` block (D68). Reads the docs and both sidebar trees; phase 17 widens it to all of
`src/`.
`<GameExample>` block (D68). Reads the docs and both sidebar trees before the build; since
phase 17 (D79) a second pass, `--built`, reads every built page's visible text after it, so
the marketing pages and the words their data renders are covered too. A game's own routes, and
an element marked `data-game`, allow that game's words.
- `astro check` plus a production build, in CI on every PR.
---
@@ -2262,6 +2265,106 @@ exception to "every page links out".
The check parses them line by line and was not affected. Fixed at the org lead's ask in `docs`
#371, which removes the two blank lines and changes nothing else.
### How phase 17 built it
Five decisions, taken by the org lead on 2026-10-22 before any page was written, each from a
concrete example. The count of record is now **seventy-nine**.
**D75 — core marketing pages describe what a game module adds in core's words, and link to
the games.** The homepage, `/features/`, `/architecture/` and `/app/` name both games and use
neither game's vocabulary. The module-supplied group became *What a game module adds*: seven
things both modules supply, each in its own way (live server status, a live feed, leaderboards,
maps, Teams from the game, players' own game accounts, game steps for events), with a button
to each game's page. Each game's real list, in its own words, lives in `src/data/games/` and
renders only on `/modules/uo/` and `/modules/rust/`. The alternative was D68 applied to
marketing — game-labelled blocks carrying each game's real list on the core pages. It was
declined as longer, and as the front door carrying two games' jargon at once.
**D76 — the homepage's "What it looks like" keeps its game screenshot, labelled.** This is
D67's choice, made. The capture stays the Ultima Online live-status screen, now printing
"Ultima Online" beside it, because the claim under the data-path diagram is live game state
on a public page and no core screen shows that. Phase 18 adds a labelled Rust capture beside
it. The alternative, a core screen such as the admin dashboard, was declined.
**D77 — `/app/` describes the app's Rust screens.** When the phase began they were on
`Android-app` `edge` only, 26 commits ahead of `main` and in no release. The org lead chose to
describe them and release them the same day, rather than hold the page or say "next release".
`/app/`'s game section is now *The game, live*: one item per game, titled with the game's name
and written in core's words, plus Rust's half of *Your own side of the game*.
**D78 — `/runicnpc/` is in the header.** The top navigation is now five items, so a Rust
server owner who runs no Runic Gateway finds the plugin from any page (D61's reason). It is in
the footer's Product column and on the Rust module page's RunicNPC card too. The alternative
was the footer and the card only.
**D79 — `checkTerms.mjs` reads the built pages.** D64 had the check widen to all of `src/`. A
source scan of the marketing pages would see expressions rather than the words they render —
a capability label, a screenshot caption, a value from `platform.json` — and would take ids
like `shard-status` and slugs like `guilds` for prose. So the check gained a second pass,
`--built`, run after the build the way `checkLinks.mjs` is. It reads every page's visible text
plus `alt`, `title`, `aria-label`, `placeholder`, the `<title>` and the meta description, and
not link targets, scripts, styles or comments. A game's routes (`GAME_ROUTES`: its docs group,
its module page, and `/runicnpc/` for Rust) allow its words, and so does any element marked
`data-game` — `<GameExample>` and, new this phase, a game-tagged `<Screenshot>`. Starlight's
sidebar and the previous/next links are skipped, because the source pass reads the sidebar as
data and each neighbour's title is checked on its own page. The source pass is unchanged and
still runs first, naming a file and a line. Failures from the built pass name a route and a
snippet. It is still the twelfth check: one script, one word list, one allowlist (whose entry
now carries a `route` as well as a `file`). The alternative was a source scan with per-file
exemptions.
**The check was proven before it was trusted.** Ten plants went into the built HTML. Six
failed, each naming where: a word in a core page's text, in an `alt`, in a `title`, a
UO word inside a block marked Rust, a word split across two syntax-highlighting `<span>`s, and a
UO word on a Rust page. Four passed: a word only in an `href`, one in a `<script>`, UO words on
`/modules/uo/`, and a UO block on a core page. On the build as it stood before this phase, the
pass found 49 words on seven marketing pages; all are gone.
**What was built.**
- **`/modules/`** is core now. Its Ultima Online worked example became a card per game, from
`src/data/games/index.mjs`, and what the modules share — a sidecar, their own tables, their
own releases — is said once beneath them.
- **`/modules/uo/`** carries the Ultima Online list that used to be *Game intelligence*, the
three pieces with their versions, and the five screenshots `/features/` showed until now.
- **`/modules/rust/`** carries eight features for players and four for staff, written from the
Rust docs group phase 15 walked, the four pieces (the module, rust-link, `RunicGateway.cs`,
RunicNPC) and the RunicNPC card D61 asked for. It has no screenshots and no slots for them
(D65, D4).
- **`/runicnpc/`** is written for a server owner with no site: what it does, the two ways to run
it, and where to get it. Every number is read — release, API version and floors from
`platform.json`, the counts of calls and hooks from `runicnpc.mjs`, which phase 16's check
holds to `API.md`.
- **The page-side capability check is per game**, closing the item phase 13 moved here.
`assertGameCoverage()` holds each game's list to its module's declared capabilities in both
directions. The homepage, `/features/` and each game page call it, and
`assertDetailCoverage()` now reads the games' lists too.
- **The supported-games strip** (D63) is `src/components/SupportedGames.astro`, on the homepage,
`/features/` and `/modules/`.
- **D67 is in the components.** `screens.mjs` entries may carry `game`; nine are tagged `uo`.
`Screenshot.astro` and `/app/`'s phone grid print the game's name beside each tagged capture
and mark it `data-game`.
**What the re-read found**, fixed here:
- **`/architecture/` said two machines is "the minimum and also the maximum"**, false since Rust
shipped: one sidecar serves one server, so a community with six servers runs six. The caption
now says two is the minimum and a sidecar sits beside each game server, and its game host
section no longer says the installer prints "four values" (that count was one game's).
- **`/integrations/` and the Discord capability called a Discord server a "guild"** — Discord's
API word, but read on this site as a game's. They say *Discord server* now, as phase 14 made
the docs say.
- **`collection.mjs` was re-read against what the Rust module and Steam sign-in keep**, as the
plan required, before anything was claimed. *Account records* now names linked sign-in
identities, Steam's display name and avatar address included (`user_identities`). *Game data
from the connected server* names, for Rust, each player's Steam id and in-game name, their
statistics, who is online, and the event record, which includes chat, player reports and the
IP addresses on sign-in attempts and bans, readable by staff only. Its retention now states
the module's 30 days for that record and the sidecar's 14-day default. The Play Data Safety
answers did not change: the app's Rust screens read from the deployment and send it only a
one-time link code, which the existing "everything you read and post" row already covers. Its
wording lost "shard", so `PLAY_DATA_SAFETY.md` was regenerated.
---
[api]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md

View File

@@ -84,7 +84,7 @@ Push is off until you enable it. When you do, the app mints a random, unguessabl
**Messages → Other user-generated content.** Not collected by us.
Forum posts, Team activity, character and shard information, notification preferences: all of it is a live read or write against the deployment. Apart from the notification snapshot described in the next entry, nothing is cached for offline use and nothing is duplicated anywhere else — the app with no signal is an app with almost no content, which is a limitation and also an accurate description of where the data lives.
Forum posts, Team activity, what the game module shows you, notification preferences: all of it is a live read or write against the deployment. Apart from the notification snapshot described in the next entry, nothing is cached for offline use and nothing is duplicated anywhere else — the app with no signal is an app with almost no content, which is a limitation and also an accurate description of where the data lives.
- **Why that answer:** Content is written to the community’s own installation. We have no copy, no access and no way to obtain one.
- **Retention:** Held by the deployment, under its operator’s policy

View File

@@ -56,7 +56,7 @@ that would otherwise decay quietly.
```bash
npm run check:sidebar # the rendered docs tree still matches the planned one
npm run check:terms # no game's vocabulary outside that game's section
npm run check:terms # no game's vocabulary outside its section: docs + sidebar
npm run check:screens # every screenshot has an entry, at the size declared
npm run check:tokens # no colour literal outside the token file
npm run check:brand # the branding pipeline's two quiet failures
@@ -65,6 +65,7 @@ npm run check # astro check
npm test # the beta signup's decision path, and the policy data
npm run build # everything below reads the build
npm run check:links # every internal link resolves
npm run check:terms:built # the same rule over every built page, as rendered
GITEA_TOKEN=<token> npm run check:facts # every version agrees with its authority
GITEA_TOKEN=<token> npm run check:quickstart # the install page still matches website's own files
GITEA_TOKEN=<token> npm run check:reference # every name the Reference lists still exists
@@ -229,11 +230,15 @@ src/
data/collection.mjs What is collected, in three scopes. /privacy renders it and the
Play Data Safety notes are generated from it — one inventory.
data/legal.mjs The values /terms, /privacy and the consent sentence must share.
data/games/ One file per game, in that game's words, checked against its
module's capabilities. Only that game's pages render it.
styles/tokens.css THE token file — the only place a colour literal may appear.
styles/global.css The layout shell, built entirely from tokens.
styles/starlight.css Restates our tokens as Starlight's, so the docs cannot drift.
layouts/, components/ The marketing chrome.
pages/ Marketing routes.
pages/modules/ One page per game module; with /runicnpc/, the only marketing
routes that may use a game's own words (D63, checkTerms --built).
pages/beta.astro The signup. Renders AND handles its own POST — runs per request.
content/docs/docs/ Documentation. The extra level mounts Starlight at /docs.
pages/brand/ GET /brand/* — the mount, resolved and derived. Runs per request.

View File

@@ -23,6 +23,7 @@
"check:reference": "node scripts/checkReference.mjs",
"check:sidebar": "node scripts/checkSidebar.mjs",
"check:terms": "node scripts/checkTerms.mjs",
"check:terms:built": "node scripts/checkTerms.mjs --built",
"check:screens": "node scripts/checkScreens.mjs",
"check:a11y": "node scripts/checkA11y.mjs",
"check:csp": "node scripts/checkCsp.mjs",
@@ -32,7 +33,7 @@
"brand:assets": "node scripts/buildBrandAssets.mjs",
"screens:capture": "node scripts/captureScreens.mjs",
"csp:hashes": "node scripts/checkCsp.mjs --reset && astro build && node scripts/checkCsp.mjs --write && astro build && node scripts/checkCsp.mjs",
"verify": "npm run check:sidebar && npm run check:terms && npm run check:screens && npm run check:tokens && npm run check:brand && npm run check:datasafety && npm run check && npm test && npm run build && npm run check:links && npm run check:facts && npm run check:quickstart && npm run check:reference && npm run test:served && npm run check:a11y && npm run check:csp",
"verify": "npm run check:sidebar && npm run check:terms && npm run check:screens && npm run check:tokens && npm run check:brand && npm run check:datasafety && npm run check && npm test && npm run build && npm run check:links && npm run check:terms:built && npm run check:facts && npm run check:quickstart && npm run check:reference && npm run test:served && npm run check:a11y && npm run check:csp",
"test:served": "node --test test/headers.test.mjs"
},
"dependencies": {

View File

@@ -1,6 +1,7 @@
#!/usr/bin/env node
/**
* checkTerms.mjs — PLAN.md §16, D63 and D64. The twelfth check, added in phase 14.
* checkTerms.mjs — PLAN.md §16, D63 and D64. The twelfth check, added in phase 14 and widened
* to the whole site in phase 17 (D79).
*
* D63: outside a game's own section, the site may NAME the games it supports, but it may not
* use their VOCABULARY. Core concepts keep core's names — a Team, a game server, the sidecar,
@@ -13,7 +14,12 @@
* was measured before this check existed — "shard" 227 times in 45 files — and none of that
* was anybody's decision; it was the residue of a site written while there was one game.
*
* What it reads, as of phase 14:
* It runs in two passes, one before the build and one after.
*
* node scripts/checkTerms.mjs # the SOURCE pass: docs pages and the sidebar
* node scripts/checkTerms.mjs --built # the BUILT pass: every page in dist/, as rendered
*
* ── The source pass (phase 14) ──────────────────────────────────────────────────────────
*
* - every page under `src/content/docs`, frontmatter included (a title and a description
* are what a reader sees first, in the sidebar and in search results);
@@ -21,29 +27,44 @@
* page label, in both trees. Its comments are not scanned, because a comment is not
* something a reader of the site ever sees.
*
* Phase 17 widens it to all of `src/`, once the marketing pages have a game page to move
* their game content to. That is also when it learns D67's screenshot tag.
* It needs no build, so it runs early and fails fast, and it names a file and a line.
*
* Where a game's words are allowed:
* ── The built pass (phase 17, D79) ──────────────────────────────────────────────────────
*
* - in that game's own docs directory (`GAME_DIRS`), and its own sidebar group — a group
* The marketing pages are `.astro` templates fed from `src/data/`, so most of the words a
* reader sees on them are not in the template at all: a capability's label, a screenshot's
* caption, a version read from `platform.json`. A source scan sees an expression and skips
* it, or sees an id like `shard-status` and takes it for prose. So this pass reads what the
* build actually produced — the same argument `checkLinks.mjs` makes for links. For every
* page in `dist/client` it reads the visible text, plus `alt`, `title`, `aria-label`,
* `placeholder`, the `<title>` and the meta description. It does not read link targets,
* scripts, styles or comments. It names a route and a snippet rather than a line; the source
* pass still gives lines for the docs, where most of the words are.
*
* Two parts of every docs page are skipped, because each is checked somewhere better:
* Starlight's sidebar (the source pass reads both trees as data, group by group) and the
* previous/next links (each is a neighbouring page's title, checked on that page).
*
* ── Where a game's words are allowed ────────────────────────────────────────────────────
*
* - in that game's own docs directory (`GAME_DIRS`) and its own sidebar group — a group
* whose label is the game's `name` in `platform.json`;
* - between `<GameExample game="…">` tags on a core page (D68). The component prints the
* game's name above the block, so the words arrive already labelled.
* - on that game's own routes (`GAME_ROUTES`): its docs group, its module page, and for
* Rust the standalone RunicNPC page;
* - inside an element marked `data-game="…"`: `<GameExample>` on a docs page (D68) and a
* game-tagged `<Screenshot>` (D67). Both print the game's name beside the words, which is
* the bargain: a game's words are honest on a core page when the game is named right
* there. In the source pass, the `<GameExample>` tags themselves mark the region.
*
* What is NOT read, deliberately:
* What is NOT read, deliberately: link targets and bare URLs or paths (a repository is called
* what it is called, and an address is not prose — but LINK TEXT is read, so `[Module-uo](…)`
* fails and `[releases](…)` passes), MDX `import` lines, and, in the source pass only, `{…}`
* expressions — the built pass reads what they rendered.
*
* - link targets and bare URLs. A repository is called what it is called, and an address is
* not prose. The LINK TEXT is read, so `[Module-uo](…)` fails and `[releases](…)` passes;
* - `import` lines and `{…}` expressions. They are code, and what an expression renders is
* a value from `platform.json` or `src/data/`, which phase 17 brings into scope.
*
* node scripts/checkTerms.mjs
*
* No token and no network: everything it reads is in this repository.
* No token and no network: everything it reads is in this repository or its build output.
*/
import { readFileSync } from 'node:fs';
import { readFileSync, existsSync } from 'node:fs';
import { readdir } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
@@ -52,6 +73,7 @@ import { docsSidebar, plannedSidebar, flatLabels } from '../src/config/sidebar.m
const ROOT = fileURLToPath(new URL('..', import.meta.url));
const platform = JSON.parse(readFileSync(path.join(ROOT, 'src/data/platform.json'), 'utf8'));
const BUILT = process.argv.includes('--built');
/**
* The words, each tagged with the game it belongs to. Matching is case-insensitive and on
@@ -90,20 +112,32 @@ const GAME_DIRS = {
};
/**
* The allowlist: an exact phrase in one file that may use a word the rule would otherwise
* refuse. Short on purpose, and every entry says why. An entry that no longer matches its
* file fails the check too, so the list cannot rot into a set of permanent exemptions.
* Each game's own routes, for the built pass: its docs group, the page about its module, and
* for Rust the standalone RunicNPC page (D61), which a server owner reads without knowing what
* the platform is. A route matches when the page's path starts with one of these.
*/
const GAME_ROUTES = {
uo: ['/docs/ultima-online/', '/modules/uo/'],
rust: ['/docs/rust/', '/modules/rust/', '/runicnpc/'],
};
/**
* The allowlist: an exact phrase on one page that may use a word the rule would otherwise
* refuse. Short on purpose, and every entry says why. `file` is where the source pass meets
* it and `route` is where the built pass does. An entry that no longer matches fails the
* check too, so the list cannot rot into a set of permanent exemptions.
*/
const ALLOW = [
{
file: 'src/content/docs/docs/administration/notifications-and-email.mdx',
route: '/docs/administration/notifications-and-email/',
text: 'Guild (server) ID',
why: "Discord's own word, not a game's: it is the label on core's Discord Bot screen.",
},
];
for (const game of Object.keys(GAME_DIRS)) {
if (!platform.games[game]) throw new Error(`checkTerms: GAME_DIRS names "${game}", which platform.json does not.`);
for (const game of new Set([...Object.keys(GAME_DIRS), ...Object.keys(GAME_ROUTES)])) {
if (!platform.games[game]) throw new Error(`checkTerms: "${game}" is given a section here, but platform.json has no such game.`);
}
for (const t of TERMS) {
if (!platform.games[t.game]) throw new Error(`checkTerms: "${t.word}" is tagged "${t.game}", which platform.json does not know.`);
@@ -115,6 +149,35 @@ const fail = (where, detail) => failures.push({ where, detail });
/** Replace a span with spaces, keeping newlines, so later line numbers stay true. */
const blank = (text) => text.replace(/[^\n]/g, ' ');
/** Hits for every term the given context may not use. `allowed` is a game key, or null. */
function scan(text, allowed) {
const hits = [];
for (const t of TERMS) {
if (t.game === allowed) continue;
t.re.lastIndex = 0;
for (const m of text.matchAll(t.re)) hits.push({ term: t, value: m[0], index: m.index });
}
return hits;
}
const gameName = (game) => platform.games[game].name;
async function* walk(dir, pattern) {
let entries;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) yield* walk(full, pattern);
else if (pattern.test(entry.name)) yield full;
}
}
// ═══ The source pass ═══════════════════════════════════════════════════════════════════
/** The game whose directory holds this file, or null for a core page. */
function gameOfPath(relative) {
const posix = relative.split(path.sep).join('/');
@@ -122,6 +185,10 @@ function gameOfPath(relative) {
return null;
}
function lineOf(source, index) {
return source.slice(0, index).split('\n').length;
}
/**
* Turn an MDX source into the text a reader would actually see, with everything that is not
* prose blanked out. Returns that text plus the GameExample regions, as offsets into it.
@@ -162,110 +229,221 @@ function proseOf(source, relative) {
return { text, regions };
}
function lineOf(source, index) {
return source.slice(0, index).split('\n').length;
}
async function sourcePass() {
// ── 1. The documentation pages ──
let files = 0;
const allowUsed = new Set();
/** Hits for every term the given context may not use. */
function scan(text, allowedGame) {
const hits = [];
for (const t of TERMS) {
if (t.game === allowedGame) continue;
t.re.lastIndex = 0;
for (const m of text.matchAll(t.re)) hits.push({ term: t, value: m[0], index: m.index });
}
return hits;
}
for await (const file of walk(path.join(ROOT, 'src', 'content', 'docs'), /\.mdx?$/)) {
const relative = path.relative(ROOT, file).split(path.sep).join('/');
const source = readFileSync(file, 'utf8').replace(/\r\n/g, '\n');
files++;
async function* walk(dir) {
let entries;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) yield* walk(full);
else if (/\.mdx?$/.test(entry.name)) yield full;
}
}
let { text, regions } = proseOf(source, relative);
// ── 1. The documentation pages ──────────────────────────────────────────────
let files = 0;
const allowUsed = new Set();
for (const [i, entry] of ALLOW.entries()) {
if (entry.file !== relative) continue;
if (text.includes(entry.text)) {
allowUsed.add(i);
text = text.split(entry.text).join(blank(entry.text));
}
}
for await (const file of walk(path.join(ROOT, 'src', 'content', 'docs'))) {
const relative = path.relative(ROOT, file).split(path.sep).join('/');
const posix = relative;
const source = readFileSync(file, 'utf8').replace(/\r\n/g, '\n');
files++;
let { text, regions } = proseOf(source, relative);
for (const [i, entry] of ALLOW.entries()) {
if (entry.file !== posix) continue;
if (text.includes(entry.text)) {
allowUsed.add(i);
text = text.split(entry.text).join(blank(entry.text));
const pageGame = gameOfPath(relative);
for (const hit of scan(text, pageGame)) {
const region = regions.find((r) => hit.index >= r.start && hit.index < r.end);
if (region && region.game === hit.term.game) continue;
const where = region ? `inside <GameExample game="${region.game}">` : pageGame ? `in the ${gameName(pageGame)} group` : 'on a core page';
fail(`${relative}:${lineOf(source, hit.index)}`, `"${hit.value}" is ${gameName(hit.term.game)} vocabulary, ${where}`);
}
}
const pageGame = gameOfPath(relative);
for (const hit of scan(text, pageGame)) {
const region = regions.find((r) => hit.index >= r.start && hit.index < r.end);
if (region && region.game === hit.term.game) continue;
const where = region ? `inside <GameExample game="${region.game}">` : pageGame ? `in the ${platform.games[pageGame].name} group` : 'on a core page';
fail(
`${relative}:${lineOf(source, hit.index)}`,
`"${hit.value}" is ${platform.games[hit.term.game].name} vocabulary, ${where}`,
);
for (const [i, entry] of ALLOW.entries()) {
if (!allowUsed.has(i)) fail(`allowlist: ${entry.file}`, `"${entry.text}" no longer appears there — remove the entry`);
}
}
for (const [i, entry] of ALLOW.entries()) {
if (!allowUsed.has(i)) {
fail(`allowlist: ${entry.file}`, `"${entry.text}" no longer appears there — remove the entry`);
}
}
// ── 2. The sidebar, as data ─────────────────────────────────────────────────
const gameByName = new Map(Object.entries(platform.games).map(([key, g]) => [g.name, key]));
const trees = [
['docsSidebar', docsSidebar.map((g) => [g.label, flatLabels(g.items)])],
['plannedSidebar', Object.entries(plannedSidebar).map(([label, items]) => [label, flatLabels(items)])],
];
let labels = 0;
for (const [treeName, groups] of trees) {
for (const [groupLabel, items] of groups) {
const game = gameByName.get(groupLabel) ?? null;
for (const label of [groupLabel, ...items]) {
labels++;
for (const hit of scan(label, game)) {
fail(
`src/config/sidebar.mjs ${treeName} → ${groupLabel}`,
`"${label}" uses "${hit.value}", ${platform.games[hit.term.game].name} vocabulary, ${game ? `in the ${platform.games[game].name} group` : 'in a core group'}`,
);
// ── 2. The sidebar, as data ──
const gameByName = new Map(Object.entries(platform.games).map(([key, g]) => [g.name, key]));
const trees = [
['docsSidebar', docsSidebar.map((g) => [g.label, flatLabels(g.items)])],
['plannedSidebar', Object.entries(plannedSidebar).map(([label, items]) => [label, flatLabels(items)])],
];
let labels = 0;
for (const [treeName, groups] of trees) {
for (const [groupLabel, items] of groups) {
const game = gameByName.get(groupLabel) ?? null;
for (const label of [groupLabel, ...items]) {
labels++;
for (const hit of scan(label, game)) {
fail(
`src/config/sidebar.mjs ${treeName} → ${groupLabel}`,
`"${label}" uses "${hit.value}", ${gameName(hit.term.game)} vocabulary, ${game ? `in the ${gameName(game)} group` : 'in a core group'}`,
);
}
}
}
}
return `${files} docs pages and ${labels} sidebar labels use only their own game's words`;
}
// ── Report ──────────────────────────────────────────────────────────────────
// ═══ The built pass ════════════════════════════════════════════════════════════════════
const DIST = path.join(ROOT, 'dist', 'client');
/** `dist/client/a/b/index.html` → `/a/b/`; `dist/client/404.html` → `/404`. */
function routeOf(file) {
const rel = path.relative(DIST, file).split(path.sep).join('/');
if (rel === 'index.html') return '/';
if (rel.endsWith('/index.html')) return `/${rel.slice(0, -'index.html'.length)}`;
return `/${rel.replace(/\.html$/, '')}`;
}
function gameOfRoute(route) {
for (const [game, prefixes] of Object.entries(GAME_ROUTES)) {
if (prefixes.some((p) => route.startsWith(p))) return game;
}
return null;
}
/**
* Where the element opening at `start` closes, counting nested elements of the same name.
* Astro's output is well formed, which is what makes this enough without a parser.
*/
function elementEnd(html, start, tag) {
const re = new RegExp(`<(/?)${tag}\\b[^>]*?(/?)>`, 'gi');
re.lastIndex = start;
let depth = 0;
for (let m = re.exec(html); m; m = re.exec(html)) {
if (m[2] === '/') continue; // self-closing
depth += m[1] === '/' ? -1 : 1;
if (depth === 0) return re.lastIndex;
}
return html.length;
}
/** Every element whose opening tag matches `openRe`, as `{ start, end, match }`. */
function elements(html, openRe) {
const found = [];
for (const m of html.matchAll(openRe)) {
const tag = /^<([a-z0-9-]+)/i.exec(m[0])[1];
found.push({ start: m.index, end: elementEnd(html, m.index, tag), match: m });
}
return found;
}
const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', rsquo: '’', lsquo: '‘', ldquo: '“', rdquo: '”', mdash: '—', ndash: '–', hellip: '…', rarr: '→', middot: '·' };
const decode = (s) =>
s.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (m, e) => {
if (e[0] === '#') return String.fromCodePoint(e[1] === 'x' || e[1] === 'X' ? parseInt(e.slice(2), 16) : Number(e.slice(1)));
return ENTITIES[e.toLowerCase()] ?? m;
});
/**
* Elements whose text runs on into the next word with no space between: a word split across
* two `<span>`s by a syntax highlighter is still one word to a reader. Everything else is a
* boundary, so `<h3>Guilds</h3><p>Guild…` does not read as "GuildsGuild".
*/
const INLINE = /^(?:a|abbr|b|bdi|bdo|cite|code|data|dfn|em|i|kbd|mark|q|s|samp|small|span|strong|sub|sup|time|u|var|wbr)$/i;
/** The words a reader sees in a piece of HTML: its text, and the attributes that are read aloud or shown. */
function visibleText(html) {
const attrs = [];
for (const m of html.matchAll(/\s(?:alt|title|aria-label|placeholder)="([^"]*)"/gi)) attrs.push(m[1]);
let text = html.replace(/<\/?([a-z][a-z0-9-]*)\b[^>]*>/gi, (_, tag) => (INLINE.test(tag) ? '' : ' '));
text = decode([text, ...attrs.map(decode)].join(' \n '));
text = text.replace(/\bhttps?:\/\/\S+/g, ' '); // bare URLs
text = text.replace(/(^|[\s(“"'])\/[\w./#?=&%-]+/g, '$1 '); // bare paths — an address, not prose
return text.replace(/\s+/g, ' ');
}
function snippet(text, index, length) {
const from = Math.max(0, index - 50);
const to = Math.min(text.length, index + length + 50);
return `${from > 0 ? '…' : ''}${text.slice(from, to).trim()}${to < text.length ? '…' : ''}`;
}
async function builtPass() {
if (!existsSync(DIST)) {
console.error('checkTerms --built: dist/client does not exist. Run `npm run build` first.');
process.exit(1);
}
let pages = 0;
let regionsSeen = 0;
const allowUsed = new Set();
for await (const file of walk(DIST, /\.html$/)) {
const route = routeOf(file);
let html = readFileSync(file, 'utf8');
pages++;
// The head: only the parts a reader sees, in a tab or a search result.
const head = /<head\b[\s\S]*?<\/head>/i.exec(html)?.[0] ?? '';
const headText = [/<title>([\s\S]*?)<\/title>/i.exec(head)?.[1] ?? '', /<meta\s+name="description"\s+content="([^"]*)"/i.exec(head)?.[1] ?? ''].join(' \n ');
html = html.replace(/<head\b[\s\S]*?<\/head>/i, ' ');
html = html.replace(/<(script|style|template|noscript)\b[\s\S]*?<\/\1>/gi, ' ');
html = html.replace(/<!--[\s\S]*?-->/g, ' ');
// Checked elsewhere (see the header): Starlight's sidebar and its previous/next links.
for (const skip of [/<nav\b[^>]*\baria-label="Main"[^>]*>/gi, /<div\b[^>]*\bclass="pagination-links\b[^>]*>/gi]) {
for (const el of elements(html, skip).reverse()) html = html.slice(0, el.start) + ' ' + html.slice(el.end);
}
const pageGame = gameOfRoute(route);
const pieces = [{ text: decode(headText), allowed: pageGame, where: 'in the page title or description' }];
// Regions marked with a game. Read with that game's words allowed, then cut out.
for (const el of elements(html, /<[a-z][a-z0-9-]*\b[^>]*\bdata-game="([^"]*)"[^>]*>/gi).reverse()) {
const game = el.match[1];
regionsSeen++;
if (!platform.games[game]) {
fail(route, `an element is marked data-game="${game}", which platform.json does not have`);
continue;
}
pieces.push({ text: visibleText(html.slice(el.start, el.end)), allowed: game, also: pageGame, where: `inside a block marked ${gameName(game)}` });
html = html.slice(0, el.start) + ' ' + html.slice(el.end);
}
pieces.push({ text: visibleText(html), allowed: pageGame, where: pageGame ? `on a ${gameName(pageGame)} page` : 'on a core page' });
for (const piece of pieces) {
let { text } = piece;
for (const [i, entry] of ALLOW.entries()) {
if (entry.route !== route || !text.includes(entry.text)) continue;
allowUsed.add(i);
text = text.split(entry.text).join(' '.repeat(entry.text.length));
}
for (const hit of scan(text, piece.allowed)) {
if (piece.also && hit.term.game === piece.also) continue;
fail(route, `"${hit.value}" is ${gameName(hit.term.game)} vocabulary, ${piece.where}\n ${snippet(text, hit.index, hit.value.length)}`);
}
}
}
for (const [i, entry] of ALLOW.entries()) {
if (!allowUsed.has(i)) fail(`allowlist: ${entry.route}`, `"${entry.text}" no longer appears in the built page — remove the entry`);
}
return `${pages} built pages use only their own game's words (${regionsSeen} game-marked blocks read with their game's words allowed)`;
}
// ═══ Report ════════════════════════════════════════════════════════════════════════════
const summary = BUILT ? await builtPass() : await sourcePass();
if (failures.length === 0) {
console.log(
`checkTerms: ${files} docs pages and ${labels} sidebar labels use only their own game's words ` +
`(${TERMS.length} terms, ${ALLOW.length} allowlisted phrase${ALLOW.length === 1 ? '' : 's'}).`,
);
console.log(`checkTerms${BUILT ? ' --built' : ''}: ${summary} (${TERMS.length} terms, ${ALLOW.length} allowlisted phrase${ALLOW.length === 1 ? '' : 's'}).`);
} else {
console.error(`\ncheckTerms: ${failures.length} game word(s) outside their game's section:\n`);
console.error(`\ncheckTerms${BUILT ? ' --built' : ''}: ${failures.length} game word(s) outside their game's section:\n`);
for (const f of failures) console.error(` ✗ ${f.where}\n ${f.detail}`);
console.error(`
PLAN.md §16, D63: name the games anywhere, but keep their vocabulary in their own section.
Use core's word instead — a Team, a game server, the sidecar, the bridge plugin, the game
module. If a core page genuinely needs a game's real example, wrap it in
<GameExample game="…"> (D68), which labels it with the game's name. Add to the allowlist
in this script only for a word that is not a game's at all, and say why.
module. A marketing page describes what a game module adds in core's words and links to
the game's own page (D75). If a docs page genuinely needs a game's real example, wrap it in
<GameExample game="…"> (D68); a screenshot of a game screen carries its game tag (D67).
Add to the allowlist in this script only for a word that is not a game's at all, and say why.
`);
process.exit(1);
}

View File

@@ -0,0 +1,107 @@
---
import type { Capability } from '../data/capabilities.mjs';
/**
* A game's own feature list, with the argument for each — PLAN.md §16, phase 17.
*
* Rendered only by a game's page under `/modules/` (`GAME_ROUTES` in `checkTerms.mjs`),
* which is what lets the items be written in the game's own words. It is `/features/`'s
* card treatment (D20: the label and the detail, never the label alone), so a reader who has
* seen that page reads this one the same way.
*
* A feature with a `demoPath` gets the per-capability demo link of D25. The markup is the
* exact contract `scripts/checkBrand.mjs` enforces and `applyBrand.mjs` rewrites at boot —
* see the note in `/features/` before touching it. A stock build hides every one.
*/
interface Props {
title: string;
lede?: string;
items: Capability[];
}
const { title, lede, items } = Astro.props;
---
<section class="page section feats">
<h2>{title}</h2>
{lede && <p class="prose feats__lede">{lede}</p>}
<ul class="feats__items">
{
items.map((item) => (
<li class="panel feats__item">
<h3>{item.label}</h3>
<p class="feats__detail">{item.detail}</p>
{item.demoPath && (
<a
class="demo-link"
href="" data-demo-url="" data-demo-path={item.demoPath}
rel="noopener noreferrer"
>
See it running
</a>
)}
</li>
))
}
</ul>
</section>
<style>
.feats h2 {
margin: 0;
font-size: clamp(1.5rem, 3vw, 1.95rem);
}
.feats__lede {
margin: 0.85rem 0 0;
color: var(--muted);
}
.feats__items {
display: grid;
gap: 1rem;
margin: 1.75rem 0 0;
padding: 0;
list-style: none;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 21rem), 1fr));
}
.feats__item {
display: flex;
flex-direction: column;
}
.feats__item h3 {
margin: 0 0 0.6rem;
color: var(--gold);
font-size: 1.04rem;
}
.feats__detail {
flex: 1;
margin: 0;
color: var(--muted);
font-size: 0.94rem;
}
/* As on /features/: a link rather than a button, and the flex item itself, so the
global rule that hides an empty demo link takes its margin with it. */
.demo-link {
display: inline-flex;
align-items: center;
gap: 0.35rem;
margin-top: 1rem;
color: var(--portal);
font-size: 0.88rem;
text-decoration-color: color-mix(in srgb, var(--portal) 40%, transparent);
}
.demo-link:hover {
color: var(--portal-bright);
}
.demo-link::after {
content: '\2197';
}
</style>

View File

@@ -28,6 +28,8 @@ const brand = renderBrand(Astro);
const links = [
{ href: '/features/', label: 'Features' },
{ href: '/docs/', label: 'Docs' },
// D78 (phase 17): the standalone RunicNPC page, for a Rust server owner who runs no site.
{ href: '/runicnpc/', label: 'RunicNPC' },
{ href: '/app/', label: 'App' },
{ href: '/community/', label: 'Community' },
];

View File

@@ -1,5 +1,6 @@
---
import { screenById, WEB, PHONE } from '../data/screens.mjs';
import platform from '../data/platform.json';
/**
* One screenshot, as a figure with its caption. PLAN.md §13 phase 9, D4 / D44.
@@ -44,9 +45,16 @@ const src = `/screens/${shot.id}.webp`;
// taken at one geometry (see screens.mjs), and `checkScreens.mjs` asserts the files really
// are that size, so these attributes cannot drift from the pixels.
const { width, height } = shot.family === 'web' ? WEB : PHONE;
// D67: a game's screen says whose it is. The name comes from `platform.json`, as
// GameExample's does, so the label cannot disagree with the tag the check reads.
const game = shot.game ? platform.games[shot.game] : null;
if (shot.game && !game) {
throw new Error(`Screenshot "${id}" is tagged game "${shot.game}", which platform.json does not have`);
}
---
<figure class="shot">
<figure class="shot" data-game={shot.game}>
<img
src={src}
alt={shot.alt}
@@ -55,7 +63,14 @@ const { width, height } = shot.family === 'web' ? WEB : PHONE;
loading="lazy"
decoding="async"
/>
{!bare && <figcaption>{shot.caption}</figcaption>}
{
(game || !bare) && (
<figcaption>
{game && <span class="shot__game">{game.name}</span>}
{!bare && shot.caption}
</figcaption>
)
}
</figure>
<style>
@@ -78,4 +93,18 @@ const { width, height } = shot.family === 'web' ? WEB : PHONE;
font-size: 0.9rem;
line-height: 1.5;
}
/* The game's name, ahead of the caption (D67). Styled here rather than with the global
chip, because the docs pages render this component without the marketing stylesheet. */
.shot__game {
display: inline-block;
margin-right: 0.5rem;
padding: 0.05rem 0.55rem;
border: 1px solid var(--line);
border-radius: var(--radius-pill);
color: var(--muted);
font-size: 0.76rem;
letter-spacing: 0.04em;
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,63 @@
---
import { games } from '../data/games/index.mjs';
/**
* "Supported games: Ultima Online · Rust" — PLAN.md §16, D63.
*
* The one place a core page names the games, and the way it hands a reader on to them. Core
* pages describe what a game module adds in core's words (D75); this strip is the link to
* the page where each game says it in its own. The names come from `platform.json` through
* `games/index.mjs`, so a third game appears here the day it is added there.
*/
interface Props {
/** Extra class for the caller's spacing. */
class?: string;
}
const { class: className } = Astro.props;
---
<p class:list={['games', className]}>
<span class="games__label">Supported games</span>
{
games.map((game, index) => (
<>
{index > 0 && <span class="games__sep" aria-hidden="true">·</span>}
<a class="games__link" href={game.href}>
{game.name}
</a>
</>
))
}
</p>
<style>
.games {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.35rem 0.65rem;
margin: 0;
font-size: 0.95rem;
}
.games__label {
color: var(--dim);
font-size: 0.78rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.games__label::after {
content: ':';
}
.games__sep {
color: var(--dim);
}
.games__link {
color: var(--gold);
font-weight: 600;
}
</style>

View File

@@ -1,5 +1,6 @@
---
import { screensOf, PHONE } from '../../data/screens.mjs';
import platform from '../../data/platform.json';
/**
* The app's screenshot strip. Reserved in phase 5 (D26), filled in phase 9.
@@ -45,7 +46,7 @@ const shots = screensOf('phone');
<ul class="shots__grid">
{shots.map((shot) => (
<li class="shots__item">
<li class="shots__item" data-game={shot.game}>
<img
src={`/screens/${shot.id}.webp`}
alt={shot.alt}
@@ -54,7 +55,10 @@ const shots = screensOf('phone');
loading="lazy"
decoding="async"
/>
<p class="shots__caption">{shot.caption}</p>
<p class="shots__caption">
{shot.game && <span class="shots__game">{platform.games[shot.game].name}</span>}
{shot.caption}
</p>
</li>
))}
</ul>
@@ -91,6 +95,19 @@ const shots = screensOf('phone');
box-shadow: var(--shadow-card);
}
/* The game's name on a game's screen (D67), as Screenshot.astro prints it. */
.shots__game {
display: inline-block;
margin-right: 0.5rem;
padding: 0.05rem 0.55rem;
border: 1px solid var(--line);
border-radius: var(--radius-pill);
color: var(--muted);
font-size: 0.74rem;
letter-spacing: 0.04em;
white-space: nowrap;
}
.shots__caption {
margin: 0.85rem 0 0;
color: var(--dim);

View File

@@ -26,8 +26,8 @@ import platform from '../../data/platform.json';
<p class="eyebrow">Where the game stops</p>
<h2>A seam, with a version on it</h2>
<p class="prose">
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
The core site does not know what game it serves, what that game calls a group of
players, or that Ultima Online and Rust exist. 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.
</p>
@@ -123,8 +123,8 @@ import platform from '../../data/platform.json';
<p>
Core owns the Teams primitive — the roster, the forum, the notifications, the voice
channel — and does not own the <em>word</em>. 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
all; it arrives from the module, which is why each module calls them by its own
game’s word and builds those pages itself. That is the pattern the whole interface is built on: core
supplies the machinery, the module supplies the meaning.
</p>
</section>

View File

@@ -9,7 +9,10 @@
* 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
* not a particular game's server — with the prose beside it linking to each game's page,
* which names its real components (D75). Until phase 17 the caption named one game's and
* said two machines was also the maximum, which stopped being true when Rust shipped: one
* sidecar serves one server, so six servers means six sidecars. 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.
*
@@ -73,8 +76,10 @@
</svg>
<p class="diagram__caption">
Today the game server is a ServUO shard and the sidecar is uo-link. Two machines is
the minimum and also the maximum — nothing here scales by adding a third.
The sidecar and the bridge plugin are each game’s own — see
<a href="/modules/uo/">Ultima Online</a> and <a href="/modules/rust/">Rust</a>. Two
machines is the minimum. A community running several game servers runs a sidecar
beside each one, on whichever host that server already lives on.
</p>
</div>
@@ -92,10 +97,10 @@
<section>
<h3>The game host</h3>
<p>
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.
The machine your game server is already on. One installer binary puts the bridge
plugin into the server's tree, installs the sidecar beside it and registers the
service — then prints what the site needs. It never contacts your website; you enter
those values in the admin panel yourself, and that is the moment the two halves meet.
</p>
</section>

View File

@@ -1,6 +1,7 @@
---
import platform from '../../data/platform.json';
import { capabilityGroups, assertCapabilityCoverage } from '../../data/capabilities.mjs';
import { capabilityGroups } from '../../data/capabilities.mjs';
import { games, assertGameCoverage } from '../../data/games/index.mjs';
import SupportedGames from '../SupportedGames.astro';
/**
* The grouped capabilities (PLAN.md §10). All five groups, named only — the argument for
@@ -8,8 +9,10 @@ import { capabilityGroups, assertCapabilityCoverage } from '../../data/capabilit
* copy to keep true.
*
* The call below is the point of the exercise: it throws, and therefore fails the build, if
* the "Game intelligence" list and the module's own declared capabilities have drifted
* apart. `checkFacts.mjs` already keeps `platform.json` honest against the module manifest;
* either game's own feature list (`src/data/games/`) and its module's declared capabilities
* have drifted apart. The homepage names neither list — its module-supplied group is the
* neutral one, in core's words (D75) — but it is the page a reader meets first, so it is
* the page that should refuse to build when a game page is out of date. `checkFacts.mjs` already keeps `platform.json` honest against the module manifest;
* this makes the page honest against `platform.json`, which is the half that was missing.
*
* The "not built" line at the bottom is not a disclaimer bolted on — §2's absent-features
@@ -17,17 +20,18 @@ import { capabilityGroups, assertCapabilityCoverage } from '../../data/capabilit
* what exists while quietly omitting the well-known things that do not is the exact failure
* §1 is written to prevent.
*/
assertCapabilityCoverage(platform.games.uo.module.capabilities);
for (const game of games) assertGameCoverage(game.key);
---
<section class="page section caps">
<p class="eyebrow">What it does</p>
<h2>A community site, and a window into the game</h2>
<p class="prose caps__lede">
The core is game-agnostic: it does not know what a shard is. Everything that does arrives
as an installable <a href="/modules/">module</a>, which is why the same platform can carry
a different game without a fork.
The core is game-agnostic: it does not know what game you run. Everything that does
arrives as an installable <a href="/modules/">module</a>, which is why the same platform
carries two games without a fork.
</p>
<SupportedGames class="caps__games" />
<div class="caps__grid">
{
@@ -69,6 +73,10 @@ assertCapabilityCoverage(platform.games.uo.module.capabilities);
color: var(--muted);
}
.caps__games {
margin-top: 1.25rem;
}
.caps__grid {
display: grid;
gap: 1rem;

View File

@@ -4,7 +4,7 @@ import { brand } from '../../lib/brand.mjs';
/**
* The get-started CTA (PLAN.md §10, the `/` row), built around the trap in §10's
* "installation path": a "Runic Gateway install" is two independent installs. The installer
* binary sets up the shard side only and never contacts the website; the website is a
* binary sets up the game side only and never contacts the website; the website is a
* separate Docker deployment.
*
* That belongs on the homepage rather than being saved for the docs. It is the single
@@ -13,7 +13,9 @@ import { brand } from '../../lib/brand.mjs';
* project. Saying it here is what makes the docs a confirmation rather than a surprise.
*
* The two halves are ordered site-first because that is the order they must be done in: the
* shard side ends by pasting four values into the site's admin panel, which has to exist.
* game side ends by entering what the installer printed into the site's admin panel, which
* has to exist. Which screen that is belongs to the game module, so this says "the game
* module's screen" rather than naming one game's menu (D63).
*
* Both "read the docs" links point at `/docs/` rather than at a page inside the journey.
* Phases 7 and 8 write those pages and own their slugs; guessing one now would put a URL in
@@ -44,9 +46,10 @@ import { brand } from '../../lib/brand.mjs';
<div class="start__half">
<h3><span class="start__num">2</span> The game side</h3>
<p>
One binary, run on the machine the game server already lives on. It syncs the plugin,
installs the sidecar as a service, and prints four values. You paste those into
Admin → Shard, and the two halves find each other.
One binary, run on the machine the game server already lives on — or, for Rust, a
Pterodactyl egg. It puts the bridge plugin in place, installs the sidecar, and prints
what the site needs. You enter that on the game module’s screen in the admin panel,
and the two halves find each other.
</p>
</div>
</div>

View File

@@ -9,12 +9,18 @@ import Screenshot from '../Screenshot.astro';
* -----------------------------------------------------------------------------------------
* `DataPath` above it draws the claim — a private game server, a sidecar, a public site —
* and a diagram of a data path is a promise that the data arrives. This is the page where
* it arrives, captured from a deployment wired to a running shard, so the section directly
* under the diagram is the diagram's evidence.
* it arrives, captured from a deployment wired to a running game server, so the section
* directly under the diagram is the diagram's evidence.
*
* It is one game's screen, and says so (D76, taken in phase 17 under D67): the screenshot is
* tagged `uo` in `screens.mjs`, so it prints "Ultima Online" beside it and may keep that
* game's words in its alt and caption. A core screen was the alternative and was declined:
* the claim here is live game state on a public page, which no core screen shows. Phase 18
* adds a Rust capture beside it, labelled the same way.
*
* A gallery here would compete with `Capabilities` further down, which is the part of the
* homepage that enumerates. So: one figure, the signature screen, and the rest of the set
* on `/features/` where each one sits beside the claim it supports.
* on each game's page under `/modules/`, where each one sits beside the claim it supports.
*/
---
@@ -22,9 +28,9 @@ import Screenshot from '../Screenshot.astro';
<p class="eyebrow">What it looks like</p>
<h2>The other end of that diagram</h2>
<p class="prose looks__lede">
A demo deployment with a real shard behind it. The gold supply, the state of the link and
the player online in Britain are all read from the game server over the bridge. None of it
is typed in, and none of it is a mock-up.
A demo deployment with a real Ultima Online server behind it. The gold supply, the state
of the link and the player online in Britain are all read from the game server over the
bridge. None of it is typed in, and none of it is a mock-up.
</p>
<Screenshot id="shard-status" />

View File

@@ -4,9 +4,9 @@
* ---------------------------------------------------------------------------------------
* WHY THIS IS NOT capabilities.mjs
* ---------------------------------------------------------------------------------------
* `capabilities.mjs` describes what a DEPLOYMENT does, and it is checked against
* `module-uo`'s manifest because the module declares its capabilities in a machine-readable
* file. The app declares nothing of the kind: what it does is a set of screens in
* `capabilities.mjs` and `games/` describe what a DEPLOYMENT does, and each game's list is
* checked against its module's manifest because a module declares its capabilities in a
* machine-readable file. The app declares nothing of the kind: what it does is a set of screens in
* `Routes.kt`, and there is no manifest to diff against. So these are written from that
* file and carry the route names, which is the closest thing to a citation available — a
* reader who wants to check a claim here has a file to open.
@@ -18,11 +18,19 @@
* ---------------------------------------------------------------------------------------
* "GATED BY WHAT THE DEPLOYMENT PUBLISHES" IS LOAD-BEARING, NOT A DISCLAIMER
* ---------------------------------------------------------------------------------------
* Almost everything here is conditional on the site the app is pointed at: shard screens
* Almost everything here is conditional on the site the app is pointed at: game screens
* appear only when the deployment runs a game module and has that visibility feature
* turned on, staff screens only for a staff account, push only where the operator runs an
* ntfy. A features list that omitted that would be describing an app nobody will see,
* because there is no default deployment — see `requiresDeployment` below.
*
* ---------------------------------------------------------------------------------------
* ONE ITEM PER GAME, NAMED BY THE GAME (phase 17, D75 and D77)
* ---------------------------------------------------------------------------------------
* `/app/` is a core page, so it names the games but not their vocabulary. The game screens
* are one item per game, titled with the game's name and described in core's words. The
* Rust screens (`ui/rust/` on `Android-app`) were on `edge` when this was written; the org
* lead released them the same day (D77), so the page describes them as shipped.
*/
/**
@@ -54,33 +62,29 @@ export const requiresDeployment = {
/** @type {{ heading: string, blurb: string, items: AppFeature[] }[]} */
export const appFeatures = [
{
heading: 'The shard, live',
heading: 'The game, live',
blurb:
'The same feed the website shows, on a phone. Every one of these appears only when ' +
'the deployment runs a game module and the operator has published that surface — ' +
'the visibility settings are per-feature and default to off.',
items: [
{
title: 'Status, and the boards',
title: 'Ultima Online: the server, its boards and its market',
body:
'Whether the game server is up, plus champion spawns, guilds, city governors and ' +
'houses as the shard reports them.',
gate: 'A game module, and the matching visibility feature',
'Whether the game server is up and what it reports — its champions, Teams, ' +
'governors and houses — plus the player marketplace down to a single vendor, the ' +
"published ruleset, the leaderboards and the creature atlas. Item names are " +
"resolved against the game's own string table, so they read the way they read in " +
'the client.',
gate: 'The Ultima Online module, per feature',
},
{
title: 'The player marketplace',
title: 'Rust: every server, live',
body:
'Player-run vendors and what is on their shelves, down to a single vendor. Item ' +
"names arrive as the game's own string ids and are resolved against its string " +
'table, so they read the way they read in the client.',
gate: 'A game module publishing the market',
},
{
title: 'Rules, leaderboards and the spawn atlas',
body:
"The deployment's published ruleset, its points and loyalty boards, and the " +
'creature atlas — what spawns where, and what it drops.',
gate: 'A game module, per feature',
'Every server the community runs, and a page for each with its feed, its ' +
'leaderboard, who is online, its history on the site, and its live map with the ' +
'layers you may see — positions refreshed every ten seconds while the map is open.',
gate: 'The Rust module, per feature',
},
],
},
@@ -117,10 +121,11 @@ export const appFeatures = [
'per device from the app.',
},
{
title: 'Your characters, vendors and houses',
title: 'Your own side of the game',
body:
'Character sheets, the vendors you run and the houses you own, visible to the ' +
'account they belong to and to nobody else.',
'On Ultima Online, your character sheets, the vendors you run and the houses you ' +
'own; on Rust, the Steam accounts you have linked, your coins and your backpack. ' +
'Visible to the account they belong to and to nobody else.',
gate: 'A game module',
},
],

View File

@@ -16,23 +16,17 @@
* the homepage without an argument to go with it.
*
* ---------------------------------------------------------------------------------------
* THE PART THAT IS A CHECK, NOT A LIST
* THE ONE GROUP CORE DOES NOT SUPPLY, IN CORE'S WORDS
* ---------------------------------------------------------------------------------------
* "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.
* "What a game module adds" is the only group core does not supply — it comes from
* whichever module is installed. Until phase 17 it was "Game intelligence" and listed the
* Ultima Online module's features in that game's words, on the homepage and `/features/`.
* Two modules exist now, and D63 keeps a game's vocabulary on its own pages, so the group
* says what BOTH modules add, in core's words, and each game's real list lives in
* `src/data/games/` and on its page under `/modules/` (D75).
*
* That closes a real gap. `platform.json` holds `games.uo.module.capabilities` 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.
* The capability check moved with the lists: `assertGameCoverage()` in `games/index.mjs`
* holds each game's features to the capabilities its module declares, in both directions.
*
* ---------------------------------------------------------------------------------------
* `needsModule` — THE THIRD STATE, WHICH PHASE 3 DID NOT HAVE
@@ -47,8 +41,8 @@
* 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.
* word for a Team, so each module calls them what its game calls them and builds the pages
* on the same primitive.
*
* So `needsModule` marks an item that is core machinery a module has to populate. On a bare
* core it is present, correct and permanently empty. `/` renders the requalified group
@@ -68,11 +62,13 @@
* 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.
* guessed. A module's public pages are namespaced under its own id, so a route into the
* Ultima Online module is `/uo/…`: the demo §15 plans runs that game, which is why the
* Teams item points there and the module-supplied items in `games/` carry their own.
*/
import { games } from './games/index.mjs';
/**
* The shape of a capability, written out because TypeScript otherwise infers it per group
* from whichever fields that group's items happen to use — and then `/features/` cannot
@@ -83,7 +79,7 @@
* @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 {string[]} [caps] Module capability slugs, on a game's own items (`games/`) only.
* @property {boolean} [needsModule] Core machinery a module has to populate (D24).
* @property {string} [demoPath] A stable public route, deep-linked into a demo (D25).
*
@@ -124,8 +120,8 @@ const community = {
'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.',
'which is how a group the game already has becomes a Team on the site — and why ' +
'each game keeps its own name for one without core learning a new word.',
},
{
label: 'Team forums',
@@ -178,102 +174,66 @@ const community = {
};
/**
* Game intelligence — module-supplied. The `caps` arrays are the contract with
* `platform.json`; see `assertCapabilityCoverage` below.
* What a game module adds — module-supplied, and written in core's words (D63, D75). Each
* item is something BOTH modules supply, each in its own game's way; the game's own list,
* in its own words, is on its page under `/modules/`, which `/features/` and the homepage
* link to. No `caps` here: they belong to a game's own items in `src/data/games/`, where
* `assertGameCoverage()` checks them.
*
* @type {CapabilityGroup}
*/
const gameIntelligence = {
id: 'game-intelligence',
title: 'Game intelligence',
const gameModule = {
id: 'game-module',
title: 'What a game module adds',
moduleSupplied: true,
summary:
'Supplied by the installed game module, not by the core site. Two modules exist, for ' +
'Ultima Online and for Rust; this is what the Ultima Online module publishes from a ' +
'live game server, and the Rust module brings its own set in its place.',
'Ultima Online and for Rust, and each adds its own set from a live game server — these ' +
'are the parts they share.',
items: [
{
label: 'Live server status',
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.',
'Whether each game server is up, who is on it, and how long ago the site last heard ' +
'from it. When the game is down the site is the thing that says so — it does not go ' +
'down with it.',
},
{
label: 'Economy and activity',
caps: ['shard'],
demoPath: '/uo/shard/activity',
label: 'A live feed of the world',
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.',
'What is happening in the game, as it happens. Every kind of event passes an ' +
'allowlist the module holds before it can reach a public page; staff read a second ' +
'stream carrying the rest.',
},
{
label: 'Character sheets',
caps: ['shard'],
label: 'Leaderboards',
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.',
'The numbers the game already keeps, published without anyone exporting a ' +
'spreadsheet on a Sunday — and kept by the site after the game has moved on.',
},
{
label: 'Points and loyalty boards',
caps: ['shard'],
demoPath: '/uo/leaderboards',
label: 'Maps of the world',
detail:
'The leaderboards the game already keeps, published without anyone exporting a ' +
'spreadsheet on a Sunday.',
'Where things are, drawn from the server rather than from someone else’s idea of the ' +
'game: one module maps what spawns where, the other shows what is moving now.',
},
{
label: 'Player-vendor marketplace',
caps: ['market', 'cliloc'],
demoPath: '/uo/market',
label: 'Teams, from the game',
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.",
'The groups players already form in the game arrive as Teams on the site, with the ' +
'forum, notifications and voice channel core attaches to one.',
},
{
label: 'Houses and IDOC decay',
caps: ['houses'],
demoPath: '/uo/houses',
label: 'Players’ own game accounts',
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.',
'A player links their game identity to their site account and sees their own side ' +
'of the game on it. Who may see another player at all is the operator’s choice.',
},
{
label: 'Spawn atlas',
caps: ['atlas'],
demoPath: '/uo/atlas',
label: 'Game steps for scheduled events',
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.',
'What an event may do inside the world comes from the module: core runs the ' +
'calendar, the budget and the undo, and the module brings the steps.',
},
],
};
@@ -372,7 +332,7 @@ const integration = {
{
label: 'Discord: slash commands, notifications, voice',
detail:
'A bot for the guild you already have. Commands answer from your site, ' +
'A bot for the Discord server you already have. Commands answer from your site, ' +
'notifications bridge into channels, and a Team can be granted a voice channel ' +
'that maintains its own membership.',
},
@@ -429,7 +389,7 @@ const infrastructure = {
export const capabilityGroups = [
community,
gameIntelligence,
gameModule,
administration,
integration,
infrastructure,
@@ -456,46 +416,11 @@ export function capabilityGroup(id) {
);
}
/**
* 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`.
/*
* The capability check that lived here — "Game intelligence" against the module's declared
* capabilities — moved to `assertGameCoverage()` in `games/index.mjs` in phase 17, with the
* lists it checks.
*/
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 games.uo.module.capabilities:\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`.
@@ -505,8 +430,9 @@ export function assertCapabilityCoverage(declared) {
* 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.
* Called from `/features/` for the same reason `assertGameCoverage` is called from the
* homepage: the build error should name the page that would have shipped wrong. It also
* reads each game's own list, which its page under `/modules/` renders with the detail too.
*/
export function assertDetailCoverage() {
const missing = [];
@@ -515,6 +441,11 @@ export function assertDetailCoverage() {
if (!item.detail?.trim()) missing.push(`${group.title} → ${item.label}`);
}
}
for (const game of games) {
for (const item of game.features) {
if (!item.detail?.trim()) missing.push(`${game.name} → ${item.label}`);
}
}
if (!missing.length) return;

View File

@@ -284,7 +284,7 @@ export const collected = [
scope: 'app',
title: 'Everything you read and post in the app',
body:
'Forum posts, Team activity, character and shard information, notification ' +
'Forum posts, Team activity, what the game module shows you, notification ' +
'preferences: all of it is a live read or write against the deployment. Apart from ' +
'the notification snapshot described in the next entry, nothing is cached for ' +
'offline use and nothing is duplicated anywhere else — the app with no signal is ' +
@@ -376,9 +376,12 @@ export const collected = [
body:
'A username, a password hash, an optional email address, the role, and — where the ' +
'operator has enabled it — a two-factor secret and single-use recovery codes. The ' +
'time and IP address of the last sign-in are stored on the account row.',
'time and IP address of the last sign-in are stored on the account row. A sign-in ' +
'identity linked to the account — Google, Discord, an OIDC provider or Steam — keeps ' +
'that provider’s id for the person; a Steam link also keeps the Steam display name ' +
'and the address of the Steam avatar. Linked identities are deleted with the account.',
retention: { summary: 'Set by the operator; nothing expires on its own' },
source: 'website server/db/schema.sql — users',
source: 'website server/db/schema.sql — users, user_identities',
},
{
id: 'deploy-sessions',
@@ -505,12 +508,23 @@ export const collected = [
scope: 'deployment',
title: 'Game data from the connected server',
body:
'Where a game module is installed, information about characters, guilds, houses and ' +
'the in-game economy flows from the game server to the site through the bridge. ' +
'Which of it is visible to the public is the operator’s decision, made in the ' +
'admin panel — the bridge itself forwards, and the site decides.',
retention: { summary: 'Operator-configured' },
source: 'docs/link/v7.md — the visibility framework',
'Where a game module is installed, what happens on the game server flows to the site ' +
'through the bridge, and the module keeps it. With Ultima Online that is characters ' +
'and their accounts, Teams, houses and the in-game economy. With Rust it is each ' +
'player’s Steam id and in-game name, their statistics, who is online, and the ' +
'server’s event record — which includes in-game chat, player reports and the IP ' +
'addresses on sign-in attempts and bans, readable by staff only. A linked Steam ' +
'account is kept against the site account and deleted with it. Which of any of this ' +
'the public sees is the operator’s decision, made in the admin panel — the bridge ' +
'forwards, and the site decides.',
retention: {
summary:
'Operator-configured. The Rust module keeps its event record 30 days, and its ' +
'sidecar keeps its own copy 14 days by default; statistics until the operator removes them',
},
source:
'docs/link/v7.md — the visibility framework; Module-Rust server/db/schema.sql, ' +
'server/catalogue.js, server/boot.js (EVENT_RETENTION_DAYS); Rust-Link sidecar/src/config.rs',
},
];

View File

@@ -30,6 +30,7 @@ export function footerColumns(brand) {
{ href: '/features/', label: 'Features' },
{ href: '/architecture/', label: 'Architecture' },
{ href: '/modules/', label: 'Modules' },
{ href: '/runicnpc/', label: 'RunicNPC' },
{ href: '/app/', label: 'Android app' },
],
},

104
src/data/games/index.mjs Normal file
View File

@@ -0,0 +1,104 @@
/**
* games/index.mjs — the games the platform supports, and the check that holds each one's
* feature list to its module's declared capabilities.
*
* PLAN.md §16, phase 17. Each game has a file of its own (`uo.mjs`, `rust.mjs`) written in
* that game's words, which only that game's pages render. This file is what the core pages
* read instead: the game's name and where its page is, which D63 lets any page say.
*
* The order is the order the games were supported in, and every list of games on the site
* follows it.
*/
import platform from '../platform.json' with { type: 'json' };
import * as uo from './uo.mjs';
import * as rust from './rust.mjs';
/**
* @typedef {object} Game
* @property {'uo'|'rust'} key A key of `platform.json`'s `games`.
* @property {string} name The game's own name, from `platform.json`.
* @property {string} href Its marketing page.
* @property {string} docs Its docs group.
* @property {string} release Its module's release, from `platform.json`.
* @property {string} blurb One sentence in core's words, for the core pages' game cards.
* @property {import('../capabilities.mjs').Capability[]} features
*/
/** @type {Game[]} */
export const games = [
{
key: 'uo',
name: platform.games.uo.name,
href: '/modules/uo/',
docs: '/docs/ultima-online/',
release: platform.releases['Module-uo'],
blurb:
'The first module, and the one the seam was cut around: the server’s live state, its ' +
'economy and its players’ own groups, extracted from the site without changing a URL.',
features: uo.features,
},
{
key: 'rust',
name: platform.games.rust.name,
href: '/modules/rust/',
docs: '/docs/rust/',
release: platform.releases['Module-Rust'],
blurb:
'Any number of servers, each with its own sidecar: a live feed, leaderboards that ' +
'outlast a reset, a live map, and permissions and events run from the site.',
features: [...rust.features, ...rust.staffFeatures],
},
];
for (const key of Object.keys(platform.games)) {
if (!games.some((game) => game.key === key)) {
throw new Error(`src/data/games/index.mjs has no entry for "${key}", which platform.json's games declares.`);
}
}
/** One game by key, or a build failure naming it. @param {string} key */
export function gameByKey(key) {
const game = games.find((candidate) => candidate.key === key);
if (game) return game;
throw new Error(`src/data/games/index.mjs has no game "${key}". Known: ${games.map((g) => g.key).join(', ')}.`);
}
/**
* Fails the build when a game's feature list and its module's declared capabilities
* disagree, in either direction.
*
* Called from the pages rather than a check script, for the reason phase 3 gave: the failure
* should reach whoever is editing the page, and an Astro build error names the component. It
* also means `astro build` alone cannot skip it.
*
* @param {string} key
*/
export function assertGameCoverage(key) {
const game = gameByKey(key);
const declared = platform.games[key].module.capabilities;
const claimed = new Set(game.features.flatMap((item) => item.caps || []));
const unlisted = declared.filter((cap) => !claimed.has(cap));
const invented = [...claimed].filter((cap) => !declared.includes(cap));
if (!unlisted.length && !invented.length) return;
const lines = [];
if (unlisted.length) {
lines.push(
`the module declares ${unlisted.map((c) => `"${c}"`).join(', ')}, which no feature on ` +
`its page claims — the site is under-selling what it can show.`,
);
}
if (invented.length) {
lines.push(
`the page 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/games/${key}.mjs disagrees with platform.json's games.${key}.module.capabilities:\n` +
lines.map((line) => ` - ${line}`).join('\n') +
`\n\nUpdate the features in src/data/games/${key}.mjs, or the JSON if the module itself changed.\n`,
);
}

122
src/data/games/rust.mjs Normal file
View File

@@ -0,0 +1,122 @@
/**
* games/rust.mjs — what the Rust module adds, in Rust's own words.
*
* PLAN.md §16, phase 17. `/modules/rust/` renders this; the core pages describe what a game
* module adds in core's words and link there (D75). It is the Rust half of the coverage
* assertion phase 13 moved here: `caps` is the contract with `platform.json`'s
* `games.rust.module.capabilities`, re-read by `checkFacts.mjs` from `Module-Rust`'s own
* `module.json`, and `assertGameCoverage()` in `index.mjs` holds the two together.
*
* Every item was written from the Rust docs group, which phase 15 walked on the walk site
* and checked against the module's source (D36) — this file says less than those pages and
* nothing they do not. `rust` names the module itself rather than a surface (Module-Rust's
* README: "the single gate a whole navigation group hangs on"), so it rides on the server
* list, which is the page that gate opens.
*
* The staff items carry no `caps`: a capability is what a client feature-detects on, and
* the admin screens are not something any client detects.
*
* No `demoPath` anywhere: the demo instance planned in §15 runs Ultima Online.
*/
/** @typedef {import('../capabilities.mjs').Capability} Capability */
/** For visitors and players. @type {Capability[]} */
export const features = [
{
label: 'Every server, live',
caps: ['rust', 'servers'],
detail:
'Every Rust server the community runs, as it last reported itself: up or down, who is ' +
'on, which map, and when the next wipe is. One sidecar serves one server, so a ' +
'community with six servers sees six — and a server that is down says so while the ' +
'site carries on.',
},
{
label: 'The kill feed',
caps: ['killfeed'],
detail:
'Kills, chat, comings and goings, RunicNPC bosses, and the world itself — cargo, ' +
'helicopters, airdrops — as they happen. Every kind passes a default-deny allowlist ' +
'before it reaches a public page; staff read the rest, connection records and player ' +
'reports included.',
},
{
label: 'Leaderboards, per wipe',
caps: ['leaderboard'],
detail:
'Kills, deaths, NPC kills, structures and time played, for one wipe or all-time — with ' +
'the chat titles players have earned. The record survives the wipe: the game forgets, ' +
'and the site does not.',
},
{
label: 'Who is online',
caps: ['presence'],
detail:
'Who is on right now, and who is asleep. Nothing names a player to the public by ' +
'default: how far a name reaches — staff, signed-in players, everyone — is the ' +
'operator’s choice, made per surface.',
},
{
label: 'Wipes',
caps: ['wipes'],
detail:
'Every wipe the site has seen, and the next one once the server has a wipe schedule — ' +
'the monthly forced wipe or a rule of your own.',
},
{
label: 'The live map',
caps: ['map'],
detail:
'The server’s own map picture with layers on it: monuments and world events, the ' +
'zones and NPCs your events placed, players, bases as positions only, and a player’s ' +
'own clan. Each layer has its own audience, and positions are never stored.',
},
{
label: 'Steam accounts, linked',
caps: ['identity'],
detail:
'A player joins their Steam account to their site account with a code from /link in ' +
'game, or by signing in with Steam. Staff can undo a wrong link. Rewards go to Steam ' +
'ids, so a player earns them before they ever link.',
},
{
label: 'Clans are Teams',
detail:
'Each clan on a server is a Team on the site: a page, a feed for its members and ' +
'core’s Team notifications. Who may see a clan’s roster is set alongside who may see ' +
'a name at all.',
},
];
/** For staff, one per admin screen the module adds. @type {Capability[]} */
export const staffFeatures = [
{
label: 'Permissions and groups, from the site',
detail:
'The site holds every server’s Oxide or Carbon groups and permissions and pushes them ' +
'in. One vip on the site is one vip in game, on the servers it covers, and a change ' +
'made in game is noticed rather than silently kept.',
},
{
label: 'Mod config, from the site',
detail:
'Edit your plugins’ config files on the game host from the admin panel, with a backup ' +
'first, a watched reload, and an automatic undo if the plugin does not come back.',
},
{
label: 'Events, zones and rewards',
detail:
'Rust steps for core’s scheduled events: open a ZoneManager zone with a dome, place ' +
'NPCs, count who is present, reward a kit, pay coins, start a raid base, or run ' +
'console commands. Every step arrives switched off, on the server as well as on the ' +
'site.',
},
{
label: 'Optional plugins, recognised',
detail:
'BetterChat, Economics, Backpacks, TruePVE, Dynamic PVP, Dangerous Treasures, Guarded ' +
'Crate, RaidableBases and more each add something where they are installed: coins, ' +
'titles, map layers, event steps. None is required.',
},
];

117
src/data/games/uo.mjs Normal file
View File

@@ -0,0 +1,117 @@
/**
* games/uo.mjs — what the Ultima Online module adds, in Ultima Online's own words.
*
* PLAN.md §16, phase 17. Until this phase these items were the "Game intelligence" group of
* `capabilities.mjs`, which the homepage and `/features/` render — core pages, which D63
* says may name the games but not use their vocabulary. They moved here, to a file that
* belongs to one game, and `/modules/uo/` renders them. The core pages now describe what a
* game module adds in core's words and link here (D75).
*
* `caps` is the contract with `platform.json`'s `games.uo.module.capabilities`, which
* `checkFacts.mjs` re-reads from the module's own `module.json`. `assertGameCoverage()` in
* `index.mjs` fails the build when the two drift apart, in either direction — so the day the
* module gains a capability, the JSON goes red, and the day the JSON moves, this page does.
*
* Slugs are NOT one-per-item: `shard` is the source of four separate user-facing features,
* 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). Coverage, not a bijection.
*
* `demoPath` is a stable public route on the module's own pages, deep-linked into a demo
* when one is mounted (D25). Paths are read from `module-uo`'s `client/src/entry.jsx`.
*/
/** @typedef {import('../capabilities.mjs').Capability} Capability */
/** @type {Capability[]} */
export const features = [
{
label: 'Live shard status',
caps: ['shard'],
demoPath: '/uo/shard',
detail:
'Whether the shard 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 shard 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 shard and what is on it, searchable without logging in to ' +
'the game. Item names arrive from the world as cliloc ids and are resolved against the ' +
"shard'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 shard. Each guild is also 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.',
},
];
/**
* The screenshots `/modules/uo/` shows beside the list, by id from `screens.mjs`. Every one
* is tagged `game: 'uo'` there, so it prints the game's name beside it (D67) wherever it is
* shown. They lived on `/features/` until phase 17.
*/
export const screens = ['shard-status', 'marketplace', 'spawn-atlas', 'guilds', 'houses'];

View File

@@ -40,6 +40,15 @@
* the screenshots show the platform, not somebody's private shard.
*
* Nothing here is a mock-up, and nothing here was drawn.
*
* ---------------------------------------------------------------------------------------
* A SCREENSHOT BELONGS TO ITS GAME (D67)
* ---------------------------------------------------------------------------------------
* A capture of one game's screen shows that game's words in its pixels, and its alt text has
* to describe the image honestly. So an entry may carry `game`: its alt and caption may then
* use that game's words, and `Screenshot.astro` prints the game's name beside it wherever it
* appears, marking the figure `data-game` for `checkTerms.mjs --built`. A core screen — the
* news, the dashboard, the modules list — carries no tag.
*/
/** Web capture geometry. The capture script and the check both read these. */
@@ -63,6 +72,7 @@ export const PHONE = { width: 1440, height: 3088 };
* @property {string} alt What the screen shows, for somebody who cannot see it.
* @property {string} caption The sentence printed under the figure.
* @property {'web'|'phone'} family Which geometry the file follows.
* @property {'uo'|'rust'} [game] The game whose screen this is (D67), a key of `platform.json`'s `games`.
*/
/** @type {Screen[]} */
@@ -70,6 +80,7 @@ export const screens = [
// ── The product's public surfaces ───────────────────────────────────────────────────
{
id: 'shard-status',
game: 'uo',
route: '/uo/shard',
admin: false,
alt: 'The shard page of a Runic Gateway site, showing the shard online, its gold supply, the state of the shard link, and one player online in Britain.',
@@ -79,6 +90,7 @@ export const screens = [
},
{
id: 'marketplace',
game: 'uo',
route: '/uo/market',
admin: false,
scrollY: 470,
@@ -89,6 +101,7 @@ export const screens = [
},
{
id: 'spawn-atlas',
game: 'uo',
route: '/uo/atlas',
admin: false,
scrollY: 466,
@@ -99,6 +112,7 @@ export const screens = [
},
{
id: 'guilds',
game: 'uo',
route: '/uo/guilds',
admin: false,
alt: 'The guilds page, listing each guild on the shard with its abbreviation, how many members are online, and its leader.',
@@ -108,6 +122,7 @@ export const screens = [
},
{
id: 'houses',
game: 'uo',
route: '/uo/houses',
admin: false,
alt: 'The houses page, listing homes that have entered their final decay stage with their owner and location.',
@@ -145,6 +160,7 @@ export const screens = [
},
{
id: 'admin-shard',
game: 'uo',
route: '/admin/uo/link',
admin: true,
alt: 'The shard connection screen, showing the sidecar connected, the websocket ingest live, and fields for the sidecar URL, token and protocol version.',
@@ -154,6 +170,7 @@ export const screens = [
},
{
id: 'admin-client-files',
game: 'uo',
route: '/admin/uo/files',
admin: true,
alt: 'The client files screen, showing the creature portraits section: 1,095 pictures held of 1,095 catalogued, 746 of 800 creatures matched, the last import and the extractor version, above Update and Re-import everything buttons.',
@@ -195,6 +212,7 @@ export const screens = [
},
{
id: 'app-shard',
game: 'uo',
route: '/shard',
admin: false,
alt: "The app's shard screen, showing the shard online with one player and its gold supply, links to champion spawns, guilds, governors and falling houses, and a live activity feed.",
@@ -204,6 +222,7 @@ export const screens = [
},
{
id: 'app-market',
game: 'uo',
route: '/market',
admin: false,
alt: "The app's marketplace, listing weapons for sale with their price, the shop selling them and where it stands.",

View File

@@ -54,8 +54,8 @@ import { playPolicy } from '../data/beta.mjs';
*/
const title = 'The Android app';
const description =
'A native Android client for a Runic Gateway deployment: the shard, the site and your ' +
'account, themed by whichever community you point it at.';
'A native Android client for a Runic Gateway deployment: the game, the site and your ' +
'account, themed by whichever community you point it at — Ultima Online or Rust.';
const apk = platform.androidApk;
const release = platform.releases['Android-app'];

View File

@@ -3,13 +3,10 @@ import Base from '../layouts/Base.astro';
import PageHeader from '../components/PageHeader.astro';
import NotBuilt from '../components/NotBuilt.astro';
import Screenshot from '../components/Screenshot.astro';
import SupportedGames from '../components/SupportedGames.astro';
import platform from '../data/platform.json';
import {
capabilityGroups,
assertCapabilityCoverage,
assertDetailCoverage,
} from '../data/capabilities.mjs';
import { capabilityGroups, assertDetailCoverage } from '../data/capabilities.mjs';
import { games, assertGameCoverage } from '../data/games/index.mjs';
/**
* `/features/` — PLAN.md §13 phase 4, built to D20.
@@ -24,8 +21,8 @@ import {
* 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.
* `assertGameCoverage` is the module contract the homepage and each game page also run —
* repeated here deliberately, since any 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.
*
@@ -53,7 +50,7 @@ import {
* 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.games.uo.module.capabilities);
for (const game of games) assertGameCoverage(game.key);
assertDetailCoverage();
/**
@@ -66,19 +63,21 @@ assertDetailCoverage();
* the container runs and who owns the database, and a picture of a web page says nothing
* true about either.
*
* The module-supplied group has none since phase 17: its screenshots were of one game's
* screens, and they moved with that game's list to its page under `/modules/` (D75).
*
* `Screenshot` throws on an id that `screens.mjs` does not declare, so a typo here fails the
* build rather than rendering a broken image.
*/
const groupScreens: Record<string, string[]> = {
community: ['news'],
'game-intelligence': ['marketplace', 'spawn-atlas', 'guilds', 'houses'],
administration: ['admin-dashboard', 'admin-users'],
integration: ['admin-modules'],
};
const title = 'Features';
const description =
'What a Runic Gateway deployment does — core, and what the installed game module adds.';
'What a Runic Gateway deployment does — core, and what the installed game module adds, for Ultima Online or Rust.';
---
<Base title={title} description={description}>
@@ -86,16 +85,17 @@ const description =
<p>
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 <a href="/modules/"
not know what game you run, and everything that does arrives as an <a href="/modules/"
>installable module</a
>.
</p>
<p>
Two modules exist today, for Ultima Online and for Rust, and a deployment runs one of
them. The second group below is what the Ultima Online module adds; the Rust module
brings its own set in its place. On a deployment with no module, that group is simply
absent and the other four are unchanged.
them. The second group below is what both of them add, each in its own game’s way;
each game’s own list is on its page. On a deployment with no module, that group is
simply absent and the other four are unchanged.
</p>
<SupportedGames class="features__games" />
</PageHeader>
{
@@ -110,6 +110,16 @@ const description =
<p class="prose group__summary">{group.summary}</p>
{group.moduleSupplied && (
<div class="group__games">
{games.map((game) => (
<a class="btn btn--ghost" href={game.href}>
What the {game.name} module adds
</a>
))}
</div>
)}
<ul class="group__items">
{group.items.map((item) => (
<li class="panel group__item">
@@ -175,6 +185,17 @@ const description =
color: var(--muted);
}
.group__games {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
margin-top: 1.25rem;
}
.features__games {
margin-top: 1.5rem;
}
.group__items {
display: grid;
gap: 1rem;

View File

@@ -43,7 +43,7 @@ const integrations = [
{
title: 'Slash commands',
body:
'Commands registered with your guild that answer from your site — so the thing ' +
'Commands registered with your Discord server 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.',
},
@@ -60,13 +60,13 @@ const integrations = [
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.',
'panel reports how many roles your Discord server 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 ' +
'profile — so a Team with a voice channel is a Team anyone in your Discord server can ' +
'see the membership of. That was a deliberate trade for a limit that counts per server rather ' +
'than per channel, and it is the right one for most communities, but it is not private.',
},
{

View File

@@ -2,26 +2,27 @@
import Base from '../layouts/Base.astro';
import PageHeader from '../components/PageHeader.astro';
import NotBuilt from '../components/NotBuilt.astro';
import SupportedGames from '../components/SupportedGames.astro';
import platform from '../data/platform.json';
import { capabilityGroup } from '../data/capabilities.mjs';
import { games } from '../data/games/index.mjs';
/**
* `/modules/` — PLAN.md §13 phase 4.
* `/modules/` — PLAN.md §13 phase 4, restructured in §16 phase 17.
*
* §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.
* §10 gave this page four jobs: what a module is, a 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
* TWO GAMES, EACH ON ITS OWN PAGE
* ---------------------------------------------------------------------------------------
* 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 worked example was the Ultima Online module, listed in that game's words. Since phase
* 17 there are two modules, and D63 keeps a game's vocabulary on its own pages, so this page
* is core: a card per game, in core's words, from `games/index.mjs`, each linking to
* `/modules/uo/` or `/modules/rust/`, where the game's real feature list is (D75). What the
* modules share — a sidecar, their own tables, their own releases — stays here, said once.
*
* ---------------------------------------------------------------------------------------
* THE DRAFT CHIP IS A DECISION, NOT A DISCLAIMER
@@ -34,14 +35,12 @@ import { capabilityGroup } from '../data/capabilities.mjs';
*/
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.';
'What a module is, the two game modules that exist — Ultima Online and Rust — 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 = [
{
@@ -81,8 +80,10 @@ const installPaths = [
<p>
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.
has — which is the only version of this claim worth making. Rust followed, built
against the same seam.
</p>
<SupportedGames class="mod__supported" />
</PageHeader>
<section class="page section mod" id="what">
@@ -146,60 +147,65 @@ const installPaths = [
</ol>
</section>
<section class="page section mod" id="module-uo">
<div class="mod__head">
<p class="eyebrow">The worked example</p>
<h2>module-uo</h2>
<span class="chip chip--version">{platform.releases['Module-uo']}</span>
</div>
<section class="page section mod" id="games">
<p class="eyebrow">The game modules</p>
<h2>Two games, one module each</h2>
<p class="prose mod__lede">
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
<a href="/architecture/">the architecture page</a> is real, because the code on the far
side of it was moved there rather than designed there.
Both were built inside this project, and a deployment runs one of them — the site
refuses to start a second. Each has a page of its own, in its own game's words, and a
documentation group that starts from installing it.
</p>
<div class="mod__example">
<div class="panel mod__caps">
<h3>What it publishes</h3>
<ul>
{gameIntelligence.items.map((item) => <li>{item.label}</li>)}
</ul>
<p class="mod__caps-note">
The same list <a href="/features/">features</a> expands, read from one file that is
checked against the module's own manifest on every build.
</p>
</div>
<ul class="mod__games">
{
games.map((game) => (
<li class="panel mod__game">
<div class="mod__head">
<h3>{game.name}</h3>
<span class="chip chip--version">
{game.release}
</span>
</div>
<p>{game.blurb}</p>
<div class="mod__game-links">
<a class="btn btn--primary" href={game.href}>
What the {game.name} module adds
</a>
<a class="btn btn--ghost" href={game.docs}>
Its documentation
</a>
</div>
</li>
))
}
</ul>
<div class="mod__facts">
<section>
<h3>It connects to a real server</h3>
<p>
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.
</p>
</section>
<section>
<h3>It owns its own tables</h3>
<p>
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.
</p>
</section>
<section>
<h3>It is a separate release</h3>
<p>
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.
</p>
</section>
</div>
<div class="mod__facts mod__facts--shared">
<section>
<h3>Each connects to a real server</h3>
<p>
A module talks to the sidecar beside your game server, not to the game. You deploy
that side with the installer and give the admin panel its address and token;
nothing here requires the game to exist, and with no server configured the site
renders normally and shows it offline.
</p>
</section>
<section>
<h3>Each owns its own tables</h3>
<p>
Its schema is applied by the site on every boot and its data is its own. A module
declares which versions of the core interface it speaks — the site runs
{' '}{platform.moduleApi} — and refuses to load against one it does not.
</p>
</section>
<section>
<h3>Each is a separate release</h3>
<p>
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.
</p>
</section>
</div>
</section>
@@ -239,9 +245,6 @@ const installPaths = [
<a class="btn btn--ghost" href={`${docs}/website/MODULE_API.md`} rel="noopener noreferrer">
The module contract
</a>
<a class="btn btn--ghost" href={`${docs}/modules/uo/README.md`} rel="noopener noreferrer">
module-uo in depth
</a>
</div>
</section>
@@ -287,7 +290,6 @@ const installPaths = [
.mod__grid h3,
.mod__paths h3,
.mod__caps h3,
.mod__facts h3,
.mod__draft h3 {
margin: 0 0 0.5rem;
@@ -317,55 +319,53 @@ const installPaths = [
font-style: italic;
}
.mod__example {
.mod__games {
display: grid;
gap: 1rem;
margin-top: 2rem;
grid-template-columns: minmax(0, 20rem) minmax(0, 1fr);
align-items: start;
}
.mod__caps ul {
margin: 0;
margin: 2rem 0 0;
padding: 0;
list-style: none;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 24rem), 1fr));
}
.mod__game {
display: flex;
flex-direction: column;
}
.mod__game h3 {
margin: 0;
color: var(--gold);
font-size: 1.25rem;
}
.mod__game p {
margin: 0.75rem 0 0;
color: var(--muted);
font-size: 0.94rem;
}
.mod__caps li {
position: relative;
padding-left: 1.1rem;
color: var(--text);
.mod__game-links {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
margin-top: auto;
padding-top: 1.25rem;
}
.mod__caps li + li {
margin-top: 0.3rem;
.mod__supported {
margin-top: 1.5rem;
}
/* The same drawn marker the homepage's capability lists use, so a reader who has
seen this list once recognises it as the same list. */
.mod__caps li::before {
content: '';
position: absolute;
left: 0;
top: 0.62em;
width: 5px;
height: 5px;
border-radius: var(--radius-pill);
background: var(--portal);
opacity: 0.75;
.mod__facts--shared {
display: grid;
gap: 1.4rem;
margin-top: 2.25rem;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 17rem), 1fr));
}
.mod__caps-note {
margin: 1rem 0 0;
padding-top: 0.85rem;
border-top: 1px solid var(--line-soft);
color: var(--dim);
font-size: 0.85rem;
}
.mod__facts section + section {
margin-top: 1.4rem;
.mod__facts--shared section + section {
margin-top: 0;
}
.mod__facts p {
@@ -395,10 +395,4 @@ const installPaths = [
gap: 0.7rem;
margin-top: 1.75rem;
}
@media (max-width: 860px) {
.mod__example {
grid-template-columns: minmax(0, 1fr);
}
}
</style>

View File

@@ -0,0 +1,199 @@
---
import Base from '../../layouts/Base.astro';
import PageHeader from '../../components/PageHeader.astro';
import GameFeatures from '../../components/GameFeatures.astro';
import platform from '../../data/platform.json';
import { features, staffFeatures } from '../../data/games/rust.mjs';
import { assertGameCoverage } from '../../data/games/index.mjs';
/**
* `/modules/rust/` — PLAN.md §16, phase 17.
*
* The Rust module's own page, in Rust's words (D63; `GAME_ROUTES` in `checkTerms.mjs`). The
* lists are `games/rust.mjs`, written from the Rust docs group that phase 15 walked, and the
* call below holds them to the capabilities `Module-Rust` declares — the page-side half of
* the coverage assertion phase 13 moved here.
*
* It carries the RunicNPC card D61 asked for, pointing at the standalone `/runicnpc/` page.
*
* No screenshots yet, and no slots for them: D65 brings the Rust captures in phase 18, from
* the rigs, and D4 rules out a placeholder in the meantime.
*/
assertGameCoverage('rust');
const rust = platform.games.rust;
const pieces = [
{
role: 'The module',
name: rust.module.repo,
version: platform.releases['Module-Rust'],
what: 'Every Rust screen on the site, installed from Admin → Modules.',
},
{
role: 'The sidecar',
name: 'rust-link',
version: rust.bundle.sidecar,
what: 'One beside each server, and the only part of the bridge that listens on a network.',
},
{
role: 'The bridge plugin',
name: 'RunicGateway.cs',
version: rust.bundle.plugin,
what: 'One file that runs unchanged on Oxide and Carbon, plus its optional helpers.',
},
{
role: 'NPCs',
name: 'RunicNPC',
version: rust.bundle.runicnpc,
what: 'Our NPC plugin, which the bridge drives for every NPC an event places.',
},
];
const title = 'The Rust module';
const description =
'What the Rust module adds to a Runic Gateway site: every server live, the kill feed, ' +
'leaderboards per wipe, the live map, Steam linking, and permissions, mod config and events run from the site.';
---
<Base title={title} description={description}>
<PageHeader eyebrow="Game module" title="Rust">
<p>
The <code>rust</code> module turns a Runic Gateway site into a Rust community’s site. It
follows any number of servers, each running Oxide or Carbon with a sidecar of its own,
and keeps what each one tells it after the wipe has taken it from the game.
</p>
<p>
The server side is installed as one bundle — <strong>{rust.bundle.tag}</strong>,
speaking protocol {rust.protocol} — with the installer, from a Pterodactyl egg, or by
hand. It needs Oxide {rust.bundle.oxideMin} or Carbon {rust.bundle.carbonMin} or newer,
and the uMod plugins {rust.bundle.requiresPlugins.join(' and ')}.
</p>
</PageHeader>
<section class="page section pieces" aria-labelledby="pieces-heading">
<h2 id="pieces-heading">The pieces</h2>
<ul class="pieces__list">
{
pieces.map((piece) => (
<li class="panel">
<p class="pieces__role">{piece.role}</p>
<p class="pieces__name">
<code>{piece.name}</code>
<span class="chip chip--version">{piece.version}</span>
</p>
<p class="pieces__what">{piece.what}</p>
</li>
))
}
</ul>
</section>
<GameFeatures
title="What players see"
lede="Every public surface is the operator's to widen: nothing names a player to the public by default."
items={features}
/>
<GameFeatures
title="What staff run from the site"
lede="Seven admin screens, and a Rust section on each user's page."
items={staffFeatures}
/>
<section class="page section npc">
<div class="panel npc__card">
<p class="eyebrow">Also from this project</p>
<h2>RunicNPC</h2>
<p>
Our own NPC plugin for Rust: NPCs where you want them, with the kit, health, aim and
behaviour you give them — roamers, sentries, guards, quest-givers and bosses with
phases. On a Runic Gateway server it is required and the site authors its profiles;
on any other Rust server it runs on its own, with <code>/rnpc</code> commands and an
API for other plugins.
</p>
<div class="npc__links">
<a class="btn btn--primary" href="/runicnpc/">About RunicNPC</a>
<a class="btn btn--ghost" href="/docs/rust/runicnpc/">Its documentation</a>
</div>
</div>
</section>
<section class="page section next">
<h2>Setting it up</h2>
<p class="prose">
The documentation’s Rust group starts from installing the bridge and covers every admin
screen the module adds, with the reference for each file it reads.
</p>
<div class="next__links">
<a class="btn btn--primary" href="/docs/rust/">The Rust docs</a>
<a class="btn btn--ghost" href="/modules/">All modules</a>
</div>
</section>
</Base>
<style>
.pieces h2,
.next h2 {
margin: 0 0 0.75rem;
font-size: clamp(1.5rem, 3vw, 1.95rem);
}
.pieces__list {
display: grid;
gap: 1rem;
margin: 1.25rem 0 0;
padding: 0;
list-style: none;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 15rem), 1fr));
}
.pieces__role {
margin: 0;
color: var(--dim);
font-size: 0.78rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.pieces__name {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.6rem;
margin: 0.5rem 0 0;
color: var(--gold);
}
.pieces__what {
margin: 0.6rem 0 0;
color: var(--muted);
font-size: 0.94rem;
}
.npc__card h2 {
margin: 0;
color: var(--gold);
font-size: clamp(1.4rem, 2.8vw, 1.8rem);
}
.npc__card p:not(.eyebrow) {
margin: 0.75rem 0 0;
max-width: var(--measure);
color: var(--muted);
}
.npc__links,
.next__links {
display: flex;
flex-wrap: wrap;
gap: 0.7rem;
margin-top: 1.5rem;
}
.next p {
margin: 0;
color: var(--muted);
}
</style>

165
src/pages/modules/uo.astro Normal file
View File

@@ -0,0 +1,165 @@
---
import Base from '../../layouts/Base.astro';
import PageHeader from '../../components/PageHeader.astro';
import GameFeatures from '../../components/GameFeatures.astro';
import Screenshot from '../../components/Screenshot.astro';
import platform from '../../data/platform.json';
import { features, screens } from '../../data/games/uo.mjs';
import { assertGameCoverage } from '../../data/games/index.mjs';
/**
* `/modules/uo/` — PLAN.md §16, phase 17.
*
* The Ultima Online module's own page, and so one of the few marketing pages that may use
* that game's words (D63; `GAME_ROUTES` in `checkTerms.mjs`). The feature list is
* `games/uo.mjs`, held to the module's declared capabilities by the call below, and the
* screenshots are the ones `/features/` showed until this phase — every one tagged with its
* game in `screens.mjs` (D67).
*
* Versions are read from `platform.json`, which `checkFacts.mjs` holds to the module's
* release, the installer's bundle and both halves of the bridge.
*/
assertGameCoverage('uo');
const uo = platform.games.uo;
const gitea = `${platform.gitea.base}/${platform.gitea.org}`;
const docs = `${gitea}/docs/src/branch/main`;
const pieces = [
{
role: 'The module',
name: uo.module.repo,
version: platform.releases['Module-uo'],
what: 'Every Ultima Online screen on the site, installed from Admin → Modules.',
},
{
role: 'The sidecar',
name: 'uo-link',
version: uo.bundle.sidecar,
what: 'Beside the shard, and the only part of the bridge that listens on a network.',
},
{
role: 'The bridge plugin',
name: uo.pluginRepo,
version: uo.bundle.overlay,
what: `An overlay compiled by ServUO at boot, on ServUO ${uo.bundle.servuoMin} or newer.`,
},
];
const title = 'The Ultima Online module';
const description =
'What the Ultima Online module adds to a Runic Gateway site: live shard status, the ' +
'player-vendor marketplace, the spawn atlas, guilds, houses and more, from a running ServUO shard.';
---
<Base title={title} description={description}>
<PageHeader eyebrow="Game module" title="Ultima Online">
<p>
The <code>uo</code> module turns a Runic Gateway site into an Ultima Online shard’s
site, fed live from a running ServUO shard. It was the first module, and the one the
seam was cut around: this support was extracted out of the site, and every URL it had
before the move it still has.
</p>
<p>
The shard side is installed with the installer as one bundle —
{' '}<strong>{uo.bundle.tag}</strong>, speaking protocol {uo.protocol} — and the site
side is a paste in the admin panel.
</p>
</PageHeader>
<section class="page section pieces" aria-labelledby="pieces-heading">
<h2 id="pieces-heading">The pieces</h2>
<ul class="pieces__list">
{
pieces.map((piece) => (
<li class="panel">
<p class="pieces__role">{piece.role}</p>
<p class="pieces__name">
<code>{piece.name}</code>
<span class="chip chip--version">{piece.version}</span>
</p>
<p class="pieces__what">{piece.what}</p>
</li>
))
}
</ul>
</section>
<GameFeatures
title="What it adds"
lede="Everything here is published from the live shard, and every public surface passes the visibility settings first — each one is the operator's to switch on."
items={features}
/>
<section class="page section shots" aria-label="Screenshots">
{screens.map((id) => <Screenshot id={id} />)}
</section>
<section class="page section next">
<h2>Setting it up</h2>
<p class="prose">
The documentation’s Ultima Online group starts from connecting a ServUO shard and ends
at the reference for every file the bridge reads.
</p>
<div class="next__links">
<a class="btn btn--primary" href="/docs/ultima-online/">The Ultima Online docs</a>
<a class="btn btn--ghost" href={`${docs}/modules/uo/README.md`} rel="noopener noreferrer">
The module in depth
</a>
<a class="btn btn--ghost" href="/modules/">All modules</a>
</div>
</section>
</Base>
<style>
.pieces h2,
.next h2 {
margin: 0 0 0.75rem;
font-size: clamp(1.5rem, 3vw, 1.95rem);
}
.pieces__list {
display: grid;
gap: 1rem;
margin: 1.25rem 0 0;
padding: 0;
list-style: none;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 17rem), 1fr));
}
.pieces__role {
margin: 0;
color: var(--dim);
font-size: 0.78rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.pieces__name {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.6rem;
margin: 0.5rem 0 0;
color: var(--gold);
}
.pieces__what {
margin: 0.6rem 0 0;
color: var(--muted);
font-size: 0.94rem;
}
.next p {
margin: 0;
color: var(--muted);
}
.next__links {
display: flex;
flex-wrap: wrap;
gap: 0.7rem;
margin-top: 1.5rem;
}
</style>

238
src/pages/runicnpc.astro Normal file
View File

@@ -0,0 +1,238 @@
---
import Base from '../layouts/Base.astro';
import PageHeader from '../components/PageHeader.astro';
import platform from '../data/platform.json';
import { runicnpcCalls, runicnpcHooks } from '../data/runicnpc.mjs';
/**
* `/runicnpc/` — PLAN.md §16, D61, built in phase 17.
*
* The standalone page for RunicNPC, written for the owner of a Rust server who runs no
* Runic Gateway site at all and should be able to find and use the plugin without first
* learning what the platform is. It is in the header (D78) for the same reason. It may use
* Rust's words: it is one of the Rust routes in `checkTerms.mjs`.
*
* It says less than the docs subgroup (`/docs/rust/runicnpc/`), which phase 16 walked and
* drift-checked, and nothing that subgroup does not. Every number is read: the release and
* the API version and framework floors from `platform.json`, which `checkFacts.mjs` holds to
* `runicnpc-rust`'s own `plugin.toml`, and the counts of calls and hooks from
* `runicnpc.mjs`, which `checkReference.mjs` holds to `docs/runicnpc/API.md`.
*/
const npc = platform.runicnpc;
const release = platform.releases['runicnpc-rust'];
const repo = `${platform.gitea.base}/${platform.gitea.org}/runicnpc-rust`;
const calls = Object.keys(runicnpcCalls).length;
const hooks = Object.keys(runicnpcHooks).length;
const roles = [
{ name: 'Roamer', does: 'Moves, chases and fights.' },
{ name: 'Sentry', does: 'Stands still and shoots.' },
{ name: 'Guard', does: 'Holds its spot, chases within its leash, and walks back.' },
{
name: 'Passive',
does: 'Never fights. Pressing E says a line or opens a window — a quest-giver, a quartermaster.',
},
];
const title = 'RunicNPC';
const description =
'An NPC plugin for Rust on Oxide and Carbon: roamers, sentries, guards, quest-givers and ' +
'bosses with phases, placed in game or from a site, with an API for other plugins. Runs on any server.';
---
<Base title={title} description={description}>
<PageHeader eyebrow="A Rust plugin from this project" title="RunicNPC">
<p>
NPCs where you want them, with the kit, health, aim, ranges and behaviour you give them,
brought back after they die. One file, <code>RunicNPC.cs</code>, compiled by Oxide or
Carbon from source like any uMod plugin — and it runs on any Rust server, whether or not
a Runic Gateway site is anywhere near it.
</p>
</PageHeader>
<section class="page section facts" aria-label="At a glance">
<dl class="facts__list">
<div class="panel">
<dt>Release</dt>
<dd><span class="chip chip--version">{release}</span></dd>
</div>
<div class="panel">
<dt>Runs on</dt>
<dd>Oxide {npc.oxideMin} or Carbon {npc.carbonMin}, or newer</dd>
</div>
<div class="panel">
<dt>Needs</dt>
<dd>{npc.requiresPlugins.join(' and ')}. ZoneManager is optional.</dd>
</div>
<div class="panel">
<dt>API version</dt>
<dd>{npc.api} — {calls} calls and {hooks} hooks</dd>
</div>
</dl>
</section>
<section class="page section npc">
<h2>What it does</h2>
<ul class="npc__roles">
{
roles.map((role) => (
<li class="panel">
<h3>{role.name}</h3>
<p>{role.does}</p>
</li>
))
}
</ul>
<div class="npc__grid">
<section>
<h3>Bosses</h3>
<p>
Any role but passive can be a boss: a health bar at the top of the screen, phases as
its health falls, lines said in game, and loot of its own.
</p>
</section>
<section>
<h3>Placements and routes</h3>
<p>
A placement is a spot where a profile’s NPCs stand and come back after they die. It
survives a restart; the NPCs themselves are never saved, and each boot spawns fresh ones. A
route is a line of points you record in game for an NPC to walk.
</p>
</section>
<section>
<h3>Factions</h3>
<p>
With no settings an NPC fights players only. Profiles can be put on sides, turned on
Rust’s scientists or its animals, or allied to a clan, a team or one player.
</p>
</section>
<section>
<h3>A cost it tells you</h3>
<p>
What NPCs cost a server was measured on both frameworks, not guessed, and the
warning saying so appears wherever NPCs are added. Caps are an operator’s setting.
</p>
</section>
</div>
</section>
<section class="page section ways">
<h2>Two ways to run it</h2>
<div class="ways__grid">
<div class="panel">
<h3>On its own</h3>
<p>
Place NPCs in game with <code>/rnpc</code>, edit their profiles at the console, and
let other plugins drive it through its API. Nothing else is needed. It makes no
network call, downloads nothing and scans nothing at boot.
</p>
</div>
<div class="panel">
<h3>On a Runic Gateway server</h3>
<p>
It is required there, and installed with the bridge from the same bundle. The site
authors the profiles and the faction table and pushes them to each server, places
NPCs from its live map, and runs events that place them and wait on them dying
or on a boss’s health.
</p>
</div>
</div>
</section>
<section class="page section next">
<h2>Get it</h2>
<p class="prose">
Releases carry the plugin file. The documentation covers installing it by hand, every
command, every profile field, and the API in full for plugin authors.
</p>
<div class="next__links">
<a class="btn btn--primary" href={`${repo}/releases`} rel="noopener noreferrer">
Download {release}
</a>
<a class="btn btn--ghost" href="/docs/rust/runicnpc/install/">Install it</a>
<a class="btn btn--ghost" href="/docs/rust/runicnpc/commands/">Commands</a>
<a class="btn btn--ghost" href="/docs/rust/runicnpc/api/">The API</a>
<a class="btn btn--ghost" href="/modules/rust/">The Rust module</a>
</div>
</section>
</Base>
<style>
.npc h2,
.ways h2,
.next h2 {
margin: 0 0 0.75rem;
font-size: clamp(1.5rem, 3vw, 1.95rem);
}
.facts__list {
display: grid;
gap: 1rem;
margin: 0;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr));
}
.facts__list dt {
color: var(--dim);
font-size: 0.78rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.facts__list dd {
margin: 0.5rem 0 0;
color: var(--text);
}
.npc__roles,
.ways__grid {
display: grid;
gap: 1rem;
margin: 1.25rem 0 0;
padding: 0;
list-style: none;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr));
}
.ways__grid {
grid-template-columns: repeat(auto-fit, minmax(min(100%, 22rem), 1fr));
}
.npc__roles h3,
.ways__grid h3,
.npc__grid h3 {
margin: 0 0 0.5rem;
color: var(--gold);
font-size: 1.02rem;
}
.npc__roles p,
.ways__grid p,
.npc__grid p {
margin: 0;
color: var(--muted);
font-size: 0.94rem;
}
.npc__grid {
display: grid;
gap: 1.4rem;
margin-top: 2rem;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 22rem), 1fr));
}
.next p {
margin: 0;
color: var(--muted);
}
.next__links {
display: flex;
flex-wrap: wrap;
gap: 0.7rem;
margin-top: 1.5rem;
}
</style>

View File

@@ -226,6 +226,7 @@ svg {
flex-wrap: wrap;
justify-content: flex-start;
row-gap: 0.15rem;
column-gap: 0.5rem;
padding-block: 0.55rem;
min-height: 0;
}
@@ -236,12 +237,16 @@ svg {
gap: 0;
/* Pull the first link's own padding back to the gutter so the row of links
lines up with the lockup above it rather than sitting indented. */
margin-left: -0.45rem;
margin-left: -0.3rem;
}
/* Five links since phase 17 (D78, RunicNPC). Measured at 390px, the fifth pushed Search
onto a third row: the links ended at 326px, and the row's 24px gap plus the 32px
button needed 382 of the 366 available. A tighter gap, padding and type put all of it
back on the second row with room to spare, and every link still clears a 24px target. */
.site-nav a {
padding-inline: 0.45rem;
font-size: 0.86rem;
padding-inline: 0.3rem;
font-size: 0.84rem;
}
/* Search (D47) shares the second row with the links rather than taking a third. `order`