feat(site): phase 17 — per-game module pages, /runicnpc/, and checkTerms over the built site #38
@@ -115,6 +115,17 @@ jobs:
|
||||
# fails because some other host is slow is a check people learn to ignore.
|
||||
run: npm run check:links
|
||||
|
||||
- name: Game vocabulary in the built pages
|
||||
# PLAN.md §16, D79 — phase 17 widened the twelfth check to the whole site. The
|
||||
# marketing pages are templates fed from src/data/, so the words a reader sees are
|
||||
# mostly not in any source file: a capability label, a screenshot caption, a value
|
||||
# from platform.json. This pass reads every built page's visible text, alt, title and
|
||||
# aria-label, the way check:links reads its hrefs. A game's routes, and elements
|
||||
# marked data-game (<GameExample>, a game-tagged screenshot), allow that game's words.
|
||||
#
|
||||
# Still the twelfth check, not a thirteenth: the same script, the same word list.
|
||||
run: npm run check:terms:built
|
||||
|
||||
- name: Platform facts
|
||||
# PLAN.md §12 — every version, protocol number and bundle tag is re-read from
|
||||
# its authority over the Gitea API and must agree with src/data/platform.json.
|
||||
|
||||
109
PLAN.md
109
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
|
||||
`<GameExample>` block (D68). Reads the docs and both sidebar trees; phase 17 widens it to all of
|
||||
`src/`.
|
||||
`<GameExample>` block (D68). Reads the docs and both sidebar trees before the build; since
|
||||
phase 17 (D79) a second pass, `--built`, reads every built page's visible text after it, so
|
||||
the marketing pages and the words their data renders are covered too. A game's own routes, and
|
||||
an element marked `data-game`, allow that game's words.
|
||||
- `astro check` plus a production build, in CI on every PR.
|
||||
|
||||
---
|
||||
@@ -2262,6 +2265,106 @@ exception to "every page links out".
|
||||
The check parses them line by line and was not affected. Fixed at the org lead's ask in `docs`
|
||||
#371, which removes the two blank lines and changes nothing else.
|
||||
|
||||
### How phase 17 built it
|
||||
|
||||
Five decisions, taken by the org lead on 2026-10-22 before any page was written, each from a
|
||||
concrete example. The count of record is now **seventy-nine**.
|
||||
|
||||
**D75 — core marketing pages describe what a game module adds in core's words, and link to
|
||||
the games.** The homepage, `/features/`, `/architecture/` and `/app/` name both games and use
|
||||
neither game's vocabulary. The module-supplied group became *What a game module adds*: seven
|
||||
things both modules supply, each in its own way (live server status, a live feed, leaderboards,
|
||||
maps, Teams from the game, players' own game accounts, game steps for events), with a button
|
||||
to each game's page. Each game's real list, in its own words, lives in `src/data/games/` and
|
||||
renders only on `/modules/uo/` and `/modules/rust/`. The alternative was D68 applied to
|
||||
marketing — game-labelled blocks carrying each game's real list on the core pages. It was
|
||||
declined as longer, and as the front door carrying two games' jargon at once.
|
||||
|
||||
**D76 — the homepage's "What it looks like" keeps its game screenshot, labelled.** This is
|
||||
D67's choice, made. The capture stays the Ultima Online live-status screen, now printing
|
||||
"Ultima Online" beside it, because the claim under the data-path diagram is live game state
|
||||
on a public page and no core screen shows that. Phase 18 adds a labelled Rust capture beside
|
||||
it. The alternative, a core screen such as the admin dashboard, was declined.
|
||||
|
||||
**D77 — `/app/` describes the app's Rust screens.** When the phase began they were on
|
||||
`Android-app` `edge` only, 26 commits ahead of `main` and in no release. The org lead chose to
|
||||
describe them and release them the same day, rather than hold the page or say "next release".
|
||||
`/app/`'s game section is now *The game, live*: one item per game, titled with the game's name
|
||||
and written in core's words, plus Rust's half of *Your own side of the game*.
|
||||
|
||||
**D78 — `/runicnpc/` is in the header.** The top navigation is now five items, so a Rust
|
||||
server owner who runs no Runic Gateway finds the plugin from any page (D61's reason). It is in
|
||||
the footer's Product column and on the Rust module page's RunicNPC card too. The alternative
|
||||
was the footer and the card only.
|
||||
|
||||
**D79 — `checkTerms.mjs` reads the built pages.** D64 had the check widen to all of `src/`. A
|
||||
source scan of the marketing pages would see expressions rather than the words they render —
|
||||
a capability label, a screenshot caption, a value from `platform.json` — and would take ids
|
||||
like `shard-status` and slugs like `guilds` for prose. So the check gained a second pass,
|
||||
`--built`, run after the build the way `checkLinks.mjs` is. It reads every page's visible text
|
||||
plus `alt`, `title`, `aria-label`, `placeholder`, the `<title>` and the meta description, and
|
||||
not link targets, scripts, styles or comments. A game's routes (`GAME_ROUTES`: its docs group,
|
||||
its module page, and `/runicnpc/` for Rust) allow its words, and so does any element marked
|
||||
`data-game` — `<GameExample>` and, new this phase, a game-tagged `<Screenshot>`. Starlight's
|
||||
sidebar and the previous/next links are skipped, because the source pass reads the sidebar as
|
||||
data and each neighbour's title is checked on its own page. The source pass is unchanged and
|
||||
still runs first, naming a file and a line. Failures from the built pass name a route and a
|
||||
snippet. It is still the twelfth check: one script, one word list, one allowlist (whose entry
|
||||
now carries a `route` as well as a `file`). The alternative was a source scan with per-file
|
||||
exemptions.
|
||||
|
||||
**The check was proven before it was trusted.** Ten plants went into the built HTML. Six
|
||||
failed, each naming where: a word in a core page's text, in an `alt`, in a `title`, a
|
||||
UO word inside a block marked Rust, a word split across two syntax-highlighting `<span>`s, and a
|
||||
UO word on a Rust page. Four passed: a word only in an `href`, one in a `<script>`, UO words on
|
||||
`/modules/uo/`, and a UO block on a core page. On the build as it stood before this phase, the
|
||||
pass found 49 words on seven marketing pages; all are gone.
|
||||
|
||||
**What was built.**
|
||||
|
||||
- **`/modules/`** is core now. Its Ultima Online worked example became a card per game, from
|
||||
`src/data/games/index.mjs`, and what the modules share — a sidecar, their own tables, their
|
||||
own releases — is said once beneath them.
|
||||
- **`/modules/uo/`** carries the Ultima Online list that used to be *Game intelligence*, the
|
||||
three pieces with their versions, and the five screenshots `/features/` showed until now.
|
||||
- **`/modules/rust/`** carries eight features for players and four for staff, written from the
|
||||
Rust docs group phase 15 walked, the four pieces (the module, rust-link, `RunicGateway.cs`,
|
||||
RunicNPC) and the RunicNPC card D61 asked for. It has no screenshots and no slots for them
|
||||
(D65, D4).
|
||||
- **`/runicnpc/`** is written for a server owner with no site: what it does, the two ways to run
|
||||
it, and where to get it. Every number is read — release, API version and floors from
|
||||
`platform.json`, the counts of calls and hooks from `runicnpc.mjs`, which phase 16's check
|
||||
holds to `API.md`.
|
||||
- **The page-side capability check is per game**, closing the item phase 13 moved here.
|
||||
`assertGameCoverage()` holds each game's list to its module's declared capabilities in both
|
||||
directions. The homepage, `/features/` and each game page call it, and
|
||||
`assertDetailCoverage()` now reads the games' lists too.
|
||||
- **The supported-games strip** (D63) is `src/components/SupportedGames.astro`, on the homepage,
|
||||
`/features/` and `/modules/`.
|
||||
- **D67 is in the components.** `screens.mjs` entries may carry `game`; nine are tagged `uo`.
|
||||
`Screenshot.astro` and `/app/`'s phone grid print the game's name beside each tagged capture
|
||||
and mark it `data-game`.
|
||||
|
||||
**What the re-read found**, fixed here:
|
||||
|
||||
- **`/architecture/` said two machines is "the minimum and also the maximum"**, false since Rust
|
||||
shipped: one sidecar serves one server, so a community with six servers runs six. The caption
|
||||
now says two is the minimum and a sidecar sits beside each game server, and its game host
|
||||
section no longer says the installer prints "four values" (that count was one game's).
|
||||
- **`/integrations/` and the Discord capability called a Discord server a "guild"** — Discord's
|
||||
API word, but read on this site as a game's. They say *Discord server* now, as phase 14 made
|
||||
the docs say.
|
||||
- **`collection.mjs` was re-read against what the Rust module and Steam sign-in keep**, as the
|
||||
plan required, before anything was claimed. *Account records* now names linked sign-in
|
||||
identities, Steam's display name and avatar address included (`user_identities`). *Game data
|
||||
from the connected server* names, for Rust, each player's Steam id and in-game name, their
|
||||
statistics, who is online, and the event record, which includes chat, player reports and the
|
||||
IP addresses on sign-in attempts and bans, readable by staff only. Its retention now states
|
||||
the module's 30 days for that record and the sidecar's 14-day default. The Play Data Safety
|
||||
answers did not change: the app's Rust screens read from the deployment and send it only a
|
||||
one-time link code, which the existing "everything you read and post" row already covers. Its
|
||||
wording lost "shard", so `PLAY_DATA_SAFETY.md` was regenerated.
|
||||
|
||||
---
|
||||
|
||||
[api]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
107
src/components/GameFeatures.astro
Normal file
107
src/components/GameFeatures.astro
Normal file
@@ -0,0 +1,107 @@
|
||||
---
|
||||
import type { Capability } from '../data/capabilities.mjs';
|
||||
|
||||
/**
|
||||
* A game's own feature list, with the argument for each — PLAN.md §16, phase 17.
|
||||
*
|
||||
* Rendered only by a game's page under `/modules/` (`GAME_ROUTES` in `checkTerms.mjs`),
|
||||
* which is what lets the items be written in the game's own words. It is `/features/`'s
|
||||
* card treatment (D20: the label and the detail, never the label alone), so a reader who has
|
||||
* seen that page reads this one the same way.
|
||||
*
|
||||
* A feature with a `demoPath` gets the per-capability demo link of D25. The markup is the
|
||||
* exact contract `scripts/checkBrand.mjs` enforces and `applyBrand.mjs` rewrites at boot —
|
||||
* see the note in `/features/` before touching it. A stock build hides every one.
|
||||
*/
|
||||
interface Props {
|
||||
title: string;
|
||||
lede?: string;
|
||||
items: Capability[];
|
||||
}
|
||||
|
||||
const { title, lede, items } = Astro.props;
|
||||
---
|
||||
|
||||
<section class="page section feats">
|
||||
<h2>{title}</h2>
|
||||
{lede && <p class="prose feats__lede">{lede}</p>}
|
||||
|
||||
<ul class="feats__items">
|
||||
{
|
||||
items.map((item) => (
|
||||
<li class="panel feats__item">
|
||||
<h3>{item.label}</h3>
|
||||
<p class="feats__detail">{item.detail}</p>
|
||||
{item.demoPath && (
|
||||
<a
|
||||
class="demo-link"
|
||||
href="" data-demo-url="" data-demo-path={item.demoPath}
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
See it running
|
||||
</a>
|
||||
)}
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.feats h2 {
|
||||
margin: 0;
|
||||
font-size: clamp(1.5rem, 3vw, 1.95rem);
|
||||
}
|
||||
|
||||
.feats__lede {
|
||||
margin: 0.85rem 0 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.feats__items {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
margin: 1.75rem 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 21rem), 1fr));
|
||||
}
|
||||
|
||||
.feats__item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.feats__item h3 {
|
||||
margin: 0 0 0.6rem;
|
||||
color: var(--gold);
|
||||
font-size: 1.04rem;
|
||||
}
|
||||
|
||||
.feats__detail {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.94rem;
|
||||
}
|
||||
|
||||
/* As on /features/: a link rather than a button, and the flex item itself, so the
|
||||
global rule that hides an empty demo link takes its margin with it. */
|
||||
.demo-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
margin-top: 1rem;
|
||||
color: var(--portal);
|
||||
font-size: 0.88rem;
|
||||
text-decoration-color: color-mix(in srgb, var(--portal) 40%, transparent);
|
||||
}
|
||||
|
||||
.demo-link:hover {
|
||||
color: var(--portal-bright);
|
||||
}
|
||||
|
||||
.demo-link::after {
|
||||
content: '\2197';
|
||||
}
|
||||
</style>
|
||||
@@ -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' },
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
|
||||
63
src/components/SupportedGames.astro
Normal file
63
src/components/SupportedGames.astro
Normal file
@@ -0,0 +1,63 @@
|
||||
---
|
||||
import { games } from '../data/games/index.mjs';
|
||||
|
||||
/**
|
||||
* "Supported games: Ultima Online · Rust" — PLAN.md §16, D63.
|
||||
*
|
||||
* The one place a core page names the games, and the way it hands a reader on to them. Core
|
||||
* pages describe what a game module adds in core's words (D75); this strip is the link to
|
||||
* the page where each game says it in its own. The names come from `platform.json` through
|
||||
* `games/index.mjs`, so a third game appears here the day it is added there.
|
||||
*/
|
||||
interface Props {
|
||||
/** Extra class for the caller's spacing. */
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const { class: className } = Astro.props;
|
||||
---
|
||||
|
||||
<p class:list={['games', className]}>
|
||||
<span class="games__label">Supported games</span>
|
||||
{
|
||||
games.map((game, index) => (
|
||||
<>
|
||||
{index > 0 && <span class="games__sep" aria-hidden="true">·</span>}
|
||||
<a class="games__link" href={game.href}>
|
||||
{game.name}
|
||||
</a>
|
||||
</>
|
||||
))
|
||||
}
|
||||
</p>
|
||||
|
||||
<style>
|
||||
.games {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0.35rem 0.65rem;
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.games__label {
|
||||
color: var(--dim);
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.games__label::after {
|
||||
content: ':';
|
||||
}
|
||||
|
||||
.games__sep {
|
||||
color: var(--dim);
|
||||
}
|
||||
|
||||
.games__link {
|
||||
color: var(--gold);
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ export function footerColumns(brand) {
|
||||
{ href: '/features/', label: 'Features' },
|
||||
{ href: '/architecture/', label: 'Architecture' },
|
||||
{ href: '/modules/', label: 'Modules' },
|
||||
{ href: '/runicnpc/', label: 'RunicNPC' },
|
||||
{ href: '/app/', label: 'Android app' },
|
||||
],
|
||||
},
|
||||
|
||||
104
src/data/games/index.mjs
Normal file
104
src/data/games/index.mjs
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* games/index.mjs — the games the platform supports, and the check that holds each one's
|
||||
* feature list to its module's declared capabilities.
|
||||
*
|
||||
* PLAN.md §16, phase 17. Each game has a file of its own (`uo.mjs`, `rust.mjs`) written in
|
||||
* that game's words, which only that game's pages render. This file is what the core pages
|
||||
* read instead: the game's name and where its page is, which D63 lets any page say.
|
||||
*
|
||||
* The order is the order the games were supported in, and every list of games on the site
|
||||
* follows it.
|
||||
*/
|
||||
|
||||
import platform from '../platform.json' with { type: 'json' };
|
||||
import * as uo from './uo.mjs';
|
||||
import * as rust from './rust.mjs';
|
||||
|
||||
/**
|
||||
* @typedef {object} Game
|
||||
* @property {'uo'|'rust'} key A key of `platform.json`'s `games`.
|
||||
* @property {string} name The game's own name, from `platform.json`.
|
||||
* @property {string} href Its marketing page.
|
||||
* @property {string} docs Its docs group.
|
||||
* @property {string} release Its module's release, from `platform.json`.
|
||||
* @property {string} blurb One sentence in core's words, for the core pages' game cards.
|
||||
* @property {import('../capabilities.mjs').Capability[]} features
|
||||
*/
|
||||
|
||||
/** @type {Game[]} */
|
||||
export const games = [
|
||||
{
|
||||
key: 'uo',
|
||||
name: platform.games.uo.name,
|
||||
href: '/modules/uo/',
|
||||
docs: '/docs/ultima-online/',
|
||||
release: platform.releases['Module-uo'],
|
||||
blurb:
|
||||
'The first module, and the one the seam was cut around: the server’s live state, its ' +
|
||||
'economy and its players’ own groups, extracted from the site without changing a URL.',
|
||||
features: uo.features,
|
||||
},
|
||||
{
|
||||
key: 'rust',
|
||||
name: platform.games.rust.name,
|
||||
href: '/modules/rust/',
|
||||
docs: '/docs/rust/',
|
||||
release: platform.releases['Module-Rust'],
|
||||
blurb:
|
||||
'Any number of servers, each with its own sidecar: a live feed, leaderboards that ' +
|
||||
'outlast a reset, a live map, and permissions and events run from the site.',
|
||||
features: [...rust.features, ...rust.staffFeatures],
|
||||
},
|
||||
];
|
||||
|
||||
for (const key of Object.keys(platform.games)) {
|
||||
if (!games.some((game) => game.key === key)) {
|
||||
throw new Error(`src/data/games/index.mjs has no entry for "${key}", which platform.json's games declares.`);
|
||||
}
|
||||
}
|
||||
|
||||
/** One game by key, or a build failure naming it. @param {string} key */
|
||||
export function gameByKey(key) {
|
||||
const game = games.find((candidate) => candidate.key === key);
|
||||
if (game) return game;
|
||||
throw new Error(`src/data/games/index.mjs has no game "${key}". Known: ${games.map((g) => g.key).join(', ')}.`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fails the build when a game's feature list and its module's declared capabilities
|
||||
* disagree, in either direction.
|
||||
*
|
||||
* Called from the pages rather than a check script, for the reason phase 3 gave: the failure
|
||||
* should reach whoever is editing the page, and an Astro build error names the component. It
|
||||
* also means `astro build` alone cannot skip it.
|
||||
*
|
||||
* @param {string} key
|
||||
*/
|
||||
export function assertGameCoverage(key) {
|
||||
const game = gameByKey(key);
|
||||
const declared = platform.games[key].module.capabilities;
|
||||
|
||||
const claimed = new Set(game.features.flatMap((item) => item.caps || []));
|
||||
const unlisted = declared.filter((cap) => !claimed.has(cap));
|
||||
const invented = [...claimed].filter((cap) => !declared.includes(cap));
|
||||
if (!unlisted.length && !invented.length) return;
|
||||
|
||||
const lines = [];
|
||||
if (unlisted.length) {
|
||||
lines.push(
|
||||
`the module declares ${unlisted.map((c) => `"${c}"`).join(', ')}, which no feature on ` +
|
||||
`its page claims — the site is under-selling what it can show.`,
|
||||
);
|
||||
}
|
||||
if (invented.length) {
|
||||
lines.push(
|
||||
`the page claims ${invented.map((c) => `"${c}"`).join(', ')}, which the module no ` +
|
||||
`longer declares — the site is advertising something that is gone (§1).`,
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
`src/data/games/${key}.mjs disagrees with platform.json's games.${key}.module.capabilities:\n` +
|
||||
lines.map((line) => ` - ${line}`).join('\n') +
|
||||
`\n\nUpdate the features in src/data/games/${key}.mjs, or the JSON if the module itself changed.\n`,
|
||||
);
|
||||
}
|
||||
122
src/data/games/rust.mjs
Normal file
122
src/data/games/rust.mjs
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* games/rust.mjs — what the Rust module adds, in Rust's own words.
|
||||
*
|
||||
* PLAN.md §16, phase 17. `/modules/rust/` renders this; the core pages describe what a game
|
||||
* module adds in core's words and link there (D75). It is the Rust half of the coverage
|
||||
* assertion phase 13 moved here: `caps` is the contract with `platform.json`'s
|
||||
* `games.rust.module.capabilities`, re-read by `checkFacts.mjs` from `Module-Rust`'s own
|
||||
* `module.json`, and `assertGameCoverage()` in `index.mjs` holds the two together.
|
||||
*
|
||||
* Every item was written from the Rust docs group, which phase 15 walked on the walk site
|
||||
* and checked against the module's source (D36) — this file says less than those pages and
|
||||
* nothing they do not. `rust` names the module itself rather than a surface (Module-Rust's
|
||||
* README: "the single gate a whole navigation group hangs on"), so it rides on the server
|
||||
* list, which is the page that gate opens.
|
||||
*
|
||||
* The staff items carry no `caps`: a capability is what a client feature-detects on, and
|
||||
* the admin screens are not something any client detects.
|
||||
*
|
||||
* No `demoPath` anywhere: the demo instance planned in §15 runs Ultima Online.
|
||||
*/
|
||||
|
||||
/** @typedef {import('../capabilities.mjs').Capability} Capability */
|
||||
|
||||
/** For visitors and players. @type {Capability[]} */
|
||||
export const features = [
|
||||
{
|
||||
label: 'Every server, live',
|
||||
caps: ['rust', 'servers'],
|
||||
detail:
|
||||
'Every Rust server the community runs, as it last reported itself: up or down, who is ' +
|
||||
'on, which map, and when the next wipe is. One sidecar serves one server, so a ' +
|
||||
'community with six servers sees six — and a server that is down says so while the ' +
|
||||
'site carries on.',
|
||||
},
|
||||
{
|
||||
label: 'The kill feed',
|
||||
caps: ['killfeed'],
|
||||
detail:
|
||||
'Kills, chat, comings and goings, RunicNPC bosses, and the world itself — cargo, ' +
|
||||
'helicopters, airdrops — as they happen. Every kind passes a default-deny allowlist ' +
|
||||
'before it reaches a public page; staff read the rest, connection records and player ' +
|
||||
'reports included.',
|
||||
},
|
||||
{
|
||||
label: 'Leaderboards, per wipe',
|
||||
caps: ['leaderboard'],
|
||||
detail:
|
||||
'Kills, deaths, NPC kills, structures and time played, for one wipe or all-time — with ' +
|
||||
'the chat titles players have earned. The record survives the wipe: the game forgets, ' +
|
||||
'and the site does not.',
|
||||
},
|
||||
{
|
||||
label: 'Who is online',
|
||||
caps: ['presence'],
|
||||
detail:
|
||||
'Who is on right now, and who is asleep. Nothing names a player to the public by ' +
|
||||
'default: how far a name reaches — staff, signed-in players, everyone — is the ' +
|
||||
'operator’s choice, made per surface.',
|
||||
},
|
||||
{
|
||||
label: 'Wipes',
|
||||
caps: ['wipes'],
|
||||
detail:
|
||||
'Every wipe the site has seen, and the next one once the server has a wipe schedule — ' +
|
||||
'the monthly forced wipe or a rule of your own.',
|
||||
},
|
||||
{
|
||||
label: 'The live map',
|
||||
caps: ['map'],
|
||||
detail:
|
||||
'The server’s own map picture with layers on it: monuments and world events, the ' +
|
||||
'zones and NPCs your events placed, players, bases as positions only, and a player’s ' +
|
||||
'own clan. Each layer has its own audience, and positions are never stored.',
|
||||
},
|
||||
{
|
||||
label: 'Steam accounts, linked',
|
||||
caps: ['identity'],
|
||||
detail:
|
||||
'A player joins their Steam account to their site account with a code from /link in ' +
|
||||
'game, or by signing in with Steam. Staff can undo a wrong link. Rewards go to Steam ' +
|
||||
'ids, so a player earns them before they ever link.',
|
||||
},
|
||||
{
|
||||
label: 'Clans are Teams',
|
||||
detail:
|
||||
'Each clan on a server is a Team on the site: a page, a feed for its members and ' +
|
||||
'core’s Team notifications. Who may see a clan’s roster is set alongside who may see ' +
|
||||
'a name at all.',
|
||||
},
|
||||
];
|
||||
|
||||
/** For staff, one per admin screen the module adds. @type {Capability[]} */
|
||||
export const staffFeatures = [
|
||||
{
|
||||
label: 'Permissions and groups, from the site',
|
||||
detail:
|
||||
'The site holds every server’s Oxide or Carbon groups and permissions and pushes them ' +
|
||||
'in. One vip on the site is one vip in game, on the servers it covers, and a change ' +
|
||||
'made in game is noticed rather than silently kept.',
|
||||
},
|
||||
{
|
||||
label: 'Mod config, from the site',
|
||||
detail:
|
||||
'Edit your plugins’ config files on the game host from the admin panel, with a backup ' +
|
||||
'first, a watched reload, and an automatic undo if the plugin does not come back.',
|
||||
},
|
||||
{
|
||||
label: 'Events, zones and rewards',
|
||||
detail:
|
||||
'Rust steps for core’s scheduled events: open a ZoneManager zone with a dome, place ' +
|
||||
'NPCs, count who is present, reward a kit, pay coins, start a raid base, or run ' +
|
||||
'console commands. Every step arrives switched off, on the server as well as on the ' +
|
||||
'site.',
|
||||
},
|
||||
{
|
||||
label: 'Optional plugins, recognised',
|
||||
detail:
|
||||
'BetterChat, Economics, Backpacks, TruePVE, Dynamic PVP, Dangerous Treasures, Guarded ' +
|
||||
'Crate, RaidableBases and more each add something where they are installed: coins, ' +
|
||||
'titles, map layers, event steps. None is required.',
|
||||
},
|
||||
];
|
||||
117
src/data/games/uo.mjs
Normal file
117
src/data/games/uo.mjs
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* games/uo.mjs — what the Ultima Online module adds, in Ultima Online's own words.
|
||||
*
|
||||
* PLAN.md §16, phase 17. Until this phase these items were the "Game intelligence" group of
|
||||
* `capabilities.mjs`, which the homepage and `/features/` render — core pages, which D63
|
||||
* says may name the games but not use their vocabulary. They moved here, to a file that
|
||||
* belongs to one game, and `/modules/uo/` renders them. The core pages now describe what a
|
||||
* game module adds in core's words and link here (D75).
|
||||
*
|
||||
* `caps` is the contract with `platform.json`'s `games.uo.module.capabilities`, which
|
||||
* `checkFacts.mjs` re-reads from the module's own `module.json`. `assertGameCoverage()` in
|
||||
* `index.mjs` fails the build when the two drift apart, in either direction — so the day the
|
||||
* module gains a capability, the JSON goes red, and the day the JSON moves, this page does.
|
||||
*
|
||||
* Slugs are NOT one-per-item: `shard` is the source of four separate user-facing features,
|
||||
* and the marketplace draws on `market` and `cliloc` together (item names arrive as cliloc
|
||||
* ids and are resolved against the shard's own string table). Coverage, not a bijection.
|
||||
*
|
||||
* `demoPath` is a stable public route on the module's own pages, deep-linked into a demo
|
||||
* when one is mounted (D25). Paths are read from `module-uo`'s `client/src/entry.jsx`.
|
||||
*/
|
||||
|
||||
/** @typedef {import('../capabilities.mjs').Capability} Capability */
|
||||
|
||||
/** @type {Capability[]} */
|
||||
export const features = [
|
||||
{
|
||||
label: 'Live shard status',
|
||||
caps: ['shard'],
|
||||
demoPath: '/uo/shard',
|
||||
detail:
|
||||
'Whether the shard is up, who is on it, and how long ago the site last heard from it. ' +
|
||||
'When the game is down this page is the thing that says so — the site does not go ' +
|
||||
'down with it.',
|
||||
},
|
||||
{
|
||||
label: 'Economy and activity',
|
||||
caps: ['shard'],
|
||||
demoPath: '/uo/shard/activity',
|
||||
detail:
|
||||
'A live feed of what is happening in the world, and the economy underneath it. Every ' +
|
||||
'event passes an allowlist before it can reach a public page; staff read a second ' +
|
||||
'stream carrying the rest.',
|
||||
},
|
||||
{
|
||||
label: 'Character sheets',
|
||||
caps: ['shard'],
|
||||
detail:
|
||||
'Skills, stats and equipment, drawn from the live world rather than from a form ' +
|
||||
'somebody filled in. Reachable by the account the character is linked to, and by ' +
|
||||
'staff — not by the public.',
|
||||
},
|
||||
{
|
||||
label: 'Points and loyalty boards',
|
||||
caps: ['shard'],
|
||||
demoPath: '/uo/leaderboards',
|
||||
detail:
|
||||
'The leaderboards the shard already keeps, published without anyone exporting a ' +
|
||||
'spreadsheet on a Sunday.',
|
||||
},
|
||||
{
|
||||
label: 'Player-vendor marketplace',
|
||||
caps: ['market', 'cliloc'],
|
||||
demoPath: '/uo/market',
|
||||
detail:
|
||||
'Every player vendor on the shard and what is on it, searchable without logging in to ' +
|
||||
'the game. Item names arrive from the world as cliloc ids and are resolved against the ' +
|
||||
"shard's own string table, so they read as names rather than numbers — beside the " +
|
||||
"item's own picture, in the colour it was dyed.",
|
||||
},
|
||||
{
|
||||
label: 'Houses and IDOC decay',
|
||||
caps: ['houses'],
|
||||
demoPath: '/uo/houses',
|
||||
detail:
|
||||
'Who owns what and where it stands, including which houses are decaying — published ' +
|
||||
'while it is still information rather than after it has become a rumour.',
|
||||
},
|
||||
{
|
||||
label: 'Spawn atlas',
|
||||
caps: ['atlas'],
|
||||
demoPath: '/uo/atlas',
|
||||
detail:
|
||||
"A bestiary and spawn map built by reading your shard's own spawn tables, so it " +
|
||||
"describes your server rather than someone else's idea of the game. Regions, " +
|
||||
'landmarks and champion altars come with it, and each creature is shown as the ' +
|
||||
'artwork your own client draws it with.',
|
||||
},
|
||||
{
|
||||
label: 'Champion boards',
|
||||
caps: ['champs'],
|
||||
demoPath: '/uo/champs',
|
||||
detail: 'Which altars are running, how far along they are, and what turned up.',
|
||||
},
|
||||
{
|
||||
label: 'Guilds',
|
||||
caps: ['guilds'],
|
||||
demoPath: '/uo/guilds',
|
||||
detail:
|
||||
'Guild rosters and standings, kept in step with the shard. Each guild is also a Team ' +
|
||||
'on the site, with the forum, the notifications and the voice channel that core ' +
|
||||
'attaches to one.',
|
||||
},
|
||||
{
|
||||
label: 'City governors',
|
||||
caps: ['governors'],
|
||||
demoPath: '/uo/governors',
|
||||
detail: 'Who holds which city, and what they did with it.',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* The screenshots `/modules/uo/` shows beside the list, by id from `screens.mjs`. Every one
|
||||
* is tagged `game: 'uo'` there, so it prints the game's name beside it (D67) wherever it is
|
||||
* shown. They lived on `/features/` until phase 17.
|
||||
*/
|
||||
export const screens = ['shard-status', 'marketplace', 'spawn-atlas', 'guilds', 'houses'];
|
||||
@@ -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.",
|
||||
|
||||
@@ -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'];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
199
src/pages/modules/rust.astro
Normal file
199
src/pages/modules/rust.astro
Normal file
@@ -0,0 +1,199 @@
|
||||
---
|
||||
import Base from '../../layouts/Base.astro';
|
||||
import PageHeader from '../../components/PageHeader.astro';
|
||||
import GameFeatures from '../../components/GameFeatures.astro';
|
||||
|
||||
import platform from '../../data/platform.json';
|
||||
import { features, staffFeatures } from '../../data/games/rust.mjs';
|
||||
import { assertGameCoverage } from '../../data/games/index.mjs';
|
||||
|
||||
/**
|
||||
* `/modules/rust/` — PLAN.md §16, phase 17.
|
||||
*
|
||||
* The Rust module's own page, in Rust's words (D63; `GAME_ROUTES` in `checkTerms.mjs`). The
|
||||
* lists are `games/rust.mjs`, written from the Rust docs group that phase 15 walked, and the
|
||||
* call below holds them to the capabilities `Module-Rust` declares — the page-side half of
|
||||
* the coverage assertion phase 13 moved here.
|
||||
*
|
||||
* It carries the RunicNPC card D61 asked for, pointing at the standalone `/runicnpc/` page.
|
||||
*
|
||||
* No screenshots yet, and no slots for them: D65 brings the Rust captures in phase 18, from
|
||||
* the rigs, and D4 rules out a placeholder in the meantime.
|
||||
*/
|
||||
assertGameCoverage('rust');
|
||||
|
||||
const rust = platform.games.rust;
|
||||
|
||||
const pieces = [
|
||||
{
|
||||
role: 'The module',
|
||||
name: rust.module.repo,
|
||||
version: platform.releases['Module-Rust'],
|
||||
what: 'Every Rust screen on the site, installed from Admin → Modules.',
|
||||
},
|
||||
{
|
||||
role: 'The sidecar',
|
||||
name: 'rust-link',
|
||||
version: rust.bundle.sidecar,
|
||||
what: 'One beside each server, and the only part of the bridge that listens on a network.',
|
||||
},
|
||||
{
|
||||
role: 'The bridge plugin',
|
||||
name: 'RunicGateway.cs',
|
||||
version: rust.bundle.plugin,
|
||||
what: 'One file that runs unchanged on Oxide and Carbon, plus its optional helpers.',
|
||||
},
|
||||
{
|
||||
role: 'NPCs',
|
||||
name: 'RunicNPC',
|
||||
version: rust.bundle.runicnpc,
|
||||
what: 'Our NPC plugin, which the bridge drives for every NPC an event places.',
|
||||
},
|
||||
];
|
||||
|
||||
const title = 'The Rust module';
|
||||
const description =
|
||||
'What the Rust module adds to a Runic Gateway site: every server live, the kill feed, ' +
|
||||
'leaderboards per wipe, the live map, Steam linking, and permissions, mod config and events run from the site.';
|
||||
---
|
||||
|
||||
<Base title={title} description={description}>
|
||||
<PageHeader eyebrow="Game module" title="Rust">
|
||||
<p>
|
||||
The <code>rust</code> module turns a Runic Gateway site into a Rust community’s site. It
|
||||
follows any number of servers, each running Oxide or Carbon with a sidecar of its own,
|
||||
and keeps what each one tells it after the wipe has taken it from the game.
|
||||
</p>
|
||||
<p>
|
||||
The server side is installed as one bundle — <strong>{rust.bundle.tag}</strong>,
|
||||
speaking protocol {rust.protocol} — with the installer, from a Pterodactyl egg, or by
|
||||
hand. It needs Oxide {rust.bundle.oxideMin} or Carbon {rust.bundle.carbonMin} or newer,
|
||||
and the uMod plugins {rust.bundle.requiresPlugins.join(' and ')}.
|
||||
</p>
|
||||
</PageHeader>
|
||||
|
||||
<section class="page section pieces" aria-labelledby="pieces-heading">
|
||||
<h2 id="pieces-heading">The pieces</h2>
|
||||
<ul class="pieces__list">
|
||||
{
|
||||
pieces.map((piece) => (
|
||||
<li class="panel">
|
||||
<p class="pieces__role">{piece.role}</p>
|
||||
<p class="pieces__name">
|
||||
<code>{piece.name}</code>
|
||||
<span class="chip chip--version">{piece.version}</span>
|
||||
</p>
|
||||
<p class="pieces__what">{piece.what}</p>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<GameFeatures
|
||||
title="What players see"
|
||||
lede="Every public surface is the operator's to widen: nothing names a player to the public by default."
|
||||
items={features}
|
||||
/>
|
||||
|
||||
<GameFeatures
|
||||
title="What staff run from the site"
|
||||
lede="Seven admin screens, and a Rust section on each user's page."
|
||||
items={staffFeatures}
|
||||
/>
|
||||
|
||||
<section class="page section npc">
|
||||
<div class="panel npc__card">
|
||||
<p class="eyebrow">Also from this project</p>
|
||||
<h2>RunicNPC</h2>
|
||||
<p>
|
||||
Our own NPC plugin for Rust: NPCs where you want them, with the kit, health, aim and
|
||||
behaviour you give them — roamers, sentries, guards, quest-givers and bosses with
|
||||
phases. On a Runic Gateway server it is required and the site authors its profiles;
|
||||
on any other Rust server it runs on its own, with <code>/rnpc</code> commands and an
|
||||
API for other plugins.
|
||||
</p>
|
||||
<div class="npc__links">
|
||||
<a class="btn btn--primary" href="/runicnpc/">About RunicNPC</a>
|
||||
<a class="btn btn--ghost" href="/docs/rust/runicnpc/">Its documentation</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="page section next">
|
||||
<h2>Setting it up</h2>
|
||||
<p class="prose">
|
||||
The documentation’s Rust group starts from installing the bridge and covers every admin
|
||||
screen the module adds, with the reference for each file it reads.
|
||||
</p>
|
||||
<div class="next__links">
|
||||
<a class="btn btn--primary" href="/docs/rust/">The Rust docs</a>
|
||||
<a class="btn btn--ghost" href="/modules/">All modules</a>
|
||||
</div>
|
||||
</section>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.pieces h2,
|
||||
.next h2 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: clamp(1.5rem, 3vw, 1.95rem);
|
||||
}
|
||||
|
||||
.pieces__list {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
margin: 1.25rem 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 15rem), 1fr));
|
||||
}
|
||||
|
||||
.pieces__role {
|
||||
margin: 0;
|
||||
color: var(--dim);
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.pieces__name {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
margin: 0.5rem 0 0;
|
||||
color: var(--gold);
|
||||
}
|
||||
|
||||
.pieces__what {
|
||||
margin: 0.6rem 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.94rem;
|
||||
}
|
||||
|
||||
.npc__card h2 {
|
||||
margin: 0;
|
||||
color: var(--gold);
|
||||
font-size: clamp(1.4rem, 2.8vw, 1.8rem);
|
||||
}
|
||||
|
||||
.npc__card p:not(.eyebrow) {
|
||||
margin: 0.75rem 0 0;
|
||||
max-width: var(--measure);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.npc__links,
|
||||
.next__links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.7rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.next p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
</style>
|
||||
165
src/pages/modules/uo.astro
Normal file
165
src/pages/modules/uo.astro
Normal file
@@ -0,0 +1,165 @@
|
||||
---
|
||||
import Base from '../../layouts/Base.astro';
|
||||
import PageHeader from '../../components/PageHeader.astro';
|
||||
import GameFeatures from '../../components/GameFeatures.astro';
|
||||
import Screenshot from '../../components/Screenshot.astro';
|
||||
|
||||
import platform from '../../data/platform.json';
|
||||
import { features, screens } from '../../data/games/uo.mjs';
|
||||
import { assertGameCoverage } from '../../data/games/index.mjs';
|
||||
|
||||
/**
|
||||
* `/modules/uo/` — PLAN.md §16, phase 17.
|
||||
*
|
||||
* The Ultima Online module's own page, and so one of the few marketing pages that may use
|
||||
* that game's words (D63; `GAME_ROUTES` in `checkTerms.mjs`). The feature list is
|
||||
* `games/uo.mjs`, held to the module's declared capabilities by the call below, and the
|
||||
* screenshots are the ones `/features/` showed until this phase — every one tagged with its
|
||||
* game in `screens.mjs` (D67).
|
||||
*
|
||||
* Versions are read from `platform.json`, which `checkFacts.mjs` holds to the module's
|
||||
* release, the installer's bundle and both halves of the bridge.
|
||||
*/
|
||||
assertGameCoverage('uo');
|
||||
|
||||
const uo = platform.games.uo;
|
||||
const gitea = `${platform.gitea.base}/${platform.gitea.org}`;
|
||||
const docs = `${gitea}/docs/src/branch/main`;
|
||||
|
||||
const pieces = [
|
||||
{
|
||||
role: 'The module',
|
||||
name: uo.module.repo,
|
||||
version: platform.releases['Module-uo'],
|
||||
what: 'Every Ultima Online screen on the site, installed from Admin → Modules.',
|
||||
},
|
||||
{
|
||||
role: 'The sidecar',
|
||||
name: 'uo-link',
|
||||
version: uo.bundle.sidecar,
|
||||
what: 'Beside the shard, and the only part of the bridge that listens on a network.',
|
||||
},
|
||||
{
|
||||
role: 'The bridge plugin',
|
||||
name: uo.pluginRepo,
|
||||
version: uo.bundle.overlay,
|
||||
what: `An overlay compiled by ServUO at boot, on ServUO ${uo.bundle.servuoMin} or newer.`,
|
||||
},
|
||||
];
|
||||
|
||||
const title = 'The Ultima Online module';
|
||||
const description =
|
||||
'What the Ultima Online module adds to a Runic Gateway site: live shard status, the ' +
|
||||
'player-vendor marketplace, the spawn atlas, guilds, houses and more, from a running ServUO shard.';
|
||||
---
|
||||
|
||||
<Base title={title} description={description}>
|
||||
<PageHeader eyebrow="Game module" title="Ultima Online">
|
||||
<p>
|
||||
The <code>uo</code> module turns a Runic Gateway site into an Ultima Online shard’s
|
||||
site, fed live from a running ServUO shard. It was the first module, and the one the
|
||||
seam was cut around: this support was extracted out of the site, and every URL it had
|
||||
before the move it still has.
|
||||
</p>
|
||||
<p>
|
||||
The shard side is installed with the installer as one bundle —
|
||||
{' '}<strong>{uo.bundle.tag}</strong>, speaking protocol {uo.protocol} — and the site
|
||||
side is a paste in the admin panel.
|
||||
</p>
|
||||
</PageHeader>
|
||||
|
||||
<section class="page section pieces" aria-labelledby="pieces-heading">
|
||||
<h2 id="pieces-heading">The pieces</h2>
|
||||
<ul class="pieces__list">
|
||||
{
|
||||
pieces.map((piece) => (
|
||||
<li class="panel">
|
||||
<p class="pieces__role">{piece.role}</p>
|
||||
<p class="pieces__name">
|
||||
<code>{piece.name}</code>
|
||||
<span class="chip chip--version">{piece.version}</span>
|
||||
</p>
|
||||
<p class="pieces__what">{piece.what}</p>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<GameFeatures
|
||||
title="What it adds"
|
||||
lede="Everything here is published from the live shard, and every public surface passes the visibility settings first — each one is the operator's to switch on."
|
||||
items={features}
|
||||
/>
|
||||
|
||||
<section class="page section shots" aria-label="Screenshots">
|
||||
{screens.map((id) => <Screenshot id={id} />)}
|
||||
</section>
|
||||
|
||||
<section class="page section next">
|
||||
<h2>Setting it up</h2>
|
||||
<p class="prose">
|
||||
The documentation’s Ultima Online group starts from connecting a ServUO shard and ends
|
||||
at the reference for every file the bridge reads.
|
||||
</p>
|
||||
<div class="next__links">
|
||||
<a class="btn btn--primary" href="/docs/ultima-online/">The Ultima Online docs</a>
|
||||
<a class="btn btn--ghost" href={`${docs}/modules/uo/README.md`} rel="noopener noreferrer">
|
||||
The module in depth
|
||||
</a>
|
||||
<a class="btn btn--ghost" href="/modules/">All modules</a>
|
||||
</div>
|
||||
</section>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.pieces h2,
|
||||
.next h2 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: clamp(1.5rem, 3vw, 1.95rem);
|
||||
}
|
||||
|
||||
.pieces__list {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
margin: 1.25rem 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 17rem), 1fr));
|
||||
}
|
||||
|
||||
.pieces__role {
|
||||
margin: 0;
|
||||
color: var(--dim);
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.pieces__name {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
margin: 0.5rem 0 0;
|
||||
color: var(--gold);
|
||||
}
|
||||
|
||||
.pieces__what {
|
||||
margin: 0.6rem 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.94rem;
|
||||
}
|
||||
|
||||
.next p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.next__links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.7rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
</style>
|
||||
238
src/pages/runicnpc.astro
Normal file
238
src/pages/runicnpc.astro
Normal file
@@ -0,0 +1,238 @@
|
||||
---
|
||||
import Base from '../layouts/Base.astro';
|
||||
import PageHeader from '../components/PageHeader.astro';
|
||||
|
||||
import platform from '../data/platform.json';
|
||||
import { runicnpcCalls, runicnpcHooks } from '../data/runicnpc.mjs';
|
||||
|
||||
/**
|
||||
* `/runicnpc/` — PLAN.md §16, D61, built in phase 17.
|
||||
*
|
||||
* The standalone page for RunicNPC, written for the owner of a Rust server who runs no
|
||||
* Runic Gateway site at all and should be able to find and use the plugin without first
|
||||
* learning what the platform is. It is in the header (D78) for the same reason. It may use
|
||||
* Rust's words: it is one of the Rust routes in `checkTerms.mjs`.
|
||||
*
|
||||
* It says less than the docs subgroup (`/docs/rust/runicnpc/`), which phase 16 walked and
|
||||
* drift-checked, and nothing that subgroup does not. Every number is read: the release and
|
||||
* the API version and framework floors from `platform.json`, which `checkFacts.mjs` holds to
|
||||
* `runicnpc-rust`'s own `plugin.toml`, and the counts of calls and hooks from
|
||||
* `runicnpc.mjs`, which `checkReference.mjs` holds to `docs/runicnpc/API.md`.
|
||||
*/
|
||||
const npc = platform.runicnpc;
|
||||
const release = platform.releases['runicnpc-rust'];
|
||||
const repo = `${platform.gitea.base}/${platform.gitea.org}/runicnpc-rust`;
|
||||
const calls = Object.keys(runicnpcCalls).length;
|
||||
const hooks = Object.keys(runicnpcHooks).length;
|
||||
|
||||
const roles = [
|
||||
{ name: 'Roamer', does: 'Moves, chases and fights.' },
|
||||
{ name: 'Sentry', does: 'Stands still and shoots.' },
|
||||
{ name: 'Guard', does: 'Holds its spot, chases within its leash, and walks back.' },
|
||||
{
|
||||
name: 'Passive',
|
||||
does: 'Never fights. Pressing E says a line or opens a window — a quest-giver, a quartermaster.',
|
||||
},
|
||||
];
|
||||
|
||||
const title = 'RunicNPC';
|
||||
const description =
|
||||
'An NPC plugin for Rust on Oxide and Carbon: roamers, sentries, guards, quest-givers and ' +
|
||||
'bosses with phases, placed in game or from a site, with an API for other plugins. Runs on any server.';
|
||||
---
|
||||
|
||||
<Base title={title} description={description}>
|
||||
<PageHeader eyebrow="A Rust plugin from this project" title="RunicNPC">
|
||||
<p>
|
||||
NPCs where you want them, with the kit, health, aim, ranges and behaviour you give them,
|
||||
brought back after they die. One file, <code>RunicNPC.cs</code>, compiled by Oxide or
|
||||
Carbon from source like any uMod plugin — and it runs on any Rust server, whether or not
|
||||
a Runic Gateway site is anywhere near it.
|
||||
</p>
|
||||
</PageHeader>
|
||||
|
||||
<section class="page section facts" aria-label="At a glance">
|
||||
<dl class="facts__list">
|
||||
<div class="panel">
|
||||
<dt>Release</dt>
|
||||
<dd><span class="chip chip--version">{release}</span></dd>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<dt>Runs on</dt>
|
||||
<dd>Oxide {npc.oxideMin} or Carbon {npc.carbonMin}, or newer</dd>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<dt>Needs</dt>
|
||||
<dd>{npc.requiresPlugins.join(' and ')}. ZoneManager is optional.</dd>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<dt>API version</dt>
|
||||
<dd>{npc.api} — {calls} calls and {hooks} hooks</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="page section npc">
|
||||
<h2>What it does</h2>
|
||||
|
||||
<ul class="npc__roles">
|
||||
{
|
||||
roles.map((role) => (
|
||||
<li class="panel">
|
||||
<h3>{role.name}</h3>
|
||||
<p>{role.does}</p>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
|
||||
<div class="npc__grid">
|
||||
<section>
|
||||
<h3>Bosses</h3>
|
||||
<p>
|
||||
Any role but passive can be a boss: a health bar at the top of the screen, phases as
|
||||
its health falls, lines said in game, and loot of its own.
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<h3>Placements and routes</h3>
|
||||
<p>
|
||||
A placement is a spot where a profile’s NPCs stand and come back after they die. It
|
||||
survives a restart; the NPCs themselves are never saved, and each boot spawns fresh ones. A
|
||||
route is a line of points you record in game for an NPC to walk.
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<h3>Factions</h3>
|
||||
<p>
|
||||
With no settings an NPC fights players only. Profiles can be put on sides, turned on
|
||||
Rust’s scientists or its animals, or allied to a clan, a team or one player.
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<h3>A cost it tells you</h3>
|
||||
<p>
|
||||
What NPCs cost a server was measured on both frameworks, not guessed, and the
|
||||
warning saying so appears wherever NPCs are added. Caps are an operator’s setting.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="page section ways">
|
||||
<h2>Two ways to run it</h2>
|
||||
<div class="ways__grid">
|
||||
<div class="panel">
|
||||
<h3>On its own</h3>
|
||||
<p>
|
||||
Place NPCs in game with <code>/rnpc</code>, edit their profiles at the console, and
|
||||
let other plugins drive it through its API. Nothing else is needed. It makes no
|
||||
network call, downloads nothing and scans nothing at boot.
|
||||
</p>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<h3>On a Runic Gateway server</h3>
|
||||
<p>
|
||||
It is required there, and installed with the bridge from the same bundle. The site
|
||||
authors the profiles and the faction table and pushes them to each server, places
|
||||
NPCs from its live map, and runs events that place them and wait on them dying
|
||||
or on a boss’s health.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="page section next">
|
||||
<h2>Get it</h2>
|
||||
<p class="prose">
|
||||
Releases carry the plugin file. The documentation covers installing it by hand, every
|
||||
command, every profile field, and the API in full for plugin authors.
|
||||
</p>
|
||||
<div class="next__links">
|
||||
<a class="btn btn--primary" href={`${repo}/releases`} rel="noopener noreferrer">
|
||||
Download {release}
|
||||
</a>
|
||||
<a class="btn btn--ghost" href="/docs/rust/runicnpc/install/">Install it</a>
|
||||
<a class="btn btn--ghost" href="/docs/rust/runicnpc/commands/">Commands</a>
|
||||
<a class="btn btn--ghost" href="/docs/rust/runicnpc/api/">The API</a>
|
||||
<a class="btn btn--ghost" href="/modules/rust/">The Rust module</a>
|
||||
</div>
|
||||
</section>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.npc h2,
|
||||
.ways h2,
|
||||
.next h2 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: clamp(1.5rem, 3vw, 1.95rem);
|
||||
}
|
||||
|
||||
.facts__list {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
margin: 0;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr));
|
||||
}
|
||||
|
||||
.facts__list dt {
|
||||
color: var(--dim);
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.facts__list dd {
|
||||
margin: 0.5rem 0 0;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.npc__roles,
|
||||
.ways__grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
margin: 1.25rem 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr));
|
||||
}
|
||||
|
||||
.ways__grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 22rem), 1fr));
|
||||
}
|
||||
|
||||
.npc__roles h3,
|
||||
.ways__grid h3,
|
||||
.npc__grid h3 {
|
||||
margin: 0 0 0.5rem;
|
||||
color: var(--gold);
|
||||
font-size: 1.02rem;
|
||||
}
|
||||
|
||||
.npc__roles p,
|
||||
.ways__grid p,
|
||||
.npc__grid p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.94rem;
|
||||
}
|
||||
|
||||
.npc__grid {
|
||||
display: grid;
|
||||
gap: 1.4rem;
|
||||
margin-top: 2rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 22rem), 1fr));
|
||||
}
|
||||
|
||||
.next p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.next__links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.7rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
</style>
|
||||
@@ -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`
|
||||
|
||||
Reference in New Issue
Block a user