From 56473d4d51c67889265aefbd80407665f3be4502 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Thu, 22 Oct 2026 20:10:51 -0500 Subject: [PATCH] feat(site): phase 17 - per-game module pages, /runicnpc/, and checkTerms over the built site MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PLAN.md §16 phase 17, built on the org lead's answers of 2026-10-22, recorded as D75-D79 (count of record 79). - D75: core marketing pages describe what a game module adds in core's words. "Game intelligence" became "What a game module adds"; each game's own list moved to src/data/games/ and renders only on the new /modules/uo/ and /modules/rust/. /modules/ is a card per game. - The page-side capability check is per game (assertGameCoverage), the item phase 13 moved here. - D76: the homepage keeps its game screenshot, labelled. screens.mjs entries may carry `game` (D67); Screenshot.astro and the app grid print the game's name and mark the figure data-game. - D77: /app/ describes the Rust screens (released the same day). - D78: /runicnpc/, the standalone page, is in the header and footer; the narrow header was re-measured so five links keep two rows. - D79: checkTerms.mjs --built reads every built page's visible text after the build; game routes and data-game elements allow their game's words. Proven against ten plants. Still the twelfth check. - Re-read fixes: /architecture/'s "two machines is also the maximum", Discord "guild" on /integrations/, and collection.mjs against what Steam sign-in and the Rust module keep (PLAY_DATA_SAFETY.md regenerated; the Play answers are unchanged). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- .gitea/workflows/pr-checks.yml | 11 + PLAN.md | 109 +++++- PLAY_DATA_SAFETY.md | 2 +- README.md | 7 +- package.json | 3 +- scripts/checkTerms.mjs | 392 ++++++++++++++----- src/components/GameFeatures.astro | 107 +++++ src/components/Header.astro | 2 + src/components/Screenshot.astro | 33 +- src/components/SupportedGames.astro | 63 +++ src/components/app/Screenshots.astro | 21 +- src/components/architecture/ModuleSeam.astro | 8 +- src/components/architecture/TwoHosts.astro | 19 +- src/components/home/Capabilities.astro | 24 +- src/components/home/GetStarted.astro | 13 +- src/components/home/WhatItLooksLike.astro | 18 +- src/data/app.mjs | 53 +-- src/data/capabilities.mjs | 199 +++------- src/data/collection.mjs | 32 +- src/data/footer.mjs | 1 + src/data/games/index.mjs | 104 +++++ src/data/games/rust.mjs | 122 ++++++ src/data/games/uo.mjs | 117 ++++++ src/data/screens.mjs | 19 + src/pages/app.astro | 4 +- src/pages/features.astro | 51 ++- src/pages/integrations.astro | 8 +- src/pages/modules.astro | 220 +++++------ src/pages/modules/rust.astro | 199 ++++++++++ src/pages/modules/uo.astro | 165 ++++++++ src/pages/runicnpc.astro | 238 +++++++++++ src/styles/global.css | 11 +- 32 files changed, 1924 insertions(+), 451 deletions(-) create mode 100644 src/components/GameFeatures.astro create mode 100644 src/components/SupportedGames.astro create mode 100644 src/data/games/index.mjs create mode 100644 src/data/games/rust.mjs create mode 100644 src/data/games/uo.mjs create mode 100644 src/pages/modules/rust.astro create mode 100644 src/pages/modules/uo.astro create mode 100644 src/pages/runicnpc.astro diff --git a/.gitea/workflows/pr-checks.yml b/.gitea/workflows/pr-checks.yml index 2d431c9..8fbf471 100644 --- a/.gitea/workflows/pr-checks.yml +++ b/.gitea/workflows/pr-checks.yml @@ -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 (, 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. diff --git a/PLAN.md b/PLAN.md index b23a90c..44f078f 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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 - `` block (D68). Reads the docs and both sidebar trees; phase 17 widens it to all of - `src/`. + `` 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 `` 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 diff --git a/PLAY_DATA_SAFETY.md b/PLAY_DATA_SAFETY.md index 89b0767..59fe5d7 100644 --- a/PLAY_DATA_SAFETY.md +++ b/PLAY_DATA_SAFETY.md @@ -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 diff --git a/README.md b/README.md index bb2358c..3054dee 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/package.json b/package.json index fb3dcb6..de797a0 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/scripts/checkTerms.mjs b/scripts/checkTerms.mjs index ecee8e8..59c0cef 100644 --- a/scripts/checkTerms.mjs +++ b/scripts/checkTerms.mjs @@ -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); } diff --git a/src/components/GameFeatures.astro b/src/components/GameFeatures.astro new file mode 100644 index 0000000..8503300 --- /dev/null +++ b/src/components/GameFeatures.astro @@ -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> diff --git a/src/components/Header.astro b/src/components/Header.astro index 6d3f2cf..3850253 100644 --- a/src/components/Header.astro +++ b/src/components/Header.astro @@ -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' }, ]; diff --git a/src/components/Screenshot.astro b/src/components/Screenshot.astro index ea6847d..e48bf87 100644 --- a/src/components/Screenshot.astro +++ b/src/components/Screenshot.astro @@ -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> diff --git a/src/components/SupportedGames.astro b/src/components/SupportedGames.astro new file mode 100644 index 0000000..cdb6c42 --- /dev/null +++ b/src/components/SupportedGames.astro @@ -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> diff --git a/src/components/app/Screenshots.astro b/src/components/app/Screenshots.astro index 06e3ff9..87452d9 100644 --- a/src/components/app/Screenshots.astro +++ b/src/components/app/Screenshots.astro @@ -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); diff --git a/src/components/architecture/ModuleSeam.astro b/src/components/architecture/ModuleSeam.astro index 67236a0..af7fb66 100644 --- a/src/components/architecture/ModuleSeam.astro +++ b/src/components/architecture/ModuleSeam.astro @@ -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> diff --git a/src/components/architecture/TwoHosts.astro b/src/components/architecture/TwoHosts.astro index 06101a3..f559011 100644 --- a/src/components/architecture/TwoHosts.astro +++ b/src/components/architecture/TwoHosts.astro @@ -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> diff --git a/src/components/home/Capabilities.astro b/src/components/home/Capabilities.astro index 000b505..b4ce56b 100644 --- a/src/components/home/Capabilities.astro +++ b/src/components/home/Capabilities.astro @@ -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; diff --git a/src/components/home/GetStarted.astro b/src/components/home/GetStarted.astro index 8b9da28..15e0d80 100644 --- a/src/components/home/GetStarted.astro +++ b/src/components/home/GetStarted.astro @@ -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> diff --git a/src/components/home/WhatItLooksLike.astro b/src/components/home/WhatItLooksLike.astro index 1b00c9f..110c2d6 100644 --- a/src/components/home/WhatItLooksLike.astro +++ b/src/components/home/WhatItLooksLike.astro @@ -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" /> diff --git a/src/data/app.mjs b/src/data/app.mjs index 167545f..0a8a875 100644 --- a/src/data/app.mjs +++ b/src/data/app.mjs @@ -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', }, ], diff --git a/src/data/capabilities.mjs b/src/data/capabilities.mjs index 61b3036..b1c28a7 100644 --- a/src/data/capabilities.mjs +++ b/src/data/capabilities.mjs @@ -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; diff --git a/src/data/collection.mjs b/src/data/collection.mjs index ee69496..86f3dcc 100644 --- a/src/data/collection.mjs +++ b/src/data/collection.mjs @@ -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', }, ]; diff --git a/src/data/footer.mjs b/src/data/footer.mjs index 686d982..7082044 100644 --- a/src/data/footer.mjs +++ b/src/data/footer.mjs @@ -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' }, ], }, diff --git a/src/data/games/index.mjs b/src/data/games/index.mjs new file mode 100644 index 0000000..eda5aaa --- /dev/null +++ b/src/data/games/index.mjs @@ -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`, + ); +} diff --git a/src/data/games/rust.mjs b/src/data/games/rust.mjs new file mode 100644 index 0000000..38b523d --- /dev/null +++ b/src/data/games/rust.mjs @@ -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.', + }, +]; diff --git a/src/data/games/uo.mjs b/src/data/games/uo.mjs new file mode 100644 index 0000000..117af0b --- /dev/null +++ b/src/data/games/uo.mjs @@ -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']; diff --git a/src/data/screens.mjs b/src/data/screens.mjs index 75e2e7a..09c4718 100644 --- a/src/data/screens.mjs +++ b/src/data/screens.mjs @@ -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.", diff --git a/src/pages/app.astro b/src/pages/app.astro index df78737..07a5f7a 100644 --- a/src/pages/app.astro +++ b/src/pages/app.astro @@ -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']; diff --git a/src/pages/features.astro b/src/pages/features.astro index d3d4952..47c2a67 100644 --- a/src/pages/features.astro +++ b/src/pages/features.astro @@ -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; diff --git a/src/pages/integrations.astro b/src/pages/integrations.astro index c323486..1024957 100644 --- a/src/pages/integrations.astro +++ b/src/pages/integrations.astro @@ -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.', }, { diff --git a/src/pages/modules.astro b/src/pages/modules.astro index fe2cc47..cbfc247 100644 --- a/src/pages/modules.astro +++ b/src/pages/modules.astro @@ -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> diff --git a/src/pages/modules/rust.astro b/src/pages/modules/rust.astro new file mode 100644 index 0000000..6837412 --- /dev/null +++ b/src/pages/modules/rust.astro @@ -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> diff --git a/src/pages/modules/uo.astro b/src/pages/modules/uo.astro new file mode 100644 index 0000000..5c1398e --- /dev/null +++ b/src/pages/modules/uo.astro @@ -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> diff --git a/src/pages/runicnpc.astro b/src/pages/runicnpc.astro new file mode 100644 index 0000000..9317e25 --- /dev/null +++ b/src/pages/runicnpc.astro @@ -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> diff --git a/src/styles/global.css b/src/styles/global.css index 0fe9149..c074886 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -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` -- 2.49.1