Compare commits
8 Commits
feat/phase
...
feat/phase
| Author | SHA1 | Date | |
|---|---|---|---|
| a6fc0431d7 | |||
| c29ec94f46 | |||
| 31d914ba44 | |||
| b3cb6bf1eb | |||
| d89ce06bb8 | |||
| e8cb6061fe | |||
| a993b872ac | |||
| bcb633403f |
@@ -65,6 +65,30 @@ jobs:
|
||||
# the shorter form would pass locally and break only here.
|
||||
run: npm test
|
||||
|
||||
- name: Sidebar
|
||||
# PLAN.md §12, phase 8. src/config/sidebar.mjs holds two trees — the one Starlight
|
||||
# renders and the one §10 planned — and they must agree on groups, labels and
|
||||
# ORDER. Order because the order of "Getting started" IS the installation path.
|
||||
#
|
||||
# While pages were being written the planned tree was a checklist; now that every
|
||||
# page exists it is a hand-maintained second copy, and it had already drifted
|
||||
# unnoticed (phase 7 added Content under D37 and never updated it). Nothing caught
|
||||
# that because nothing read it.
|
||||
#
|
||||
# No token, no network, no build — so it runs early and fails fast.
|
||||
run: npm run check:sidebar
|
||||
|
||||
- name: Screenshots
|
||||
# PLAN.md §12, phase 9 (D45). src/data/screens.mjs is the one list of what the site
|
||||
# shows of itself: every entry must have a file, at the size the markup declares, and
|
||||
# every file must have an entry. The size half is the one that repays the check —
|
||||
# a re-capture taken at the wrong viewport looks perfectly fine on its own and only
|
||||
# reveals itself as a page that reflows while it decodes.
|
||||
#
|
||||
# No browser and no game server: the capture tool is an authoring script whose output
|
||||
# is committed, exactly like the brand assets, so CI only reads what it produced.
|
||||
run: npm run check:screens
|
||||
|
||||
- name: Production build
|
||||
run: npm run build
|
||||
|
||||
@@ -115,3 +139,22 @@ jobs:
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: npm run check:quickstart
|
||||
|
||||
- name: Reference enumerations against their sources
|
||||
# PLAN.md §12, phase 8. The Reference section names things — every environment
|
||||
# variable, config key, installer command, visibility rung and canonical document.
|
||||
# §1 forbids re-specifying a contract, and this is what makes writing the NAMES
|
||||
# down safe anyway: each list is a SET comparison against the repository that owns
|
||||
# it, in both directions.
|
||||
#
|
||||
# The second direction is the one that earns its keep. A reference page does not
|
||||
# usually rot by describing something that vanished — it rots by quietly not
|
||||
# mentioning the three things added since it was written.
|
||||
#
|
||||
# Descriptions are deliberately NOT checked; nothing here can know whether a
|
||||
# one-line summary is still true, so it does not pretend to.
|
||||
#
|
||||
# Same token, and for the same reason: it reads five other repositories in the org.
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: npm run check:reference
|
||||
|
||||
245
PLAN.md
@@ -86,8 +86,8 @@ All values re-read from the Gitea API on **2026-08-19**, after revision 1.
|
||||
| **Current bundle** | **2026.08.19** (protocol 4, generated 09:05:52Z) | `installer` branch `bundles` → `current.json` |
|
||||
| uo-link sidecar | **v2.0.0** (2026-08-19) | release; in bundle 2026.08.19 |
|
||||
| Plugin overlay | **v1.0.0** (2026-08-19) | release; in bundle 2026.08.19 |
|
||||
| Installer | **v0.1.0** (2026-08-07) | release |
|
||||
| `module-uo` | **v1.0.1** (2026-08-19) | release |
|
||||
| Installer | **v0.1.1** (2026-08-24) | release |
|
||||
| `module-uo` | **v1.0.2** (2026-08-25) | release |
|
||||
| Android app | **v0.5.0** (2026-08-08), id `com.runicgateway.app` | release; `app/build.gradle.kts` |
|
||||
| ServUO | **57.4** — min version, and the only version the patch tier is verified against | bundle `overlay.servuo` |
|
||||
| `website` | **no releases** — ships as container images, never tagged | Gitea releases API (empty) |
|
||||
@@ -236,7 +236,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 **thirty-seven**:
|
||||
somewhere other than the thing it decided. The count of record is **forty-six**:
|
||||
|
||||
| # | Where | What it settled |
|
||||
|---|---|---|
|
||||
@@ -246,6 +246,8 @@ somewhere other than the thing it decided. The count of record is **thirty-seven
|
||||
| D26–D29 | §8, "How phase 5 built the app and the beta" | The screenshot slot reserved for phase 9, the demo as the tester target, `/beta` handling its own POST, equal billing for the APK and the beta |
|
||||
| D30–D33 | §9, "How phase 6 built the legal pages" | One logging hop and no edge provider, eighteen or older, no governing-law clause, the Data Safety notes as a generated document |
|
||||
| D34–D37 | §10, "How phase 7 built the documentation journey" | One PR for all twenty pages, a self-contained install quickstart with a drift check, every admin screen walked before it was described, a thirteenth Administration page for content |
|
||||
| D38–D41 | §10, "How phase 8 built the builder and reference docs" | One PR for all twenty pages again, Reference enumerates names and checks every one of them, the docs section links to the drawn diagrams rather than importing them, `plannedSidebar` becomes a checked invariant |
|
||||
| D42–D46 | §10, "How phase 9 took the screenshots" | The full rig behind the imagery, a neutral demo brand, the captures beside the claims, a committed and checked capture pipeline, the world dressed in the plugin repo's scaffolding |
|
||||
|
||||
---
|
||||
|
||||
@@ -507,6 +509,11 @@ same day. `src/components/app/Screenshots.astro` exists now, rendering nothing,
|
||||
data change rather than a design task. Rejected: shipping the fourteen, and pulling phase 9's rig
|
||||
forward into phase 5.
|
||||
|
||||
*Filled in phase 9:* six captures, from an emulator pointed at the same seeded deployment the web
|
||||
screenshots came from, on the same day — see "How phase 9 took the screenshots" in §10. The
|
||||
component now reads `src/data/screens.mjs` rather than a list of its own, which is what made it a
|
||||
data change in the end.
|
||||
|
||||
**D27 — the public demo is the tester target, so the beta waits for it.** `ConnectScreen.kt` on
|
||||
`Android-app` `main` is unambiguous — nothing in the app runs until a valid Runic Gateway site has
|
||||
been entered and validated — so an installed app with no deployment behind it is a text field. The
|
||||
@@ -669,7 +676,7 @@ Organised by what a reader is trying to do. A reader should never need to know t
|
||||
| `/architecture/` | The system explained visually, for a technical evaluator deciding whether to run it |
|
||||
| `/modules/` | What a module is, `module-uo` as the worked example, writing your own, the Integration Kit (draft-badged per D8) |
|
||||
| `/integrations/` | Discord, mobile + ntfy push, SSO — with an explicit "not built" list |
|
||||
| `/app/` | The Android app: what it does, the signed-APK download beside the beta CTA, and a screenshot slot **phase 9 fills** (D26 — the 14 existing screenshots are the wrong fourteen) |
|
||||
| `/app/` | The Android app: what it does, the signed-APK download beside the beta CTA, and six phone captures **phase 9 filled** (D26 — the 14 existing screenshots were the wrong fourteen) |
|
||||
| `/beta/` | The closed-beta signup (§8). The one page that handles its own POST (D28) |
|
||||
| `/community/` | Discord (`discord.gg/t2Jav8yT4g`) as the front door, the Gitea org for code and contributions, the `brand.json` contact address for vulnerabilities (D13) — the split in §14 N3 |
|
||||
| `/privacy/`, `/terms/` | §9 |
|
||||
@@ -837,7 +844,7 @@ Reference Environment variables · Installer CLI · sidecar.toml ·
|
||||
Bridge.cfg · HTTP API · Event catalog · Canonical documents
|
||||
```
|
||||
|
||||
Roughly 38 pages — 37 planned, plus the Content page D37 added in phase 7. Every Reference page is a **navigable summary plus a link to the canonical
|
||||
**Forty pages** — thirty-nine planned, plus the Content page D37 added in phase 7. (This said "roughly 38, 37 planned" until phase 8 counted the tree: 7 + 13 + 8 + 5 + 7. `checkSidebar.mjs` now keeps the count honest.) Every Reference page is a **navigable summary plus a link to the canonical
|
||||
document** — never a re-specification, per §1.
|
||||
|
||||
### The installation path
|
||||
@@ -909,12 +916,21 @@ Hero editor into Branding and theming, Web Bot Activity into Authentication.
|
||||
it is **missing from website's root `.env.example`**, the file Compose actually reads. It is
|
||||
present in `server/.env.example`, which is the file local development copies, which is why this
|
||||
has never bitten anyone in dev. The quickstart carries it, declared as an upstream omission so the
|
||||
check fails the day it is fixed.
|
||||
check fails the day it is fixed. **Fixed in website#163** (merged 2026-08-24), which also adds
|
||||
`BOT_INTERNAL_KEY` to the README's "set at least" list — required in production even on a
|
||||
deployment running no bot. The declaration did exactly what it was built to do: this repo went red
|
||||
on the next run, and the entry is deleted here.
|
||||
- **The installer points operators at a screen that no longer exists.** It prints
|
||||
`<site>/admin/shard`, and INSTALL.md §5 repeats it. Since the module-system cutover a module owns
|
||||
one path segment, and the screen is **`/admin/uo/link`**, labelled *Shard (uo-link)*. Both the
|
||||
binary (`installer` `main:src/sidecar.rs`) and the guide are stale; the journey documents the real
|
||||
path and says the printed one does not resolve.
|
||||
one path segment, and the screen is **`/admin/uo/link`**, labelled *Shard (uo-link)*. The old path
|
||||
does not even 404 — the SPA sends the operator to the dashboard, so the link looks like it worked
|
||||
and the four values have nowhere to go. **Fixed in installer#22** (the path is a named constant and
|
||||
both handoff tests assert it) **and docs#174**, both merged 2026-08-24, and shipped in installer
|
||||
**v0.1.1**. Getting there found a fourth defect, in `installer`'s release pipeline: the run for the
|
||||
fix built every artifact and pushed tag `v0.1.1`, then took a `500` from `POST /releases` one
|
||||
second later, leaving an orphan tag and no binaries. Re-running the workflow published it — the
|
||||
failure was a race with the tag push, not a structural one — so the note here names v0.1.0 as the
|
||||
version that prints the old path rather than describing the installer as currently wrong.
|
||||
- **The admin "Restart the server" button opens a `window.confirm`.** Its text is the honest
|
||||
warning that a deployment with no supervisor does not come back — which is exactly why
|
||||
`restart: unless-stopped` is called out as load-bearing on the install page rather than left as
|
||||
@@ -929,6 +945,206 @@ and phase 6 (the card void) for the same lesson.
|
||||
|
||||
---
|
||||
|
||||
### How phase 8 built the builder and reference docs
|
||||
|
||||
Twenty more pages — Modules (8), Architecture (5), Reference (7) — completing the tree §10
|
||||
planned. Four decisions, taken by the org lead before anything was written.
|
||||
|
||||
**D38 — one PR for all twenty pages, again.** The alternative on the table was splitting the
|
||||
prose (Modules + Architecture) from Reference, since only Reference needed new checking
|
||||
machinery. Rejected for the same reason D34 was: the three sections cross-reference each
|
||||
other heavily, and a split means either landing pages whose links point at nothing yet or
|
||||
writing the links twice.
|
||||
|
||||
**D39 — Reference enumerates the NAMES, and checks every one of them.** This is the phase's
|
||||
central decision, because §1 forbids re-specifying a contract and a Reference section is
|
||||
exactly where that rule is most tempting to break.
|
||||
|
||||
The line drawn: **names are on the page, semantics are not.** Every environment variable,
|
||||
config key, installer command, visibility rung and canonical document is listed, with one
|
||||
terse line saying what it is *for*. Shapes, defaults that matter, interactions and every
|
||||
"why" stay in the canonical document.
|
||||
|
||||
That is only safe because `scripts/checkReference.mjs` compares each list against the
|
||||
repository that owns it — six sources, over the Gitea API, never from a working tree — as a
|
||||
**set comparison in both directions**. The second direction is the one that earns its keep:
|
||||
a reference page does not usually rot by describing something that vanished, it rots by
|
||||
quietly not mentioning the three things added since it was written.
|
||||
|
||||
The alternative considered was strict summary-plus-link with nothing enumerated. It needs no
|
||||
machinery and cannot rot — but a Reference section that cannot answer "what variables are
|
||||
there?" without a click-through is a link farm, and the checking machinery turned out to be
|
||||
one script.
|
||||
|
||||
Descriptions are deliberately **not** checked, and the script says so. Nothing can know
|
||||
whether a one-line summary is still true; keeping them short enough to re-read is the
|
||||
mitigation, not a check.
|
||||
|
||||
**D40 — the docs link to the drawn diagrams rather than importing them.** `/architecture/`'s
|
||||
three diagrams are Astro components carrying marketing chrome and depending on
|
||||
`src/styles/diagram.css`, which Starlight does not load. Reusing them inside the docs would
|
||||
have coupled the two layouts for one page's benefit. The docs use text diagrams in code
|
||||
blocks — which are also copy-pasteable into an issue — and link out to the drawn versions.
|
||||
|
||||
**D41 — `plannedSidebar` stops being a checklist and becomes a checked invariant.** It was
|
||||
written in phase 1 so phases 7 and 8 had their checklist where they would be working. With
|
||||
every page now written it is a second, hand-maintained copy of the live tree, which is the
|
||||
exact shape §1 warns about — so `checkSidebar.mjs` asserts the two agree on groups, labels
|
||||
**and order**.
|
||||
|
||||
Order, because the order of "Getting started" *is* the installation path, and a reordering
|
||||
nobody noticed would be a worse defect than a missing page.
|
||||
|
||||
**What the checks found, before any of the pages shipped.**
|
||||
|
||||
- **`plannedSidebar` had already drifted.** Phase 7 added the Content page under D37 and
|
||||
never updated the planned list. Nothing failed, because nothing read it — which is the
|
||||
whole argument for D41. Reproduced by deleting the entry again and watching the new check
|
||||
catch it.
|
||||
- **The page count in this document was wrong**, and had been since §10 was written: it said
|
||||
"roughly 38 — 37 planned", where the tree it describes is forty.
|
||||
- **`module.json`'s `mounts` and the SPA's paths are different mechanisms**, which is not
|
||||
stated plainly in any one place. `module-uo` declares `admin: ["/shard", "/uo-link"]` and
|
||||
its screen lives at `/admin/uo/link`; API routes are deliberately *not* namespaced while
|
||||
SPA routes are. That is the distinction the installer got wrong in v0.1.0, and it now has
|
||||
a named home on *The module system*.
|
||||
|
||||
**The check was verified by breaking it, not by watching it pass.** It went green on its
|
||||
first run, which is the least trustworthy possible outcome, so seven mutations were fed
|
||||
through it — a stale name, an omitted name, a renamed key in each of three sources, a
|
||||
canonical document that moved, and the visibility ladder **reordered with its membership
|
||||
unchanged**. All seven failed the build. The ladder case is the one worth keeping: it is a
|
||||
security boundary, and a set comparison alone would have passed it.
|
||||
|
||||
---
|
||||
|
||||
### How phase 9 took the screenshots
|
||||
|
||||
D4 said real screenshots from the review stack rather than placeholders, and left the how
|
||||
open. Five decisions settled it, taken by the org lead before the rig was built.
|
||||
|
||||
**D42 — the full rig: a real shard, a real sidecar, a real site.** ServUO with the bridge
|
||||
overlay on this machine, the Rust sidecar beside it, `website` `main` with `module-uo`
|
||||
installed, and the demo database seeded on top for what a fresh shard cannot produce.
|
||||
|
||||
The alternatives were cheaper and both of them lie a little. Sidecar-only screenshots the
|
||||
degraded state — a reachable bridge with nothing behind it. Everything-database-seeded
|
||||
produces pages that look identical to the real thing and were produced by nothing: the
|
||||
marketplace would be rows somebody typed. This is the one option where the marketplace rows
|
||||
are player vendors the game actually holds, the atlas is parsed from the shard's own spawn
|
||||
files, and "Candlewick House is now IDOC" happened.
|
||||
|
||||
**D43 — a neutral demo brand.** The deployment is "Runic Gateway Demo", not UOMysticmoon.
|
||||
The screenshots show the platform rather than one private community, which is the same
|
||||
instinct as D27's refusal to publicise a real shard — and §15's demo instance can wear this
|
||||
identity the day it exists, so the imagery stays true rather than becoming a period piece.
|
||||
The name says "Demo" deliberately: nobody should have to wonder whether they are looking at
|
||||
a server they could join.
|
||||
|
||||
**D44 — the captures sit beside the claims they support, in two places.** A figure set on
|
||||
`/features/`, one on the homepage, and inline shots on the phase-7 administration pages that
|
||||
describe a screen in prose. Eleven web captures.
|
||||
|
||||
The administration pages are where a screenshot does the most work, because phase 7
|
||||
described thirteen screens it could not show. A dedicated `/screenshots/` gallery was
|
||||
rejected for the reason galleries usually are: a page nobody visits does less than a figure
|
||||
sitting under the sentence it proves.
|
||||
|
||||
**D45 — the rig is committed, not remembered.** Three files rather than a folder of images:
|
||||
`scripts/seedDemo.mjs` puts the content there by driving the site's own API,
|
||||
`src/data/screens.mjs` declares every capture with its route, viewport, scroll offset and
|
||||
caption, and `scripts/captureScreens.mjs` turns the second into files.
|
||||
`scripts/checkScreens.mjs` is the ninth check script and runs in CI.
|
||||
|
||||
The argument is the same one D35 made for the install quickstart: the way real screenshots
|
||||
rot is that the recipe for taking them lives in somebody's memory. Re-taking the set after a
|
||||
redesign is now `npm run screens:capture`, and the check fails if an entry has no file, a
|
||||
file is the wrong size, a file is orphaned, or a declared screen is rendered nowhere.
|
||||
|
||||
**Why the seed drives the API and never the database.** Every row it creates could have been
|
||||
an `INSERT`, and every `INSERT` would be a second implementation of a rule the website owns —
|
||||
how a body is sanitized, which excerpt is derived, how a password is hashed. A seed that
|
||||
writes SQL produces a database the product could not have produced, and screenshots of that
|
||||
database show a product that does not exist.
|
||||
|
||||
**D46 — the world gets dressed in `servuo-plugins/tools`.** `BridgeSeeder` builds a world at
|
||||
realistic scale; it never needed the world to look like anything, so a vendor traded as
|
||||
"Seed Shop 810" and a character was "Seed004A" — and every one of those strings travels the
|
||||
whole bridge and lands on the marketplace, the guild roster and the housing pages.
|
||||
`BridgeDemoDress` renames them in place and seeds nothing, drawing names from fixed tables
|
||||
hashed off each object's serial, so a re-run reproduces the same world and a screenshot can
|
||||
be retaken later and still match.
|
||||
|
||||
**What the rig found.** A screenshot rig is an integration test with a human in the loop, and
|
||||
this one turned up six things nothing else had:
|
||||
|
||||
- **A fresh `module-uo` install pinned wire protocol 3 while the sidecar speaks 4**, so a new
|
||||
deployment 409s on every shard read until an admin edits the number by hand. The protocol-4
|
||||
cutover bumped `link`, `servuo-plugins` and `docs` and missed the module's own default.
|
||||
Fixed upstream and released as `module-uo` **v1.0.2** — which is what this repository's own
|
||||
facts check then noticed, since `platform.json` still said v1.0.1.
|
||||
- **A renamed guild member never reaches the site.** The plugin folds name, abbreviation,
|
||||
leader, member count and alliance into the signature it compares, and re-emits the roster
|
||||
only when the member *set* changes — so renaming a member leaves the published roster stale
|
||||
indefinitely.
|
||||
- **A guild deleted while the shard is offline is a ghost row forever.** The "gone" pass
|
||||
compares against a cache that is cleared on reconnect, so nothing emits `guild.remove`. The
|
||||
demo's guild board was showing two guilds the world no longer had, a week after they went.
|
||||
- **"Houses in danger" cannot show a house that was already collapsing.** The ingest writes
|
||||
that column only from the `house.decay` transition feed, while the registry frame's stage is
|
||||
deliberately left alone so the two cannot clobber each other. A house already in IDOC when
|
||||
the site connects is therefore invisible — the page said none while the shard had two.
|
||||
- **The Android news list prints raw ISO timestamps.** Found while choosing the phone
|
||||
captures; the news screen was dropped from that set rather than shipping a picture of it.
|
||||
- **The app says "1 players online".** `shard_online_count` and `ShardEventText.kt` both
|
||||
interpolate a count into a fixed plural. Found in the retake after a character was signed in,
|
||||
and it is in the shipped phone capture — a `plurals` resource is the fix, in the app.
|
||||
|
||||
The first is fixed. The rest are raised as product observations, with the rig working around
|
||||
them: the guilds are built *after* the rename, and the IDOC staging is two passes with a wait
|
||||
between them so the site watches the collapse happen. All of that is scaffolding under
|
||||
`servuo-plugins/tools/`, which is never deployed.
|
||||
|
||||
**The emulator pass (D26), and the AVD that would not take it.** The six phone captures come
|
||||
from an emulator pointed at the same deployment on the same day, signed in as an ordinary
|
||||
player, reached through `adb reverse` — the app's debug network policy permits cleartext to
|
||||
`localhost` only, which is a better default than the one that would have made `10.0.2.2`
|
||||
work. The device is API 35 rather than the API 36 the plan named: the API 36 image on this
|
||||
machine had 200 MB free and refused the install, and wiping somebody's development device to
|
||||
take a screenshot is not a trade worth making.
|
||||
|
||||
The shard screen is the one worth having. It shows the two houses entering IDOC in its live
|
||||
activity feed — the same event that reached `/uo/houses` in the browser, on the same rig, in
|
||||
the same minute.
|
||||
|
||||
**The character that had to be logged in by hand.** The org lead asked for a player in the
|
||||
world, and the scaffolding does its half — it sets a known password on a seeded account,
|
||||
because `BridgeSeeder` gives every account a random GUID nobody kept. Driving the client is
|
||||
where automation stopped. ClassicUO stores its password crypted, so a plaintext one in
|
||||
`settings.json` decrypts to garbage and auto-login fails; posted mouse clicks reach the client
|
||||
but posted text does not; and the remaining route — taking the foreground and typing — was
|
||||
tried once, failed to take focus, and typed into the browser window the person at this machine
|
||||
was using. It was not tried again.
|
||||
|
||||
The org lead signed in instead, and the two frames that depended on it were retaken: the shard
|
||||
page now reads **1 player online, in Britain**, and the app's shard card agrees. Both came from
|
||||
the same `npm run screens:capture shard-status app-shard`, which is the whole point of D45 —
|
||||
the thing that changed was the world, not the recipe.
|
||||
|
||||
Two things that pass is worth noticing here. Presence reaches the public page as **counts and
|
||||
regions, not names**, which is the visibility framework doing its job unprompted. And the
|
||||
**guild board's "online" column did not move**: it is refreshed only when a guild's signature
|
||||
changes, which is the same defect as the stale roster above wearing a different hat.
|
||||
|
||||
**A layout decision worth recording.** The `/features/` figures are one-up at the column's
|
||||
full width, not a two-column grid. Two-up was built first and is the obvious layout for a set
|
||||
of figures — but these are screenshots of a dense interface, and halving the width puts the
|
||||
product's own type at about a third of its real size, which reads as a thumbnail of something
|
||||
rather than a picture of it. A long section of legible evidence beats a tidy grid of
|
||||
unreadable tiles.
|
||||
|
||||
---
|
||||
|
||||
## 11. Visual direction
|
||||
|
||||
**"Modern infrastructure software with an arcane identity."** Dark-first. Marketing pages are
|
||||
@@ -1027,6 +1243,13 @@ a mechanism rather than diligence:
|
||||
disagreement. Two-directional, like `PLANNED_ROUTES`: a value that drifts fails, **and** a service
|
||||
or variable that appears upstream fails until it is either included or recorded as deliberately
|
||||
omitted with a reason. Its own first run found two stale entries.
|
||||
- **`scripts/checkScreens.mjs`** — added in phase 9 for D45. `src/data/screens.mjs` is the one
|
||||
list of what the site shows of itself, and this proves every entry has a file at the size the
|
||||
markup declares, that nothing in `public/screens/` is orphaned, and that every declared
|
||||
capture is rendered somewhere. The size half is the one that repays it: a re-capture taken at
|
||||
the wrong viewport looks perfectly fine on its own and only reveals itself as a page that
|
||||
reflows while it decodes. No browser and no game server — the capture tool is an authoring
|
||||
script whose output is committed, exactly like the brand assets.
|
||||
- **`scripts/checkTokens.mjs`** — no colour literal outside the token file (§7).
|
||||
- `astro check` plus a production build, in CI on every PR.
|
||||
|
||||
@@ -1044,10 +1267,10 @@ a mechanism rather than diligence:
|
||||
| **5** | The app and the beta: `/app/`, `/beta/`, the signup handler, the SQLite store, rate limiting, the export CLI (§8). **Also the repository's first `node --test` suite**, and phase 9 inherits an emulator pass (D26) |
|
||||
| **6** | Legal: `/privacy/`, `/terms/`, footer links, and the Play Data Safety notes (§9) |
|
||||
| **7** | Docs — the journey: Getting started (7) + Administration (**13**, per D37) — twenty pages in one PR (D34), with the install page self-contained and drift-checked (D35) and every admin screen walked before it was described (D36). **The installation path is the priority of the whole project** |
|
||||
| **8** | Docs — builder and reference: Modules (8) + Architecture (5) + Reference (7) |
|
||||
| **8** | Docs — builder and reference: Modules (8) + Architecture (5) + Reference (7) — twenty pages in one PR (D38), with Reference enumerating names and **checking every one of them** against its source (D39), and `plannedSidebar` becoming a checked invariant (D41) |
|
||||
| **9** | Screenshots (D4): stand up the local review stack, seed presentable content, capture the admin panel, Teams, forums, marketplace, spawn atlas and shard console; build the screenshot components. **Plus an emulator pass against the same seeded stack** to fill `/app/`'s reserved slot (D26) |
|
||||
| **10** | Polish: responsive, accessibility, SEO/OpenGraph/sitemap/robots, full-text search, CSP headers |
|
||||
| **11** | Validation: `astro check`, production build, **all six check scripts** (tokens, brand, links, facts, quickstart, data safety), mobile layout verified in a real browser, a signup walked end to end |
|
||||
| **11** | Validation: `astro check`, production build, **all nine check scripts** (tokens, brand, links, facts, quickstart, data safety, reference, sidebar, screens), mobile layout verified in a real browser, a signup walked end to end |
|
||||
| **12** | Delivery: Dockerfile, `docker-compose.yml` with both bind mounts documented, Gitea Actions workflow publishing to the registry, README, CONTRIBUTING with the AI-disclosure requirement, and an operator note covering DNS, TLS and the reverse proxy (D6) |
|
||||
|
||||
Phases 5 and 6 are deliberately adjacent and early: the beta cannot start without `/privacy`, and
|
||||
|
||||
838
package-lock.json
generated
11
package.json
@@ -20,11 +20,15 @@
|
||||
"check:links": "node scripts/checkLinks.mjs",
|
||||
"check:datasafety": "node scripts/playDataSafety.mjs --check",
|
||||
"check:quickstart": "node scripts/checkQuickstart.mjs",
|
||||
"check:reference": "node scripts/checkReference.mjs",
|
||||
"check:sidebar": "node scripts/checkSidebar.mjs",
|
||||
"check:screens": "node scripts/checkScreens.mjs",
|
||||
"play:datasafety": "node scripts/playDataSafety.mjs",
|
||||
"beta": "node scripts/beta.mjs",
|
||||
"test": "node --test test/beta.test.mjs test/legal.test.mjs",
|
||||
"brand:assets": "node scripts/buildBrandAssets.mjs",
|
||||
"verify": "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"
|
||||
"screens:capture": "node scripts/captureScreens.mjs",
|
||||
"verify": "npm run check:sidebar && 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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/node": "^11.1.4",
|
||||
@@ -38,7 +42,8 @@
|
||||
"devDependencies": {
|
||||
"@astrojs/check": "^0.9.10",
|
||||
"opentype.js": "^2.0.0",
|
||||
"yaml": "^2.8.1",
|
||||
"typescript": "^6.0.3"
|
||||
"puppeteer-core": "^23.11.1",
|
||||
"typescript": "^6.0.3",
|
||||
"yaml": "^2.8.1"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
public/screens/admin-appearance.webp
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
public/screens/admin-dashboard.webp
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
public/screens/admin-modules.webp
Normal file
|
After Width: | Height: | Size: 73 KiB |
BIN
public/screens/admin-shard.webp
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
public/screens/admin-users.webp
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
public/screens/app-account.webp
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
public/screens/app-drawer.webp
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
public/screens/app-home.webp
Normal file
|
After Width: | Height: | Size: 136 KiB |
BIN
public/screens/app-market.webp
Normal file
|
After Width: | Height: | Size: 89 KiB |
BIN
public/screens/app-shard.webp
Normal file
|
After Width: | Height: | Size: 111 KiB |
BIN
public/screens/app-wiki.webp
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
public/screens/guilds.webp
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
public/screens/houses.webp
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
public/screens/marketplace.webp
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
public/screens/news.webp
Normal file
|
After Width: | Height: | Size: 73 KiB |
BIN
public/screens/shard-status.webp
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
public/screens/spawn-atlas.webp
Normal file
|
After Width: | Height: | Size: 58 KiB |
189
scripts/captureScreens.mjs
Normal file
@@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* captureScreens.mjs — retakes the screenshots in `src/data/screens.mjs`.
|
||||
*
|
||||
* PLAN.md §13 phase 9, D4 / D45.
|
||||
*
|
||||
* node scripts/captureScreens.mjs # every web screen
|
||||
* node scripts/captureScreens.mjs shard-status admin-users
|
||||
* RG_DEMO=http://localhost:3000 node scripts/captureScreens.mjs
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* AN AUTHORING TOOL, LIKE buildBrandAssets.mjs — NOT A CHECK
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* This never runs in CI and CI never needs it: its output is committed, because the site
|
||||
* must build from a clean checkout with no game server, no database and no browser. What
|
||||
* CI runs is `checkScreens.mjs`, which only reads the files this produced.
|
||||
*
|
||||
* It exists because D4 asks for real screenshots of a real deployment, and the way real
|
||||
* screenshots rot is that the recipe for taking them lives in somebody's memory. The rig
|
||||
* is written down in PLAN.md §13; the framing — route, viewport, scroll offset, whether to
|
||||
* sign in — is written down in `screens.mjs`; and this turns the two into files.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHY puppeteer-core AND NOT puppeteer
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* `puppeteer` downloads its own Chromium — a hundred-odd megabytes fetched on every clean
|
||||
* install of a repository that needs a browser once per redesign. `puppeteer-core` drives
|
||||
* a Chrome that is already on the machine, which every machine that can look at this site
|
||||
* has. Point `RG_CHROME` at it if it is somewhere unusual.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHY IT SIGNS IN THROUGH THE API RATHER THAN THE LOGIN FORM
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* The administration screens need a session, and typing into the login form is the part of
|
||||
* a browser script most likely to break on a redesign — a moved field, a renamed button, a
|
||||
* React input that ignores synthetic typing. The session cookie is the only thing actually
|
||||
* wanted, so this asks the API for one from inside the page and lets the browser store it.
|
||||
* If that call stops returning 200 the script says so and stops, rather than quietly
|
||||
* screenshotting a login screen twelve times.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, readdirSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import sharp from 'sharp';
|
||||
|
||||
import { screens, screensOf, WEB } from '../src/data/screens.mjs';
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const OUT = path.join(HERE, '..', 'public', 'screens');
|
||||
|
||||
const BASE = (process.env.RG_DEMO || 'http://localhost:3000').replace(/\/+$/, '');
|
||||
const USER = process.env.RG_ADMIN_USER || 'demoadmin';
|
||||
const PASS = process.env.RG_ADMIN_PASS || 'DemoReview!2026';
|
||||
|
||||
/** Where Chrome usually is, per platform. First hit wins; `RG_CHROME` beats all of them. */
|
||||
const CHROME_CANDIDATES = [
|
||||
process.env.RG_CHROME,
|
||||
'C:/Program Files/Google/Chrome/Application/chrome.exe',
|
||||
'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/usr/bin/google-chrome',
|
||||
'/usr/bin/chromium',
|
||||
].filter(Boolean);
|
||||
|
||||
const wanted = process.argv.slice(2).filter((arg) => !arg.startsWith('-'));
|
||||
const todo = screensOf('web').filter((shot) => wanted.length === 0 || wanted.includes(shot.id));
|
||||
|
||||
if (todo.length === 0) {
|
||||
const known = screens.map((shot) => shot.id).join(', ');
|
||||
console.error(`Nothing to capture. Known ids: ${known}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const chrome = CHROME_CANDIDATES.find((candidate) => existsSync(candidate));
|
||||
|
||||
if (!chrome) {
|
||||
console.error(
|
||||
'No Chrome found. Set RG_CHROME to the browser executable — this script drives an\n' +
|
||||
'installed Chrome rather than downloading one (see the header).',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const puppeteer = (await import('puppeteer-core')).default;
|
||||
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
executablePath: chrome,
|
||||
headless: 'new',
|
||||
defaultViewport: { ...WEB.viewport, deviceScaleFactor: WEB.scale },
|
||||
// Scrollbars are the browser's furniture, not the product's, and a colour profile that
|
||||
// is not sRGB makes the palette in a screenshot disagree with the palette on the page.
|
||||
args: ['--hide-scrollbars', '--force-color-profile=srgb'],
|
||||
});
|
||||
|
||||
/**
|
||||
* One page per privilege level rather than signing in and out around each shot: signing
|
||||
* out is the step that gets forgotten, and a public page captured with an admin session
|
||||
* shows a navigation bar the public never sees.
|
||||
*/
|
||||
const anon = await browser.newPage();
|
||||
const admin = await browser.newPage();
|
||||
|
||||
await admin.goto(BASE, { waitUntil: 'domcontentloaded' });
|
||||
|
||||
const status = await admin.evaluate(
|
||||
async (username, password) => {
|
||||
const res = await fetch('/api/v1/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
return res.status;
|
||||
},
|
||||
USER,
|
||||
PASS,
|
||||
);
|
||||
|
||||
if (status !== 200) {
|
||||
console.error(
|
||||
`Could not sign in as "${USER}" at ${BASE} (HTTP ${status}).\n` +
|
||||
'Seed the demo first — see PLAN.md §13 phase 9 and scripts/seedDemo.mjs.',
|
||||
);
|
||||
await browser.close();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let failures = 0;
|
||||
|
||||
for (const shot of todo) {
|
||||
const page = shot.admin ? admin : anon;
|
||||
const url = BASE + shot.route;
|
||||
|
||||
try {
|
||||
await page.goto(url, { waitUntil: 'networkidle2', timeout: 30_000 });
|
||||
|
||||
if (shot.scrollY) {
|
||||
await page.evaluate((y) => window.scrollTo(0, y), shot.scrollY);
|
||||
}
|
||||
|
||||
// Live pages settle after their first paint: a shard panel fills in from an event
|
||||
// stream, a list re-sorts once its data lands. A second is cheap and the difference
|
||||
// between a screenshot of the product and a screenshot of its loading state.
|
||||
await new Promise((resolve) => setTimeout(resolve, 1200));
|
||||
|
||||
const png = await page.screenshot({ type: 'png' });
|
||||
const file = path.join(OUT, `${shot.id}.webp`);
|
||||
|
||||
// Quality 82 is where UI text stops visibly softening; the files land near 150 KB,
|
||||
// which is what makes a page with five of them still a page and not a download.
|
||||
await sharp(png).webp({ quality: 82 }).toFile(file);
|
||||
|
||||
const meta = await sharp(file).metadata();
|
||||
|
||||
if (meta.width !== WEB.width || meta.height !== WEB.height) {
|
||||
console.error(
|
||||
` ! ${shot.id}: got ${meta.width}x${meta.height}, expected ${WEB.width}x${WEB.height}`,
|
||||
);
|
||||
failures++;
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(` + ${shot.id.padEnd(18)} ${shot.route.padEnd(20)} ${meta.width}x${meta.height}`);
|
||||
} catch (err) {
|
||||
console.error(` ! ${shot.id}: ${err.message}`);
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
|
||||
// A file left behind by a screen that has since been renamed or dropped is a file the
|
||||
// site still ships and nothing points at. Say so; do not delete somebody's work silently.
|
||||
if (wanted.length === 0) {
|
||||
const declared = new Set(screensOf('web').map((shot) => `${shot.id}.webp`));
|
||||
const phones = new Set(screensOf('phone').map((shot) => `${shot.id}.webp`));
|
||||
const orphans = readdirSync(OUT).filter((name) => !declared.has(name) && !phones.has(name));
|
||||
|
||||
if (orphans.length > 0) {
|
||||
console.log(`\nNot declared in screens.mjs, left alone: ${orphans.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n${todo.length - failures} captured, ${failures} failed.`);
|
||||
process.exit(failures > 0 ? 1 : 0);
|
||||
191
scripts/checkReference.mjs
Normal file
@@ -0,0 +1,191 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* checkReference.mjs — PLAN.md §12, added in phase 8.
|
||||
*
|
||||
* The Reference section names things: every environment variable, every config key, every
|
||||
* installer command, every canonical document. §1 forbids re-specifying a contract, and
|
||||
* this is the machinery that makes writing the NAMES down safe anyway — the same bargain
|
||||
* checkQuickstart.mjs struck for the quickstart, applied to six more sources.
|
||||
*
|
||||
* Each enumeration in `src/data/reference.mjs` is compared against its authority, read from
|
||||
* the repository that owns it over the Gitea API — never from a working tree, per §1's
|
||||
* process rule. Every comparison is a SET comparison in both directions:
|
||||
*
|
||||
* - a name this site lists that the source no longer has fails (the reference is stale);
|
||||
* - a name the source has that this site does not list fails (the reference is
|
||||
* incomplete, which is the failure mode a hand-maintained list actually has).
|
||||
*
|
||||
* The second direction is the one that earns its keep. A reference page does not usually
|
||||
* rot by describing something that vanished — it rots by quietly not mentioning the three
|
||||
* things added since it was written.
|
||||
*
|
||||
* Descriptions are deliberately NOT checked. Nothing here can know whether a one-line
|
||||
* summary is still true, so it does not pretend to; keeping them terse is the mitigation.
|
||||
*
|
||||
* GITEA_TOKEN=<token> node scripts/checkReference.mjs
|
||||
*
|
||||
* Anonymous raw fetches fail on this instance, so the token is required. A check that
|
||||
* silently skips itself is worse than no check.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
envVars,
|
||||
sidecarConfig,
|
||||
installerCommands,
|
||||
bridgeCfg,
|
||||
visibilityLadder,
|
||||
canonicalDocs,
|
||||
} from '../src/data/reference.mjs';
|
||||
|
||||
const ROOT = fileURLToPath(new URL('..', import.meta.url));
|
||||
const platform = JSON.parse(readFileSync(path.join(ROOT, 'src/data/platform.json'), 'utf8'));
|
||||
|
||||
const BASE = platform.gitea.base;
|
||||
const ORG = platform.gitea.org;
|
||||
const TOKEN = process.env.GITEA_TOKEN?.trim();
|
||||
|
||||
const failures = [];
|
||||
const checked = [];
|
||||
const ok = (what) => checked.push(what);
|
||||
const fail = (what, detail) => failures.push({ what, detail });
|
||||
|
||||
/** Same raw-file accessor checkFacts.mjs and checkQuickstart.mjs use. */
|
||||
async function raw(repo, filePath, ref = 'main') {
|
||||
const url = `${BASE}/api/v1/repos/${ORG}/${repo}/raw/${filePath}?ref=${encodeURIComponent(ref)}`;
|
||||
const res = await fetch(url, { headers: { Authorization: `token ${TOKEN}` } });
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText} for ${url}`);
|
||||
return res.text();
|
||||
}
|
||||
|
||||
/**
|
||||
* The one comparison this whole script performs, so the failure messages are identical
|
||||
* everywhere and say which direction broke.
|
||||
*/
|
||||
function compareSets(label, mine, theirs, hint) {
|
||||
const mineSet = new Set(mine);
|
||||
const theirsSet = new Set(theirs);
|
||||
|
||||
const stale = [...mineSet].filter((k) => !theirsSet.has(k));
|
||||
const missing = [...theirsSet].filter((k) => !mineSet.has(k));
|
||||
|
||||
for (const k of stale) {
|
||||
fail(`${label}: ${k}`, `listed here, but ${hint} no longer has it — remove it, and re-read the prose around it`);
|
||||
}
|
||||
for (const k of missing) {
|
||||
fail(`${label}: ${k}`, `is in ${hint} and NOT listed here — add it, or the reference is lying by omission`);
|
||||
}
|
||||
if (!stale.length && !missing.length) ok(`${label} (${mineSet.size})`);
|
||||
}
|
||||
|
||||
/** `KEY=value` lines. Commented-out suggestions are prose about a variable, not a key. */
|
||||
const envKeysOf = (text) =>
|
||||
text
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.match(/^([A-Z][A-Z0-9_]*)=/))
|
||||
.filter(Boolean)
|
||||
.map((m) => m[1]);
|
||||
|
||||
/** `Key=value` lines from the plugin's config, same rule about comments. */
|
||||
const cfgKeysOf = (text) =>
|
||||
text
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.match(/^([A-Za-z][A-Za-z0-9]*)=/))
|
||||
.filter(Boolean)
|
||||
.map((m) => m[1]);
|
||||
|
||||
async function run() {
|
||||
if (!TOKEN) {
|
||||
console.error('checkReference: GITEA_TOKEN is not set. This check cannot run anonymously.');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
// ── 1. Environment variables ──────────────────────────────────────────────
|
||||
compareSets(
|
||||
'env',
|
||||
Object.keys(envVars),
|
||||
envKeysOf(await raw('website', '.env.example')),
|
||||
'website main:.env.example',
|
||||
);
|
||||
|
||||
// ── 2. sidecar.toml ───────────────────────────────────────────────────────
|
||||
//
|
||||
// Parsed from the serde structs rather than from a sample file, because the sample is
|
||||
// GENERATED by the binary on first run and no committed copy is authoritative. Each
|
||||
// `pub name: T` inside a `struct XCfg` is one key, and the struct name gives the section.
|
||||
const configRs = await raw('link', 'sidecar/src/config.rs');
|
||||
const sidecarKeys = [];
|
||||
for (const m of configRs.matchAll(/struct\s+(\w+)Cfg\s*\{([\s\S]*?)\n\}/g)) {
|
||||
const section = m[1].toLowerCase();
|
||||
for (const f of m[2].matchAll(/pub\s+(\w+)\s*:/g)) sidecarKeys.push(`${section}.${f[1]}`);
|
||||
}
|
||||
compareSets('sidecar.toml', Object.keys(sidecarConfig), sidecarKeys, 'link main:sidecar/src/config.rs');
|
||||
|
||||
// ── 3. Installer commands ─────────────────────────────────────────────────
|
||||
const cliRs = await raw('installer', 'src/cli.rs');
|
||||
const cmdBlock = cliRs.match(/enum\s+Command\s*\{([\s\S]*?)\n\}/);
|
||||
const cmds = cmdBlock ? [...cmdBlock[1].matchAll(/^\s*([A-Z]\w*)\s*[,{]/gm)].map((m) => m[1]) : [];
|
||||
compareSets('installer command', Object.keys(installerCommands), cmds, 'installer main:src/cli.rs');
|
||||
|
||||
// ── 4. Bridge.cfg ─────────────────────────────────────────────────────────
|
||||
const bridgeKeys = Object.values(bridgeCfg).flatMap((group) => Object.keys(group));
|
||||
compareSets(
|
||||
'Bridge.cfg',
|
||||
bridgeKeys,
|
||||
cfgKeysOf(await raw('servuo-plugins', 'overlay/Config/Bridge.cfg')),
|
||||
'servuo-plugins main:overlay/Config/Bridge.cfg',
|
||||
);
|
||||
|
||||
// ── 5. The visibility ladder ──────────────────────────────────────────────
|
||||
//
|
||||
// A security boundary, so it is checked against the module that enforces it rather than
|
||||
// against prose. The order matters as much as the membership: it is a ladder, and a
|
||||
// reader reasoning about "staff and above" needs the rungs in the right sequence.
|
||||
const vis = await raw('Module-uo', 'server/utils/shardVisibility.js');
|
||||
const ladderMatch = vis.match(/const\s+LADDER\s*=\s*\[([\s\S]*?)\]/);
|
||||
const ladder = ladderMatch
|
||||
? [...ladderMatch[1].matchAll(/'([a-z_]+)'/g)].map((m) => m[1])
|
||||
: [];
|
||||
if (ladder.length === 0) {
|
||||
fail('visibility ladder', 'could not find LADDER in Module-uo main:server/utils/shardVisibility.js');
|
||||
} else if (ladder.join(' ') !== visibilityLadder.join(' ')) {
|
||||
fail(
|
||||
'visibility ladder',
|
||||
`order or membership differs — here "${visibilityLadder.join(' → ')}", upstream "${ladder.join(' → ')}"`,
|
||||
);
|
||||
} else ok(`visibility ladder (${ladder.length} rungs, in order)`);
|
||||
|
||||
// ── 6. Canonical documents ────────────────────────────────────────────────
|
||||
//
|
||||
// Existence only. A link to a document that moved is the single most likely way this
|
||||
// section breaks, and it is exactly what a build can answer.
|
||||
for (const docPath of Object.keys(canonicalDocs)) {
|
||||
const url = `${BASE}/api/v1/repos/${ORG}/docs/contents/${docPath}?ref=main`;
|
||||
const res = await fetch(url, { headers: { Authorization: `token ${TOKEN}` } });
|
||||
if (res.ok) ok(`canonical doc ${docPath}`);
|
||||
else fail(`canonical doc ${docPath}`, `not found in docs main (HTTP ${res.status})`);
|
||||
}
|
||||
|
||||
// ── Report ────────────────────────────────────────────────────────────────
|
||||
if (failures.length === 0) {
|
||||
console.log(`checkReference: ${checked.length} enumeration check(s) passed against their sources.`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(`\ncheckReference: ${failures.length} disagreement(s) with the platform:\n`);
|
||||
for (const f of failures) console.error(` ✗ ${f.what}\n ${f.detail}`);
|
||||
console.error(`
|
||||
The Reference section names things, which is only safe while the names are checked
|
||||
(§1, and the same bargain checkQuickstart.mjs struck). Update src/data/reference.mjs
|
||||
to match the source. Do not "fix" the check.
|
||||
`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
run().catch((err) => {
|
||||
console.error(`checkReference: ${err.message}`);
|
||||
process.exit(2);
|
||||
});
|
||||
171
scripts/checkScreens.mjs
Normal file
@@ -0,0 +1,171 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* checkScreens.mjs — the screenshots agree with what the pages say about them.
|
||||
*
|
||||
* PLAN.md §12, §13 phase 9, D45.
|
||||
*
|
||||
* node scripts/checkScreens.mjs
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHAT IT PROVES, AND WHY EACH ONE IS WORTH A CHECK
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* 1. EVERY DECLARED SCREEN HAS A FILE. A missing image is invisible in review — the page
|
||||
* still builds, still lays out, and only a reader sees the broken frame.
|
||||
*
|
||||
* 2. EVERY FILE IS THE DECLARED SIZE. `width` and `height` reach the markup as intrinsic
|
||||
* attributes, and an attribute that disagrees with the file is a page that jumps as the
|
||||
* image decodes. It also catches a re-capture taken at the wrong viewport, which looks
|
||||
* fine on its own and wrong beside the others.
|
||||
*
|
||||
* 3. NOTHING IN public/screens IS ORPHANED. A capture that stopped being referenced is a
|
||||
* file the container still ships and nobody looks at — and, worse, one that never gets
|
||||
* retaken, so it silently becomes the oldest thing in the repository.
|
||||
*
|
||||
* 4. EVERY DECLARED SCREEN IS ACTUALLY USED. The mirror of 3: an entry in `screens.mjs`
|
||||
* that no page renders is a capture being maintained for nothing. Usage is a literal
|
||||
* search for the id across `src/`, which is how both readers of the data refer to one —
|
||||
* `<Screenshot id="admin-users" />` and the `groupScreens` map on `/features/`.
|
||||
*
|
||||
* 5. THE ALT TEXT AND CAPTION SAY SOMETHING. An empty alt on an editorial image is an
|
||||
* accessibility failure the build cannot otherwise see, and a caption is the sentence
|
||||
* that makes a screenshot evidence rather than decoration.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHY IT READS THE PNG HEADER ITSELF
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* It does not: it reads the WebP header, and it does it with twenty lines rather than a
|
||||
* dependency. `sharp` is already here for the brand assets and could answer this, but this
|
||||
* check runs in CI on every pull request and a check that needs a native image library to
|
||||
* tell you a file is 1920 pixels wide is a check that will one day fail for a reason that
|
||||
* has nothing to do with screenshots.
|
||||
*/
|
||||
|
||||
import { readdirSync, readFileSync, existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { screens, WEB, PHONE } from '../src/data/screens.mjs';
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.join(HERE, '..');
|
||||
const DIR = path.join(ROOT, 'public', 'screens');
|
||||
const SRC = path.join(ROOT, 'src');
|
||||
|
||||
const problems = [];
|
||||
|
||||
/**
|
||||
* The pixel size of a WebP file, from its header.
|
||||
*
|
||||
* A RIFF container: "RIFF" size "WEBP" then one of three chunk types. Lossy ("VP8 ") and
|
||||
* lossless ("VP8L") pack the dimensions differently, and an animated or extended file
|
||||
* ("VP8X") states them outright. `cwebp` at quality 82 writes VP8 , but a future change of
|
||||
* encoder should not turn this check into a mystery, so all three are handled.
|
||||
*/
|
||||
function webpSize(file) {
|
||||
const buf = readFileSync(file);
|
||||
|
||||
if (buf.length < 30 || buf.toString('ascii', 0, 4) !== 'RIFF' || buf.toString('ascii', 8, 12) !== 'WEBP') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const chunk = buf.toString('ascii', 12, 16);
|
||||
|
||||
if (chunk === 'VP8X') {
|
||||
return {
|
||||
width: 1 + (buf[24] | (buf[25] << 8) | (buf[26] << 16)),
|
||||
height: 1 + (buf[27] | (buf[28] << 8) | (buf[29] << 16)),
|
||||
};
|
||||
}
|
||||
|
||||
if (chunk === 'VP8L') {
|
||||
const bits = buf[21] | (buf[22] << 8) | (buf[23] << 16) | (buf[24] << 24);
|
||||
return { width: 1 + (bits & 0x3fff), height: 1 + ((bits >> 14) & 0x3fff) };
|
||||
}
|
||||
|
||||
if (chunk === 'VP8 ') {
|
||||
return {
|
||||
width: buf.readUInt16LE(26) & 0x3fff,
|
||||
height: buf.readUInt16LE(28) & 0x3fff,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Every file under `src/`, read once, so usage is a search rather than a guess. */
|
||||
function sourceText() {
|
||||
const out = [];
|
||||
|
||||
const walk = (dir) => {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) walk(full);
|
||||
else if (/\.(astro|mdx?|mjs|js|ts|tsx)$/.test(entry.name)) out.push(readFileSync(full, 'utf8'));
|
||||
}
|
||||
};
|
||||
|
||||
walk(SRC);
|
||||
return out;
|
||||
}
|
||||
|
||||
const sources = sourceText();
|
||||
const declared = new Set();
|
||||
|
||||
for (const shot of screens) {
|
||||
const name = `${shot.id}.webp`;
|
||||
const file = path.join(DIR, name);
|
||||
declared.add(name);
|
||||
|
||||
if (!existsSync(file)) {
|
||||
problems.push(
|
||||
`${shot.id}: no file at public/screens/${name}. ` +
|
||||
`Retake it: node scripts/captureScreens.mjs ${shot.id}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const want = shot.family === 'web' ? WEB : PHONE;
|
||||
const size = webpSize(file);
|
||||
|
||||
if (!size) {
|
||||
problems.push(`${shot.id}: public/screens/${name} is not a WebP this check can read.`);
|
||||
} else if (size.width !== want.width || size.height !== want.height) {
|
||||
problems.push(
|
||||
`${shot.id}: file is ${size.width}x${size.height}, ` +
|
||||
`declared ${want.width}x${want.height} for the "${shot.family}" family.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!shot.alt || shot.alt.length < 20) {
|
||||
problems.push(`${shot.id}: alt text is missing or too short to describe the screen.`);
|
||||
}
|
||||
|
||||
if (!shot.caption) {
|
||||
problems.push(`${shot.id}: no caption.`);
|
||||
}
|
||||
|
||||
const used = sources.some((text) => text.includes(`'${shot.id}'`) || text.includes(`"${shot.id}"`));
|
||||
|
||||
if (!used) {
|
||||
problems.push(
|
||||
`${shot.id}: declared but no page renders it. Use it, or delete the entry and its file.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (existsSync(DIR)) {
|
||||
for (const name of readdirSync(DIR)) {
|
||||
if (!declared.has(name)) {
|
||||
problems.push(`public/screens/${name}: not declared in src/data/screens.mjs.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (problems.length > 0) {
|
||||
console.error(`\ncheckScreens: ${problems.length} problem(s)\n`);
|
||||
for (const problem of problems) console.error(` - ${problem}`);
|
||||
console.error('');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`checkScreens: ${screens.length} screens, all present, sized and used.`);
|
||||
77
scripts/checkSidebar.mjs
Normal file
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* checkSidebar.mjs — PLAN.md §12, added in phase 8.
|
||||
*
|
||||
* `src/config/sidebar.mjs` holds two trees: `docsSidebar`, which Starlight renders, and
|
||||
* `plannedSidebar`, the tree §10 planned. While pages were still being written the second
|
||||
* was a checklist. Now that every page exists it is a second copy of the first, maintained
|
||||
* by hand — and a hand-maintained copy with nothing reading it is exactly the shape of
|
||||
* thing §1 is about.
|
||||
*
|
||||
* It had already drifted, silently: phase 7 added the `Content` page under D37 and this
|
||||
* list was never updated. Nothing failed, because nothing read it. That is the whole
|
||||
* argument for this check.
|
||||
*
|
||||
* So the two must agree on groups, labels AND order. Order is checked because the order of
|
||||
* "Getting started" IS the installation path — §10 calls it the priority of the whole
|
||||
* project — and a reordering that nobody noticed would be a worse defect than a missing
|
||||
* page.
|
||||
*
|
||||
* node scripts/checkSidebar.mjs
|
||||
*
|
||||
* No token and no network: both trees are in this repository.
|
||||
*/
|
||||
|
||||
import { docsSidebar, plannedSidebar } from '../src/config/sidebar.mjs';
|
||||
|
||||
const failures = [];
|
||||
const fail = (what, detail) => failures.push({ what, detail });
|
||||
|
||||
const live = new Map(docsSidebar.map((g) => [g.label, g.items.map((i) => i.label)]));
|
||||
const planned = new Map(Object.entries(plannedSidebar));
|
||||
|
||||
// ── Groups ──────────────────────────────────────────────────────────────────
|
||||
for (const label of live.keys()) {
|
||||
if (!planned.has(label)) fail(`group ${label}`, 'is in the live sidebar and not in plannedSidebar');
|
||||
}
|
||||
for (const label of planned.keys()) {
|
||||
if (!live.has(label)) fail(`group ${label}`, 'is in plannedSidebar and not in the live sidebar');
|
||||
}
|
||||
|
||||
// ── Pages, in order ─────────────────────────────────────────────────────────
|
||||
for (const [label, liveItems] of live) {
|
||||
const plannedItems = planned.get(label);
|
||||
if (!plannedItems) continue;
|
||||
|
||||
for (const page of liveItems) {
|
||||
if (!plannedItems.includes(page)) fail(`${label} → ${page}`, 'is live but not in plannedSidebar');
|
||||
}
|
||||
for (const page of plannedItems) {
|
||||
if (!liveItems.includes(page)) fail(`${label} → ${page}`, 'is planned but has no live sidebar entry');
|
||||
}
|
||||
|
||||
// Only meaningful once membership matches; otherwise it just repeats the above.
|
||||
if (liveItems.length === plannedItems.length && liveItems.every((p) => plannedItems.includes(p))) {
|
||||
if (liveItems.join(' | ') !== plannedItems.join(' | ')) {
|
||||
fail(
|
||||
`${label} order`,
|
||||
`live "${liveItems.join(' → ')}" vs planned "${plannedItems.join(' → ')}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Report ──────────────────────────────────────────────────────────────────
|
||||
if (failures.length === 0) {
|
||||
const pages = [...live.values()].reduce((n, items) => n + items.length, 0);
|
||||
console.log(`checkSidebar: ${live.size} groups and ${pages} pages agree with plannedSidebar.`);
|
||||
} else {
|
||||
console.error(`\ncheckSidebar: ${failures.length} disagreement(s) between the two trees:\n`);
|
||||
for (const f of failures) console.error(` ✗ ${f.what}\n ${f.detail}`);
|
||||
console.error(`
|
||||
Both trees are in src/config/sidebar.mjs. Decide which one is right — if a page was
|
||||
deliberately added, renamed or reordered, plannedSidebar records that decision and
|
||||
should move with it.
|
||||
`);
|
||||
process.exit(1);
|
||||
}
|
||||
462
scripts/seedDemo.mjs
Normal file
@@ -0,0 +1,462 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* seedDemo.mjs — the deployment the screenshots are taken of. PLAN.md §13 phase 9, D45.
|
||||
*
|
||||
* node scripts/seedDemo.mjs → seed (idempotent; safe to re-run)
|
||||
* node scripts/seedDemo.mjs --dry-run → say what it would do, write nothing
|
||||
*
|
||||
* Environment (all optional; the defaults are this machine's review stack):
|
||||
*
|
||||
* RG_BASE http://localhost:3000 the website the seed drives
|
||||
* RG_ADMIN_USER demoadmin an existing admin, created by website's own
|
||||
* RG_ADMIN_PASS DemoReview!2026 `npm run seed` — see PLAN.md §13 phase 9
|
||||
* RG_DEMO_PASS DemoReview!2026 the password every seeded cast member gets
|
||||
* UOLINK_BASE http://127.0.0.1:8080 sidecar REST, written to Admin → Shard
|
||||
* UOLINK_WS ws://127.0.0.1:8080/ws sidecar WebSocket
|
||||
* UOLINK_TOKEN (unset) sidecar auth token; skipped when absent
|
||||
* UOLINK_PROTOCOL 4 wire protocol to pin — see the note below
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHY THE SEED DRIVES THE API AND NEVER THE DATABASE
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* Every row this creates could have been an INSERT, and every INSERT would have been a
|
||||
* second implementation of a rule the website already owns: how a body is sanitized, what
|
||||
* a slug may contain, which excerpt is derived when none is given, how a password is
|
||||
* hashed. A seed that writes SQL directly produces a database the product could not have
|
||||
* produced, and screenshots of that database show a product that does not exist.
|
||||
*
|
||||
* So this speaks HTTP to a running site, as an admin, through the same endpoints the admin
|
||||
* panel calls. The cost is that the site has to be up; the benefit is that the content is
|
||||
* real, and that this script keeps working when a column moves.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHY IT IS IDEMPOTENT RATHER THAN DESTRUCTIVE
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* Re-running must not double the news list, and must not erase a screenshot rig somebody
|
||||
* has been adjusting by hand. Every step therefore looks before it writes and reports
|
||||
* `= exists` rather than failing. That also makes the script usable as a repair: point it
|
||||
* at a stack that has drifted and it puts back only what is missing.
|
||||
*
|
||||
* What it deliberately does NOT create: anything the shard owns. Teams arrive from the
|
||||
* guild board over the bridge, the marketplace from player vendors, the atlas from real
|
||||
* spawners (PLAN.md §13 phase 9, D42). Seeding those would be inventing game state that
|
||||
* the product is supposed to be showing, which is exactly what D4 forbids.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const BASE = (process.env.RG_BASE || 'http://localhost:3000').replace(/\/+$/, '');
|
||||
const API = `${BASE}/api/v1`;
|
||||
const ADMIN_USER = process.env.RG_ADMIN_USER || 'demoadmin';
|
||||
const ADMIN_PASS = process.env.RG_ADMIN_PASS || 'DemoReview!2026';
|
||||
const DEMO_PASS = process.env.RG_DEMO_PASS || 'DemoReview!2026';
|
||||
const UOLINK_BASE = process.env.UOLINK_BASE || 'http://127.0.0.1:8080';
|
||||
const UOLINK_WS = process.env.UOLINK_WS || 'ws://127.0.0.1:8080/ws';
|
||||
const UOLINK_TOKEN = process.env.UOLINK_TOKEN || '';
|
||||
// The pinned wire protocol has to be STATED, not left to the module's default.
|
||||
//
|
||||
// `module-uo`'s schema fragment still carries `protocol INT NOT NULL DEFAULT 3`, from the
|
||||
// protocol-3 cutover; the sidecar on `link` `main` speaks 4. The module handles protocol 4's
|
||||
// frames — `guild.roster` and `guild.leave` ingest landed with the Teams cutover — but a
|
||||
// FRESH install pins 3, and the sidecar answers a 3 with `409 protocol version mismatch` on
|
||||
// every REST call. So a new deployment reads nothing from its shard until somebody edits the
|
||||
// number in Admin → Shard. Raised with the org lead rather than patched from here: the fix
|
||||
// belongs in `module-uo`, not in this repo's screenshot rig (PLAN.md §13 phase 9).
|
||||
const UOLINK_PROTOCOL = Number(process.env.UOLINK_PROTOCOL || 4);
|
||||
|
||||
const DRY = process.argv.includes('--dry-run');
|
||||
|
||||
// ── The demo deployment's identity (D43) ───────────────────────────────────────────────
|
||||
//
|
||||
// A neutral demo brand rather than UOMysticmoon: the screenshots show the platform, not a
|
||||
// private shard, and §15's demo VM can wear the same identity so the imagery stays true the
|
||||
// day it exists. The name is deliberately "… Demo" rather than an invented community —
|
||||
// nobody should have to wonder whether they are looking at a real server they could join.
|
||||
// The published contact address lives in exactly one file in this repository (D13), and
|
||||
// `checkFacts.mjs` fails the build if a literal address appears anywhere else — including
|
||||
// here. So the demo wears the same address the site publishes, read from the same place.
|
||||
const brandDefault = JSON.parse(
|
||||
readFileSync(new URL('../brand-default/brand.json', import.meta.url), 'utf8'),
|
||||
);
|
||||
|
||||
const SETTINGS = {
|
||||
site_title: 'Runic Gateway Demo',
|
||||
site_mode: 'live',
|
||||
status_message: 'Live — the demo shard is up.',
|
||||
homepage_teaser:
|
||||
'A public demonstration of Runic Gateway: a self-hosted community site wired to a ' +
|
||||
'live game server. Everything on this site is real data from the shard behind it.',
|
||||
contact_email: brandDefault.contactEmail,
|
||||
};
|
||||
|
||||
// ── The cast ───────────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Five accounts, one per role the admin screens distinguish, so a screenshot of the users
|
||||
// table shows the role column doing something. Names are ordinary fantasy given names and
|
||||
// belong to nobody.
|
||||
const USERS = [
|
||||
{ username: 'aldricmoss', role: 'moderator' },
|
||||
{ username: 'brannwen', role: 'editor' },
|
||||
{ username: 'sablequill', role: 'player' },
|
||||
{ username: 'tobinreed', role: 'player' },
|
||||
{ username: 'mirenavox', role: 'player' },
|
||||
];
|
||||
|
||||
// ── News, five-on-friday, the newsletter ───────────────────────────────────────────────
|
||||
//
|
||||
// Written as a small community's real output rather than lorem: a patch note, an event, a
|
||||
// maintenance notice and a Friday post. Bodies are short HTML because that is what the
|
||||
// editor stores, and the list screens show the excerpt anyway.
|
||||
const POSTS = [
|
||||
{
|
||||
category: 'news',
|
||||
title: 'Autumn patch: vendor search, and a fix for house decay',
|
||||
excerpt:
|
||||
'Player-vendor listings are now searchable from the site, and the decay timer no ' +
|
||||
'longer resets when a co-owner logs in.',
|
||||
body:
|
||||
'<p>The autumn patch is live. The headline change is that <strong>every player ' +
|
||||
'vendor on the shard is now searchable from this site</strong> — the marketplace ' +
|
||||
'page reads the same live feed the game does, so a listing appears within a minute ' +
|
||||
'of being priced.</p><p>We also fixed the house decay timer resetting when a ' +
|
||||
'co-owner logged in. That bug had been quietly keeping condemned houses alive since ' +
|
||||
'spring.</p><p>Full notes are on the wiki.</p>',
|
||||
published: true,
|
||||
},
|
||||
{
|
||||
category: 'news',
|
||||
title: 'The Harvest Moon festival opens this weekend',
|
||||
excerpt:
|
||||
'Three days of gatherings at the crossroads, with a champion spawn on the last ' +
|
||||
'night. Everyone is welcome, no signup needed.',
|
||||
body:
|
||||
'<p>The Harvest Moon festival runs from Friday evening to Sunday night at the ' +
|
||||
'crossroads north of town. There is no signup and no entry fee — turn up.</p>' +
|
||||
'<p>Saturday is the market day; bring anything you want to sell and we will set out ' +
|
||||
'extra vendor stalls. Sunday night closes with a champion spawn, which will be ' +
|
||||
'announced in game and on the shard status page here.</p>',
|
||||
published: true,
|
||||
},
|
||||
{
|
||||
category: 'news',
|
||||
title: 'Scheduled maintenance, Tuesday 03:00 UTC',
|
||||
excerpt:
|
||||
'About twenty minutes of downtime for a server restart and a world save. The site ' +
|
||||
'stays up throughout.',
|
||||
body:
|
||||
'<p>We are restarting the shard on Tuesday at 03:00 UTC for a world save and a ' +
|
||||
'server update. Expect about twenty minutes of downtime.</p><p>This site stays up ' +
|
||||
'while the shard is down — the status panel will simply show the shard as offline, ' +
|
||||
'and the marketplace and atlas will show their last known state.</p>',
|
||||
published: true,
|
||||
},
|
||||
{
|
||||
category: 'five-on-friday',
|
||||
title: 'Five on Friday: the ones who keep the roads clear',
|
||||
excerpt:
|
||||
'Five players who spent the week doing unglamorous work, and what they were up to.',
|
||||
body:
|
||||
'<p>Five people who made the week better for everybody else:</p><ol><li>Sable, for ' +
|
||||
'restocking the free reagent stall three times without being asked.</li><li>Tobin, ' +
|
||||
'for guiding two new players through their first dungeon.</li><li>Mirena, for the ' +
|
||||
'map corrections on the wiki.</li><li>Brannwen, for writing up the champion ' +
|
||||
'rotation.</li><li>Aldric, for handling a difficult report quietly and well.</li>' +
|
||||
'</ol>',
|
||||
published: true,
|
||||
},
|
||||
{
|
||||
category: 'newsletter',
|
||||
title: 'Monthly notes — what changed, and what is next',
|
||||
excerpt:
|
||||
'A month of changes in one place: the vendor search, the new guides, and what we ' +
|
||||
'are working on next.',
|
||||
body:
|
||||
'<p>A quiet, productive month. The vendor search shipped, the wiki gained four ' +
|
||||
'guides, and the guild boards now update on the site within a minute of a change in ' +
|
||||
'game.</p><p>Next month we are looking at the champion boards and at making the ' +
|
||||
'atlas easier to read on a phone.</p>',
|
||||
published: true,
|
||||
},
|
||||
];
|
||||
|
||||
// ── The wiki ───────────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// One category and four pages, because the wiki index screenshot needs a category with
|
||||
// enough in it to look like a wiki rather than a placeholder.
|
||||
const WIKI_CATEGORY = {
|
||||
slug: 'guides',
|
||||
title: 'Guides',
|
||||
description: 'How things work here, written by the people who play here.',
|
||||
};
|
||||
|
||||
const WIKI_PAGES = [
|
||||
{
|
||||
slug: 'getting-started',
|
||||
title: 'Getting started',
|
||||
excerpt: 'What to install, how to connect, and the first hour.',
|
||||
body:
|
||||
'<h2>Before you connect</h2><p>You need a game client and an account. Make the ' +
|
||||
'account on this site — the shard accepts accounts created here, and it saves you ' +
|
||||
'typing your password into a chat window.</p><h2>The first hour</h2><p>Start in ' +
|
||||
'town, take the newcomer quest, and do not sell your starting tools. If you get ' +
|
||||
'stuck, ask in Discord: somebody is usually around.</p>',
|
||||
},
|
||||
{
|
||||
slug: 'player-vendors',
|
||||
title: 'Player vendors',
|
||||
excerpt: 'How to hire one, how to price, and how the site search finds you.',
|
||||
body:
|
||||
'<h2>Hiring a vendor</h2><p>Any house you own or co-own can hold vendors. Hire one ' +
|
||||
'from an innkeeper and place it inside.</p><h2>Being findable</h2><p>Everything a ' +
|
||||
'vendor holds is published to the marketplace on this site within about a minute, ' +
|
||||
'including the price and the house it stands in. If a listing looks stale, the ' +
|
||||
'shard was probably down when you priced it — it will correct itself on the next ' +
|
||||
'sweep.</p>',
|
||||
},
|
||||
{
|
||||
slug: 'housing-and-decay',
|
||||
title: 'Housing and decay',
|
||||
excerpt: 'Placement rules, the decay timer, and what IDOC actually means here.',
|
||||
body:
|
||||
'<h2>Placement</h2><p>Houses can be placed anywhere the client allows, with the ' +
|
||||
'usual clearance rules. There is no lottery.</p><h2>Decay</h2><p>A house decays if ' +
|
||||
'nobody with access logs in for long enough. The site lists houses approaching ' +
|
||||
'collapse on the housing page, which is the same data the game uses — not a ' +
|
||||
'prediction.</p>',
|
||||
},
|
||||
{
|
||||
slug: 'community-rules',
|
||||
title: 'Community rules',
|
||||
excerpt: 'The short version: do not be the reason somebody stops playing.',
|
||||
body:
|
||||
'<h2>The rules</h2><ol><li>No harassment, in game or on the site.</li><li>No ' +
|
||||
'exploiting bugs — report them instead, and you will usually be thanked in ' +
|
||||
'public.</li><li>One account per person for events with prizes.</li></ol>' +
|
||||
'<h2>Appeals</h2><p>Every moderation action can be appealed from your account page. ' +
|
||||
'An appeal is read by somebody who was not involved in the original action.</p>',
|
||||
},
|
||||
];
|
||||
|
||||
// ── HTTP plumbing ──────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// One cookie jar, because the session is a cookie and `fetch` has no jar of its own. Only
|
||||
// the value of the auth cookie matters, so this keeps exactly that.
|
||||
|
||||
let cookie = '';
|
||||
let created = 0;
|
||||
let existed = 0;
|
||||
|
||||
function keepCookies(res) {
|
||||
const raw = res.headers.getSetCookie?.() ?? [];
|
||||
for (const line of raw) {
|
||||
const [pair] = line.split(';');
|
||||
if (pair.trim()) cookie = pair.trim();
|
||||
}
|
||||
}
|
||||
|
||||
async function call(method, path, body) {
|
||||
const res = await fetch(`${API}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(cookie ? { Cookie: cookie } : {}),
|
||||
},
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
});
|
||||
keepCookies(res);
|
||||
const text = await res.text();
|
||||
let data = null;
|
||||
try {
|
||||
data = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
data = text;
|
||||
}
|
||||
return { ok: res.ok, status: res.status, data };
|
||||
}
|
||||
|
||||
function say(mark, what) {
|
||||
console.log(` ${mark} ${what}`);
|
||||
if (mark === '+') created += 1;
|
||||
if (mark === '=') existed += 1;
|
||||
}
|
||||
|
||||
function fail(what, res) {
|
||||
console.error(`\n ! ${what} failed — HTTP ${res.status}`);
|
||||
console.error(` ${JSON.stringify(res.data)?.slice(0, 400)}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
// ── The steps ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function login() {
|
||||
const res = await call('POST', '/auth/login', { username: ADMIN_USER, password: ADMIN_PASS });
|
||||
if (!res.ok) {
|
||||
console.error(
|
||||
`\nCould not log in as "${ADMIN_USER}". Create the admin first, from the website repo:\n` +
|
||||
` cd website/server && DB_NAME=<demo db> ADMIN_USERNAME=${ADMIN_USER} ` +
|
||||
`ADMIN_PASSWORD='…' node db/seed.js\n`,
|
||||
);
|
||||
fail('login', res);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`\nsigned in as ${ADMIN_USER} at ${BASE}`);
|
||||
}
|
||||
|
||||
async function settings() {
|
||||
console.log('\nsite settings (D43 — the neutral demo identity)');
|
||||
if (DRY) {
|
||||
for (const [k, v] of Object.entries(SETTINGS)) say('~', `${k} = ${v}`);
|
||||
return;
|
||||
}
|
||||
const res = await call('PUT', '/admin/settings', SETTINGS);
|
||||
if (!res.ok) return fail('settings', res);
|
||||
for (const [k, v] of Object.entries(SETTINGS)) say('+', `${k} = ${String(v).slice(0, 60)}`);
|
||||
}
|
||||
|
||||
async function uoLink() {
|
||||
console.log('\nshard connection (Admin → Shard)');
|
||||
if (!UOLINK_TOKEN) {
|
||||
say('~', 'UOLINK_TOKEN unset — leaving the sidecar config alone');
|
||||
return;
|
||||
}
|
||||
const now = await call('GET', '/admin/uo-link/config');
|
||||
if (now.status === 404) {
|
||||
say('~', 'no /admin/uo-link route — the uo module is not installed');
|
||||
return;
|
||||
}
|
||||
if (
|
||||
now.ok &&
|
||||
now.data?.config?.baseUrl === UOLINK_BASE &&
|
||||
now.data?.config?.protocol === UOLINK_PROTOCOL &&
|
||||
now.data?.config?.enabled
|
||||
) {
|
||||
say('=', `already pointed at ${UOLINK_BASE} (protocol ${UOLINK_PROTOCOL})`);
|
||||
return;
|
||||
}
|
||||
if (DRY) return say('~', `would point the site at ${UOLINK_BASE}`);
|
||||
const res = await call('PUT', '/admin/uo-link/config', {
|
||||
baseUrl: UOLINK_BASE,
|
||||
wsUrl: UOLINK_WS,
|
||||
token: UOLINK_TOKEN,
|
||||
protocol: UOLINK_PROTOCOL,
|
||||
enabled: true,
|
||||
});
|
||||
if (!res.ok) return fail('uo-link config', res);
|
||||
say('+', `pointed at ${UOLINK_BASE} (protocol ${UOLINK_PROTOCOL})`);
|
||||
}
|
||||
|
||||
async function users() {
|
||||
console.log('\naccounts');
|
||||
const list = await call('GET', '/admin/users');
|
||||
if (!list.ok) return fail('list users', list);
|
||||
const rows = Array.isArray(list.data) ? list.data : (list.data?.users ?? []);
|
||||
const have = new Set(rows.map((u) => u.username));
|
||||
for (const user of USERS) {
|
||||
if (have.has(user.username)) {
|
||||
say('=', `${user.username} (${user.role})`);
|
||||
continue;
|
||||
}
|
||||
if (DRY) {
|
||||
say('~', `${user.username} (${user.role})`);
|
||||
continue;
|
||||
}
|
||||
const res = await call('POST', '/admin/users', {
|
||||
username: user.username,
|
||||
password: DEMO_PASS,
|
||||
role: user.role,
|
||||
});
|
||||
if (!res.ok) {
|
||||
fail(`create ${user.username}`, res);
|
||||
continue;
|
||||
}
|
||||
say('+', `${user.username} (${user.role})`);
|
||||
}
|
||||
}
|
||||
|
||||
async function posts() {
|
||||
console.log('\nposts');
|
||||
const list = await call('GET', '/admin/posts');
|
||||
if (!list.ok) return fail('list posts', list);
|
||||
const rows = Array.isArray(list.data) ? list.data : (list.data?.posts ?? []);
|
||||
const have = new Set(rows.map((p) => p.title));
|
||||
for (const post of POSTS) {
|
||||
if (have.has(post.title)) {
|
||||
say('=', `${post.category}: ${post.title}`);
|
||||
continue;
|
||||
}
|
||||
if (DRY) {
|
||||
say('~', `${post.category}: ${post.title}`);
|
||||
continue;
|
||||
}
|
||||
const res = await call('POST', '/admin/posts', post);
|
||||
if (!res.ok) {
|
||||
fail(`create post "${post.title}"`, res);
|
||||
continue;
|
||||
}
|
||||
say('+', `${post.category}: ${post.title}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function wiki() {
|
||||
console.log('\nwiki');
|
||||
const cats = await call('GET', '/admin/wiki/categories');
|
||||
if (!cats.ok) return fail('list wiki categories', cats);
|
||||
const catRows = Array.isArray(cats.data) ? cats.data : (cats.data?.categories ?? []);
|
||||
let category = catRows.find((c) => c.slug === WIKI_CATEGORY.slug);
|
||||
if (category) {
|
||||
say('=', `category ${WIKI_CATEGORY.slug}`);
|
||||
} else if (DRY) {
|
||||
say('~', `category ${WIKI_CATEGORY.slug}`);
|
||||
} else {
|
||||
const res = await call('POST', '/admin/wiki/categories', WIKI_CATEGORY);
|
||||
if (!res.ok) return fail('create wiki category', res);
|
||||
category = res.data?.category ?? res.data;
|
||||
say('+', `category ${WIKI_CATEGORY.slug}`);
|
||||
}
|
||||
|
||||
const pages = await call('GET', '/admin/wiki');
|
||||
if (!pages.ok) return fail('list wiki pages', pages);
|
||||
const pageRows = Array.isArray(pages.data) ? pages.data : (pages.data?.pages ?? []);
|
||||
const have = new Set(pageRows.map((p) => p.slug));
|
||||
for (const page of WIKI_PAGES) {
|
||||
if (have.has(page.slug)) {
|
||||
say('=', `page ${page.slug}`);
|
||||
continue;
|
||||
}
|
||||
if (DRY) {
|
||||
say('~', `page ${page.slug}`);
|
||||
continue;
|
||||
}
|
||||
const res = await call('POST', '/admin/wiki', {
|
||||
...page,
|
||||
category_id: category?.id ?? null,
|
||||
published: true,
|
||||
});
|
||||
if (!res.ok) {
|
||||
fail(`create wiki page "${page.slug}"`, res);
|
||||
continue;
|
||||
}
|
||||
say('+', `page ${page.slug}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── main ───────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
console.log(DRY ? '\nseedDemo — DRY RUN, nothing will be written' : '\nseedDemo');
|
||||
|
||||
await login();
|
||||
await settings();
|
||||
await uoLink();
|
||||
await users();
|
||||
await posts();
|
||||
await wiki();
|
||||
|
||||
console.log(
|
||||
`\n${DRY ? 'would create' : 'created'} ${created}, already present ${existed}` +
|
||||
(process.exitCode ? ' — with failures above' : ''),
|
||||
);
|
||||
console.log(
|
||||
'\nWhat this does NOT seed, on purpose: teams, the marketplace, houses, points boards\n' +
|
||||
'and the atlas. Those arrive from the shard over the bridge (D42) — start the sidecar\n' +
|
||||
'and the shard, and they populate themselves.\n',
|
||||
);
|
||||
81
src/components/Screenshot.astro
Normal file
@@ -0,0 +1,81 @@
|
||||
---
|
||||
import { screenById, WEB, PHONE } from '../data/screens.mjs';
|
||||
|
||||
/**
|
||||
* One screenshot, as a figure with its caption. PLAN.md §13 phase 9, D4 / D44.
|
||||
*
|
||||
* -----------------------------------------------------------------------------------------
|
||||
* WHY THE PAGE PASSES AN ID AND NOTHING ELSE
|
||||
* -----------------------------------------------------------------------------------------
|
||||
* A marketing page and a documentation page show the same administration screen for
|
||||
* different reasons, and the thing they must not do is describe it differently. The alt
|
||||
* text and the caption therefore live with the capture in `screens.mjs`, next to the route
|
||||
* they came from, and a page asks for `admin-shard` rather than restating what is in it.
|
||||
*
|
||||
* It also means a re-capture cannot silently invalidate a caption: the sentence and the
|
||||
* frame it describes are edited in the same file.
|
||||
*
|
||||
* -----------------------------------------------------------------------------------------
|
||||
* WHY IT FAILS THE BUILD ON AN UNKNOWN ID
|
||||
* -----------------------------------------------------------------------------------------
|
||||
* The alternative is a page that renders a broken image, which looks like a deployment
|
||||
* problem rather than a typo and survives review. `checkScreens.mjs` covers the other
|
||||
* direction — a declared screen whose file is missing — so between them a screenshot is
|
||||
* either complete or the build stops.
|
||||
*/
|
||||
interface Props {
|
||||
/** An `id` from `src/data/screens.mjs`. */
|
||||
id: string;
|
||||
/** Suppress the caption where the surrounding prose already says it. */
|
||||
bare?: boolean;
|
||||
}
|
||||
|
||||
const { id, bare = false } = Astro.props;
|
||||
|
||||
const shot = screenById(id);
|
||||
|
||||
if (!shot) {
|
||||
throw new Error(`Screenshot "${id}" is not declared in src/data/screens.mjs`);
|
||||
}
|
||||
|
||||
const src = `/screens/${shot.id}.webp`;
|
||||
|
||||
// Intrinsic size comes from the family rather than the entry: every capture in a family is
|
||||
// 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;
|
||||
---
|
||||
|
||||
<figure class="shot">
|
||||
<img
|
||||
src={src}
|
||||
alt={shot.alt}
|
||||
width={width}
|
||||
height={height}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
{!bare && <figcaption>{shot.caption}</figcaption>}
|
||||
</figure>
|
||||
|
||||
<style>
|
||||
.shot {
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.shot img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.shot figcaption {
|
||||
margin: 0.85rem 0 0;
|
||||
color: var(--dim);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
@@ -1,61 +1,36 @@
|
||||
---
|
||||
import { screensOf, PHONE } from '../../data/screens.mjs';
|
||||
|
||||
/**
|
||||
* The app's screenshot strip — defined now, empty until phase 9 (D26).
|
||||
* The app's screenshot strip. Reserved in phase 5 (D26), filled in phase 9.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHY A COMPONENT THAT RENDERS NOTHING IS WORTH COMMITTING
|
||||
* WHY THIS COMPONENT EXISTED FOR A PHASE WITH NOTHING IN IT
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* PLAN.md §10 says `/app/` shows "the 14 existing screenshots". They exist —
|
||||
* `docs/android/screenshots/` on the `docs` repository — and they are the wrong fourteen:
|
||||
* a trusted-device and recovery-code smoke test from 2026-07-22, captured against a
|
||||
* PLAN.md §10 said `/app/` shows "the 14 existing screenshots". They exist —
|
||||
* `docs/android/screenshots/` on the `docs` repository — and they are the wrong fourteen: a
|
||||
* trusted-device and recovery-code smoke test from 2026-07-22, captured against a
|
||||
* development instance with no seeded content, before the theming work that changed how
|
||||
* every screen looks. Five of them are two-factor prompts. The home shot is an empty page.
|
||||
*
|
||||
* Shipping them would break two things at once: D4, which says real screenshots from the
|
||||
* review stack rather than placeholders, and §1, because they would show an app that no
|
||||
* longer looks like that. D26 records the decision — the slot is reserved, phase 9 fills
|
||||
* it, and phase 9 is already the phase that stands up the review stack and seeds the
|
||||
* content the web screenshots need. Adding an emulator pass to a rig that is being built
|
||||
* anyway is most of the work already done, and it has the property that the phone shots
|
||||
* and the browser shots then show the same deployment on the same day.
|
||||
*
|
||||
* The component exists rather than the page carrying a `TODO` because a defined shape is
|
||||
* what makes phase 9 a data change instead of a design task: fill `shots`, and the section
|
||||
* appears with a heading, a caption line and a grid. Nothing else has to be decided then.
|
||||
* Shipping them would have broken D4 and §1 at once, so D26 reserved the slot for the phase
|
||||
* that stands up the review stack anyway. The shape was defined then and the data arrived
|
||||
* now, which is exactly what it was for: filling it was a data change.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHAT PHASE 9 SHOULD PUT HERE
|
||||
* WHY IT READS screens.mjs RATHER THAN HOLDING ITS OWN LIST
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* Portrait captures at the device's own pixel size, from an API 36 emulator pointed at the
|
||||
* seeded review stack, one per idea rather than one per screen: the shard hub with live
|
||||
* data, the marketplace, a character sheet, the news list, the notification settings, and
|
||||
* the drawer showing a deployment's own navigation. Six is plenty. Fourteen was never a
|
||||
* target — it was the number that happened to exist.
|
||||
* The draft carried its own `shots` array, written before there was anywhere else to put
|
||||
* one. There is now: `src/data/screens.mjs` holds every capture the site ships, web and
|
||||
* phone alike, and `scripts/checkScreens.mjs` proves each one exists at the size the markup
|
||||
* claims. A second list here would be the one nothing checks.
|
||||
*
|
||||
* They belong in `public/`, not `brand-default/`: these are editorial content shipped with
|
||||
* the image, not branding an operator overrides (§7).
|
||||
* The phone captures come from an emulator pointed at the same seeded deployment the web
|
||||
* screenshots were taken from, on the same day — which is the property D26 was really
|
||||
* after, since the app takes its colours, type and navigation from the site it connects to.
|
||||
*/
|
||||
|
||||
/**
|
||||
* One capture. `width` and `height` are the real pixel dimensions and are required rather
|
||||
* than optional: without them the page reflows as each image decodes, and a strip of six
|
||||
* phone screenshots is the worst possible place for that.
|
||||
*
|
||||
* Frontmatter is TypeScript, so this is an interface rather than the JSDoc typedef the
|
||||
* `.mjs` data files use — and it has to be typed explicitly, because an empty array
|
||||
* annotated by inference is `any[]` and `astro check` is right to refuse it.
|
||||
*/
|
||||
interface Shot {
|
||||
/** Site-absolute path under `/screens/`. */
|
||||
src: string;
|
||||
/** What the screen shows, for somebody who cannot see it. */
|
||||
alt: string;
|
||||
caption: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
const shots: Shot[] = [];
|
||||
const shots = screensOf('phone');
|
||||
---
|
||||
|
||||
{
|
||||
@@ -63,8 +38,8 @@ const shots: Shot[] = [];
|
||||
<section class="page section shots">
|
||||
<h2>What it looks like</h2>
|
||||
<p class="prose shots__lede">
|
||||
Captured against a real deployment with real content, not mocked up. The app takes
|
||||
its colours, type and navigation from the site it is connected to, so these show one
|
||||
Captured against a real deployment with real content, not mocked up. The app takes its
|
||||
colours, type and navigation from the site it is connected to, so these show one
|
||||
community's app rather than a neutral one.
|
||||
</p>
|
||||
|
||||
@@ -72,10 +47,10 @@ const shots: Shot[] = [];
|
||||
{shots.map((shot) => (
|
||||
<li class="shots__item">
|
||||
<img
|
||||
src={shot.src}
|
||||
src={`/screens/${shot.id}.webp`}
|
||||
alt={shot.alt}
|
||||
width={shot.width}
|
||||
height={shot.height}
|
||||
width={PHONE.width}
|
||||
height={PHONE.height}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
|
||||
44
src/components/home/WhatItLooksLike.astro
Normal file
@@ -0,0 +1,44 @@
|
||||
---
|
||||
import Screenshot from '../Screenshot.astro';
|
||||
|
||||
/**
|
||||
* The homepage's one screenshot. PLAN.md §13 phase 9, D4 / D44.
|
||||
*
|
||||
* -----------------------------------------------------------------------------------------
|
||||
* WHY ONE, AND WHY THIS ONE
|
||||
* -----------------------------------------------------------------------------------------
|
||||
* `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.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
---
|
||||
|
||||
<section class="page section looks">
|
||||
<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.
|
||||
</p>
|
||||
|
||||
<Screenshot id="shard-status" />
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.looks h2 {
|
||||
margin: 0.35rem 0 0.75rem;
|
||||
font-size: clamp(1.6rem, 3.2vw, 2.1rem);
|
||||
}
|
||||
|
||||
.looks__lede {
|
||||
margin: 0;
|
||||
max-width: 46rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
</style>
|
||||
@@ -44,12 +44,54 @@ export const docsSidebar = [
|
||||
{ label: 'Troubleshooting', slug: 'docs/administration/troubleshooting' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Modules',
|
||||
items: [
|
||||
{ label: 'The module system', slug: 'docs/modules/the-module-system' },
|
||||
{ label: 'Installing modules', slug: 'docs/modules/installing-modules' },
|
||||
{ label: 'Module lifecycle', slug: 'docs/modules/module-lifecycle' },
|
||||
{ label: 'The module manifest', slug: 'docs/modules/the-module-manifest' },
|
||||
{ label: 'The module API', slug: 'docs/modules/the-module-api' },
|
||||
{ label: 'Building a module', slug: 'docs/modules/building-a-module' },
|
||||
{ label: 'The Integration Kit', slug: 'docs/modules/the-integration-kit' },
|
||||
{ label: 'Testing and release', slug: 'docs/modules/testing-and-release' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Architecture',
|
||||
items: [
|
||||
{ label: 'System architecture', slug: 'docs/architecture/system-architecture' },
|
||||
{ label: 'The bridge', slug: 'docs/architecture/the-bridge' },
|
||||
{ label: 'Authentication architecture', slug: 'docs/architecture/authentication-architecture' },
|
||||
{ label: 'Teams architecture', slug: 'docs/architecture/teams-architecture' },
|
||||
{ label: 'Protocol versions', slug: 'docs/architecture/protocol-versions' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Reference',
|
||||
items: [
|
||||
{ label: 'Environment variables', slug: 'docs/reference/environment-variables' },
|
||||
{ label: 'Installer CLI', slug: 'docs/reference/installer-cli' },
|
||||
{ label: 'sidecar.toml', slug: 'docs/reference/sidecar-toml' },
|
||||
{ label: 'Bridge.cfg', slug: 'docs/reference/bridge-cfg' },
|
||||
{ label: 'HTTP API', slug: 'docs/reference/http-api' },
|
||||
{ label: 'Event catalog', slug: 'docs/reference/event-catalog' },
|
||||
{ label: 'Canonical documents', slug: 'docs/reference/canonical-documents' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* The full planned tree, kept next to the live sidebar so phases 7 and 8 have their
|
||||
* checklist in the place they will be working. Not exported into the Starlight config —
|
||||
* it names pages that do not exist yet.
|
||||
* The tree §10 planned, kept as the record of what was intended — every page it names now
|
||||
* exists, as of phase 8.
|
||||
*
|
||||
* It was the phases 7/8 checklist, and a checklist with nothing left on it is no longer
|
||||
* pulling its weight: it is a second copy of the tree above, maintained by hand, and it had
|
||||
* already drifted once (phase 7 added `Content` under D37 and this list was not updated,
|
||||
* which nothing caught because nothing reads it). `checkSidebar.mjs` now asserts the two
|
||||
* agree, which is what makes keeping it safe.
|
||||
*
|
||||
* Not exported into the Starlight config.
|
||||
*/
|
||||
export const plannedSidebar = {
|
||||
'Getting started': [
|
||||
@@ -65,6 +107,7 @@ export const plannedSidebar = {
|
||||
'Configuration',
|
||||
'Branding and theming',
|
||||
'Navigation and pages',
|
||||
'Content',
|
||||
'Users and roles',
|
||||
'Authentication',
|
||||
'Teams',
|
||||
|
||||
@@ -4,6 +4,7 @@ description: Colours, fonts and corners from the Appearance screen; logo, hero a
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
import Screenshot from '../../../../components/Screenshot.astro';
|
||||
|
||||
One prebuilt image runs as any community's site. Nothing about your identity is compiled
|
||||
in — it is a theme row in the database, three image files on a mount, and a few environment
|
||||
@@ -31,6 +32,8 @@ Two things the screen tells you that are easy to miss:
|
||||
- **The accent reaches the mobile app and the Discord bot**, both of which theme themselves
|
||||
from this site's public branding. Changing it here changes them.
|
||||
|
||||
<Screenshot id="admin-appearance" />
|
||||
|
||||
## Brand assets
|
||||
|
||||
The same screen uploads three images, and each applies as soon as the upload finishes —
|
||||
|
||||
@@ -4,10 +4,13 @@ description: The five states a module can be in, installing and upgrading, disab
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
import Screenshot from '../../../../components/Screenshot.astro';
|
||||
|
||||
Installing your first module is [Getting started](/docs/getting-started/install-a-game-module/).
|
||||
This is what the screen means afterwards.
|
||||
|
||||
<Screenshot id="admin-modules" />
|
||||
|
||||
## The five states
|
||||
|
||||
`installed → enabled → started`, with `disabled` and `startup_failed` as recoverable
|
||||
|
||||
@@ -4,6 +4,7 @@ description: The module's shard screen — connection settings, what the status
|
||||
---
|
||||
|
||||
import platform from '../../../../data/platform.json';
|
||||
import Screenshot from '../../../../components/Screenshot.astro';
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
|
||||
With the `uo` module installed, **Shard (uo-link)** appears in the admin sidebar at
|
||||
@@ -13,6 +14,8 @@ the controls that ride on it.
|
||||
Setting it up for the first time is
|
||||
[Connect a game server](/docs/getting-started/connect-a-game-server/).
|
||||
|
||||
<Screenshot id="admin-shard" />
|
||||
|
||||
## Connection
|
||||
|
||||
Four fields, all four printed by the installer, plus the switch that turns the integration
|
||||
|
||||
@@ -4,6 +4,7 @@ description: The four roles and what each one reaches, creating accounts, and in
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
import Screenshot from '../../../../components/Screenshot.astro';
|
||||
|
||||
## The four roles
|
||||
|
||||
@@ -24,6 +25,8 @@ Admin routes are re-validated against the database on **every request**, not jus
|
||||
Demoting an account takes effect at once — the open session does not keep its access until
|
||||
it expires.
|
||||
|
||||
<Screenshot id="admin-users" />
|
||||
|
||||
## Creating an account
|
||||
|
||||
**Admin → Users → + Add user** creates one directly: username, password, role, and it is
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
---
|
||||
title: Authentication architecture
|
||||
description: One session model behind three very different front doors — cookies, bearer tokens and SSO — and where the boundaries actually are.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
|
||||
The administrator's view of this is
|
||||
[Authentication](/docs/administration/authentication/). This is how it is built.
|
||||
|
||||
## One session service, three surfaces
|
||||
|
||||
The governing decision: **there is a single source of truth for sessions**, and every
|
||||
authentication surface produces the *same* session model.
|
||||
|
||||
```
|
||||
browser native app SSO provider
|
||||
(httpOnly JWT) (bearer + refresh) (OAuth2 / OIDC + PKCE)
|
||||
│ │ │
|
||||
└───────────────────┼────────────────────────┘
|
||||
▼
|
||||
sessionService
|
||||
createSession(user, authMethod)
|
||||
validateSession()
|
||||
```
|
||||
|
||||
Controllers call `createSession`; middleware calls `validateSession`. Nothing invents its
|
||||
own notion of "logged in".
|
||||
|
||||
That matters more than it sounds. Three front doors with three session implementations is
|
||||
three places for an authorization bug to hide, and the one that gets least attention is the
|
||||
one that gets exploited.
|
||||
|
||||
<Aside type="note" title="`utils/auth.js` is a facade">
|
||||
It exists for backward compatibility and is a thin wrapper. New work goes through the
|
||||
session service.
|
||||
</Aside>
|
||||
|
||||
## The three surfaces
|
||||
|
||||
**Web** — a JWT signed with `JWT_SECRET`, carried in an `httpOnly`, `sameSite=Lax` cookie.
|
||||
`secure` is decided **per request** (`COOKIE_SECURE=auto` → `secure: req.secure`), which is
|
||||
what lets one deployment work both over HTTPS through a proxy and over plain HTTP on a LAN
|
||||
address.
|
||||
|
||||
**Mobile** — short-lived bearer access tokens plus **rotated, hashed, revocable** refresh
|
||||
tokens. Hashed server-side, so a database disclosure does not hand over live sessions.
|
||||
|
||||
**SSO** — Google, Discord or a custom OIDC provider, PKCE-guarded.
|
||||
|
||||
## SSO is link-only, by policy
|
||||
|
||||
**An external identity must already be linked to an existing account.** Identities are
|
||||
never auto-provisioned.
|
||||
|
||||
This is a deliberate policy rather than an unimplemented feature. Auto-provisioning turns
|
||||
"anyone with a Google account" into "anyone with an account here", which is not a decision
|
||||
a site operator should make by installing an OAuth client.
|
||||
|
||||
## Admin is re-validated every request
|
||||
|
||||
Roles are **re-checked against the database on every admin request**, not trusted from the
|
||||
token.
|
||||
|
||||
The consequence is the point: a demoted user loses access **at once**, rather than when
|
||||
their token happens to expire. A stateless JWT that carried the role would keep asserting it
|
||||
for up to a day.
|
||||
|
||||
## Trusted devices gate the second factor only
|
||||
|
||||
A second, separate httpOnly cookie (`rg_trust`, 30 days by default) lets a browser or app
|
||||
**skip the TOTP step** on future logins — **never the password**.
|
||||
|
||||
Four properties, each chosen:
|
||||
|
||||
- It is **opaque and sha256-hashed server-side**, stored in a table. It is not a JWT claim,
|
||||
so the stateless session token is unchanged.
|
||||
- It is **per-row revocable**, from the admin panel or by the user.
|
||||
- It **deliberately outlives logout.** Logging out ends a session; it does not make the
|
||||
device untrusted, because the device is still the same device.
|
||||
- It is **cleared** on untrust, password change, password reset, or disabling TOTP.
|
||||
|
||||
**Recovery codes** (bcrypt, single-use) are the lockout fallback. Every trusted-device and
|
||||
MFA action is audit-logged.
|
||||
|
||||
## The login-hardening layer
|
||||
|
||||
Bot scoring with automatic IP banning, TOTP 2FA, a honeypot field, and rate limiting with
|
||||
backoff. The admin *Bot Activity* panel is deliberately **read plus emergency-unban only** —
|
||||
it is a window onto an automatic system, not a control surface for it.
|
||||
|
||||
## Where core's boundaries stop
|
||||
|
||||
Core's security boundaries end at **authentication, roles and the session**.
|
||||
|
||||
A module that serves game data brings its **own** audience rules, and core does not police
|
||||
them beyond the gates it hands over — `requireAuth`, `requireRole`, and the tier group
|
||||
gates. See [The module API](/docs/modules/the-module-api/#registerroutes-and-the-tier-gate).
|
||||
|
||||
`module-uo`'s is the worked example, and it is a real boundary rather than a convenience
|
||||
filter: an admin-configurable, per-feature and per-field audience ladder with **fail-closed
|
||||
defaults**, applied at routes, at SSE subscribe time, *and* at the navigation. All three,
|
||||
because a surface that is filtered in only two of those places leaks through the third.
|
||||
|
||||
## Content Security Policy
|
||||
|
||||
`script-src 'self'` with **no inline script**, which is why [module chunks are served
|
||||
same-origin](/docs/modules/building-a-module/) and why an import map was never an option.
|
||||
|
||||
`form-action 'self'` is pinned explicitly rather than inherited, because it blocks an
|
||||
injected form POSTing credentials off-origin — an exfiltration path `connect-src` does not
|
||||
cover.
|
||||
|
||||
Violation reports go to a **same-origin** sink that stores nothing: reports describe attacks
|
||||
against this site and are not handed to a third-party collector. It parses both wire formats
|
||||
(browsers disagree), and always answers `204` even for malformed input — a `4xx` would make
|
||||
the error handler log attacker-supplied bodies and turn an open endpoint into a log-flood
|
||||
primitive.
|
||||
|
||||
## Canonical document
|
||||
|
||||
[`BACKEND_DESIGN.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/BACKEND_DESIGN.md)
|
||||
§6 is normative for everything on this page.
|
||||
100
src/content/docs/docs/architecture/protocol-versions.mdx
Normal file
@@ -0,0 +1,100 @@
|
||||
---
|
||||
title: Protocol versions
|
||||
description: One number, declared in three repositories, that decides whether a shard and a sidecar are allowed to talk to each other.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
|
||||
The loopback wire protocol between the game plugin and the sidecar is a **versioned
|
||||
compatibility contract**, not a build dependency. Nothing compiles the three sides together,
|
||||
so the number is what stops a mismatch from being discovered as corrupted data.
|
||||
|
||||
The current protocol is **4**.
|
||||
|
||||
## Three declaration sites
|
||||
|
||||
The same number is written down in three places, and they must move together.
|
||||
|
||||
| Where | What declares it |
|
||||
|---|---|
|
||||
| `link/sidecar/src/main.rs` | `pub const PROTOCOL_VERSION: u32 = 4` — what the sidecar speaks |
|
||||
| `servuo-plugins/overlay.toml` | `protocol = 4` — what the plugin overlay speaks |
|
||||
| The bundle manifest | Copied from `overlay.toml` by CI, so a released pair carries its own claim |
|
||||
|
||||
<Aside type="caution" title="Bump the overlay in the same PR as the emitters">
|
||||
CI folds `overlay.toml` into the release manifest, and **the installer refuses to pair an
|
||||
overlay and a sidecar whose protocol numbers disagree**.
|
||||
|
||||
A bump that lands separately from the emitters does not fail loudly — it silently fails to
|
||||
compose into a bundle, and the next release simply does not appear.
|
||||
</Aside>
|
||||
|
||||
## How a mismatch is caught
|
||||
|
||||
Two independent mechanisms, at two different boundaries.
|
||||
|
||||
**Sidecar ↔ website.** Every sidecar response carries `X-UOLink-Version`. A mismatch is
|
||||
rejected with **`409`** rather than mis-parsed. The website's protocol expectation is
|
||||
admin-managed, alongside the base URL and token, on the shard configuration screen.
|
||||
|
||||
**Overlay ↔ sidecar.** The installer resolves a **bundle** — an exact, protocol-checked
|
||||
sidecar and overlay pair published by CI — and never "latest of each". That is the whole
|
||||
reason bundles exist: two independently released components that must agree cannot be
|
||||
allowed to be chosen independently.
|
||||
|
||||
## What a bump obliges
|
||||
|
||||
Changing a message shape means editing every side plus the specification. A protocol-4
|
||||
change touched:
|
||||
|
||||
| Repository | What had to change |
|
||||
|---|---|
|
||||
| `servuo-plugins` | The emitters, the config keys, and `overlay.toml` |
|
||||
| `link` | `PROTOCOL_VERSION`, a store migration, and the projections |
|
||||
| `module-uo` | The tables, the ingest, and the kind-to-feature map |
|
||||
| `docs` | The protocol document and the integration guide |
|
||||
|
||||
Note `link`'s entry: **a protocol bump can require a store migration**, because the sidecar
|
||||
persists what it forwards. That is not automatic, and version 4 was the first bump that
|
||||
needed one.
|
||||
|
||||
## This is not the module API version
|
||||
|
||||
Two different numbers, versioning two different contracts, and confusing them is easy.
|
||||
|
||||
| | Versions | Lives in | Checked |
|
||||
|---|---|---|---|
|
||||
| **`PROTOCOL_VERSION`** | The game ↔ sidecar wire | `link`, `servuo-plugins`, the bundle | `X-UOLink-Version`, and the installer's pairing check |
|
||||
| **`MODULE_API_VERSION`** | The website ↔ module contract | `website`, and every module's `coreApi` | At module load, before the module's code runs |
|
||||
|
||||
A module that never talks to a game server has no protocol version at all. See [The module
|
||||
manifest](/docs/modules/the-module-manifest/#coreapi-and-what-a-range-means).
|
||||
|
||||
## When a contract owes a bump
|
||||
|
||||
The rule this project settled on: **a contract owes a bump only once it has landed on
|
||||
`main`.**
|
||||
|
||||
While a version has only ever existed on a development branch, additions join it in place
|
||||
rather than forcing a new number. Once it has shipped, it is somebody else's dependency and
|
||||
a change to it is a change to a published contract.
|
||||
|
||||
## If you are building a bridge for another game
|
||||
|
||||
You do not inherit this protocol — you define your own between your plugin and your sidecar.
|
||||
What is worth inheriting is the **shape**:
|
||||
|
||||
- Declare the version on both sides, in files a release can read.
|
||||
- Make a released pair carry its own compatibility claim, so a deployment tool can refuse a
|
||||
bad combination rather than discovering it at runtime.
|
||||
- Reject a mismatch **loudly and early**. A `409` is a good outcome; a successful parse of a
|
||||
message you did not expect is not.
|
||||
|
||||
## Canonical documents
|
||||
|
||||
[`link/v4.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v4.md)
|
||||
is the protocol-4 record, including its cross-repository obligations;
|
||||
[`link/PLAN.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md)
|
||||
§7 is the wire protocol, and
|
||||
[`link/INTEGRATION.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/INTEGRATION.md)
|
||||
the integration guide.
|
||||
141
src/content/docs/docs/architecture/system-architecture.mdx
Normal file
@@ -0,0 +1,141 @@
|
||||
---
|
||||
title: System architecture
|
||||
description: The whole platform in one place — what each repository is, what talks to what, and the invariants that hold across all of them.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
|
||||
The drawn version of this, for evaluators, is on
|
||||
[Architecture](/architecture/). This page is the detailed account.
|
||||
|
||||
## Ten repositories, deployed independently
|
||||
|
||||
Nothing here is a monorepo. Each repository has its own history, its own CI and its own
|
||||
release cadence; what binds them is a set of **versioned contracts**, not a build.
|
||||
|
||||
| Repository | What it is |
|
||||
|---|---|
|
||||
| `website` | The Node/Express + MariaDB + React site. The only internet-facing web app |
|
||||
| `Module-uo` | All the *Ultima Online* code, installed into the site as a module |
|
||||
| `link` | The **uo-link sidecar**, in Rust — the only network-facing bridge component |
|
||||
| `servuo-plugins` | The in-game plugin, C#, that feeds the sidecar |
|
||||
| `installer` | Deploys the shard side: sidecar plus plugin overlay |
|
||||
| `Android-app` | Native Android client of the website API |
|
||||
| `Integration-kit` | The instruction book for putting a different game on the platform |
|
||||
| `docs` | Canonical design docs and the protocol spec |
|
||||
| `runicgateway.com` | This site |
|
||||
| `.profile` | The organisation landing page |
|
||||
|
||||
## The layers
|
||||
|
||||
```
|
||||
Browser (React SPA) Native Android app
|
||||
│ cookie │ bearer
|
||||
└──────────┬─────────────────┘
|
||||
▼
|
||||
┌────────────────────────┐
|
||||
│ website (Node) │
|
||||
│ middleware → router │
|
||||
│ → controller → model │
|
||||
│ → db │
|
||||
└───────┬────────────┬───┘
|
||||
│ │ loads at boot
|
||||
▼ ▼
|
||||
MariaDB modules/<id>/ ← installed, never built
|
||||
│
|
||||
▼
|
||||
the game, via whatever
|
||||
bridge that module owns
|
||||
```
|
||||
|
||||
The backend is strictly layered — `middleware → router → controller → model → db` — with
|
||||
models in `.model.js` (logic) and `.db.js` (SQL) pairs, and **raw parameterised queries with
|
||||
no ORM anywhere**.
|
||||
|
||||
## Core is game-agnostic
|
||||
|
||||
Since the module system shipped on **2026-08-12**, nothing in core knows about any
|
||||
particular game. Routes, tables, pages, navigation and push streams for a game arrive from
|
||||
[a module](/docs/modules/the-module-system/) the operator installed. Core provides the seams;
|
||||
the module fills them.
|
||||
|
||||
That is why the architecture below describes `module-uo` as *the worked example* rather than
|
||||
as part of the platform. It is the module every other module is measured against, not a
|
||||
component core depends on.
|
||||
|
||||
## The invariants
|
||||
|
||||
These hold across repository boundaries, and every one of them is load-bearing.
|
||||
|
||||
### The game is never network-reachable
|
||||
|
||||
The ServUO shard **dials out** over loopback TCP `127.0.0.1:7788`, newline-delimited JSON,
|
||||
to the sidecar. The sidecar is the listener; the game opens no port. Only the sidecar is
|
||||
exposed, and only the website's backend talks to it.
|
||||
|
||||
See [The bridge](/docs/architecture/the-bridge/).
|
||||
|
||||
### A wedged sidecar can never stall the game
|
||||
|
||||
On the C# side, `Emit()` enqueues onto a **bounded, drop-oldest** queue and returns
|
||||
immediately. It never touches the socket from the game's core thread. Every world read
|
||||
happens on the core thread; a dedicated writer thread drains the queue.
|
||||
|
||||
Dropping game events is strictly better than pausing the game to deliver them.
|
||||
|
||||
### The website degrades rather than fails
|
||||
|
||||
The sidecar REST client never throws — every call returns `{ ok, data, status }`. The public
|
||||
site still renders with the shard shown offline.
|
||||
|
||||
That guarantee covers **reading the configuration too**: resolving the admin-managed config
|
||||
decrypts a stored token, which throws if the ciphertext cannot be authenticated (a rotated
|
||||
`SECRET_ENC_KEY`, or a database dump restored under a different key). That is caught inside
|
||||
the client and reported as unavailable, so a wrong key degrades the shard surface instead of
|
||||
500-ing it — and the admin config screen keeps working, which is the screen you need in order
|
||||
to recover.
|
||||
|
||||
### Sensitive events never reach the public
|
||||
|
||||
Ingested events fan out over two SSE channels: a **public allowlist** stream, and an
|
||||
**admin-only** stream that additionally carries staff audit, cheat detection and login
|
||||
attempts with IPs.
|
||||
|
||||
**The catalog is the module's; the boundary is core's.** A module declares which of its
|
||||
kinds are public-safe, and core enforces the split. A sensitive kind cannot reach the public
|
||||
channel.
|
||||
|
||||
### A failed module never takes the site down
|
||||
|
||||
The loader catches failures across a module's entire lifecycle and marks it
|
||||
`startup_failed`. The site comes up with that module's routes and navigation absent, and the
|
||||
admin panel says why. See [Module
|
||||
lifecycle](/docs/modules/module-lifecycle/#failure-is-contained-by-construction).
|
||||
|
||||
### Secrets are encrypted at rest
|
||||
|
||||
OAuth client secrets, the sidecar token and the Gmail refresh token are AES-256-GCM
|
||||
encrypted, keyed by `SECRET_ENC_KEY`. **The sidecar token is write-only in the API** — it is
|
||||
never returned to any client.
|
||||
|
||||
<Aside type="caution" title="Rotating that key orphans every stored secret">
|
||||
Nothing re-encrypts. What was stored under the old key can no longer be read, and every
|
||||
stored secret has to be entered again. See [Environment
|
||||
variables](/docs/reference/environment-variables/).
|
||||
</Aside>
|
||||
|
||||
## A deploy is two independent installs
|
||||
|
||||
Worth stating plainly, because it is the single most common misunderstanding: **the
|
||||
installer binary sets up the shard side only, and never contacts the website.** The website
|
||||
is a separate Docker deployment on, usually, a different machine.
|
||||
|
||||
The [installation path](/docs/getting-started/requirements/) walks both in order.
|
||||
|
||||
## Canonical documents
|
||||
|
||||
[`ARCHITECTURE.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/ARCHITECTURE.md)
|
||||
holds the canonical diagram, and
|
||||
[`BACKEND_DESIGN.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/BACKEND_DESIGN.md)
|
||||
is the full API, schema and security contract. See [Canonical
|
||||
documents](/docs/reference/canonical-documents/) for the whole map.
|
||||
153
src/content/docs/docs/architecture/teams-architecture.mdx
Normal file
@@ -0,0 +1,153 @@
|
||||
---
|
||||
title: Teams architecture
|
||||
description: Teams is a contract, not a surface — how core owns guilds, clans and corporations without ever learning what one is called.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
|
||||
Most games have groups: guilds, clans, corporations, tribes, crews. Runic Gateway supports
|
||||
them as a **core platform primitive**, while core itself never learns what yours is called.
|
||||
|
||||
The administrator's view is [Teams](/docs/administration/teams/).
|
||||
|
||||
## The sentence the design turns on
|
||||
|
||||
**Teams is a contract, not a surface.**
|
||||
|
||||
Core owns the tables, the sync, the access rules and the activity feed. It does **not** own
|
||||
the word for a Team, and therefore does not own the Team *page*. The module that owns the
|
||||
vocabulary owns the page.
|
||||
|
||||
That was not the first design. Core originally rendered Team pages with slots a module
|
||||
filled. It was inverted, and the inversion is the interesting part: instead of core naming
|
||||
places for a module's content, **a module declares a place on its own page for core to
|
||||
fill** — `registry.declareModuleSlot(id, name, { core })`, with core offering contributions
|
||||
rather than naming slots.
|
||||
|
||||
<Aside type="caution" title="Why the direction matters">
|
||||
The first version had core's fills naming three of `module-uo`'s slots **literally**. It
|
||||
worked for exactly one module and silently did nothing for any other game — an empty page
|
||||
with nothing logged.
|
||||
|
||||
It was found by writing the Integration Kit for an audience outside this project, which is
|
||||
precisely what that book is for.
|
||||
</Aside>
|
||||
|
||||
## Six invariants
|
||||
|
||||
Each has a test named against it.
|
||||
|
||||
1. **Module unavailability is staleness, never emptiness.** No Team subsystem may apply a
|
||||
destructive result derived from a failed, timed-out or unanswered module call.
|
||||
2. **Four authority paths stay four.** Game membership, leadership, forum access and
|
||||
external-platform access are separate tables answering separate questions, resolved by
|
||||
separate predicates. **No predicate reads another's table.**
|
||||
3. **Non-contamination.** A manual forum grant never writes the membership projection, in
|
||||
either direction, ever. Both facts coexist; neither migrates into the other.
|
||||
4. **A Team's name is immutable for the life of its record.** A rename is an archive plus a
|
||||
create.
|
||||
5. **Core never interprets module vocabulary.** Activity kinds, Team metadata and capability
|
||||
strings are opaque. Core stores, gates and displays; it never branches on content it does
|
||||
not own.
|
||||
6. **The game never touches the website.** Everything crosses the sidecar.
|
||||
|
||||
Invariant 1 deserves emphasis, because it is the one a naive implementation gets wrong: if
|
||||
the module fails to answer "who is in this Team?", the answer is **not** "nobody". Treating
|
||||
a timeout as an empty roster would silently disband every Team on the site.
|
||||
|
||||
## The rename rule
|
||||
|
||||
Core's key is **(`module_id`, `external_id`, `name`) taken together** — not `external_id`
|
||||
alone.
|
||||
|
||||
| Situation | What core does |
|
||||
|---|---|
|
||||
| New `external_id` | Create a Team |
|
||||
| Known id, same name | Update in place |
|
||||
| Known id, **different name** | **Archive** the row and create a new one |
|
||||
| Id absent from an authoritative full list | Archive as disbanded, subject to invariant 1 |
|
||||
|
||||
The archived Team keeps its forum, activity history, grants and integration record; all
|
||||
become read-only. It stays reachable at its old slug, `noindex`, with a banner linking to
|
||||
the successor — so a Discord message from before the rename lands somewhere that explains
|
||||
itself instead of 404-ing.
|
||||
|
||||
This puts the whole of *"is this a rename or a different group?"* **inside the module**. If
|
||||
your game has no persistent group id, synthesise `external_id` from whatever is stable, or
|
||||
fold the name into it so every rename is a fresh id. Core only ever sees "an id appeared /
|
||||
an id's name changed / an id is gone".
|
||||
|
||||
## The module-facing interface
|
||||
|
||||
A module registers a provider:
|
||||
|
||||
```js
|
||||
api.registerTeamProvider({ getTeams, getTeamMembers, getTeamLeaders })
|
||||
```
|
||||
|
||||
and pushes through `ctx.teams`:
|
||||
|
||||
| Call | What it does |
|
||||
|---|---|
|
||||
| `ctx.teams.publish(event)` | An optimisation — makes a membership change visible at once |
|
||||
| `ctx.teams.reconcile({ reason })` | A debounced *request*; returns immediately |
|
||||
| `ctx.teams.activity.push(items)` | Writes the per-Team feed |
|
||||
|
||||
**`ctx.teams` is push-only, and that is the contract.** There is no reader. A module
|
||||
*answers* questions about Teams; it does not ask them. A `getTeamRoster` would be core
|
||||
offering to read back the module's own answer — which the module already holds.
|
||||
|
||||
All three are fire-and-forget and never reject, because they are called from inside
|
||||
game-event handlers and a storage problem of core's must not become the module's control
|
||||
flow. Correctness comes from reconciliation either way.
|
||||
|
||||
### The six event kinds
|
||||
|
||||
`team.created` · `team.disbanded` · `team.member.added` · `team.member.removed` ·
|
||||
`team.leader.added` · `team.leader.removed`
|
||||
|
||||
Six rather than four because **leadership is its own authority path**: a leadership change
|
||||
has to be expressible without pretending someone joined or left.
|
||||
|
||||
**`team.created` and `team.disbanded` only ask for a reconciliation.** Core will not invent
|
||||
a Team from a delta — it would have no name, no roster and no leaders — and will not archive
|
||||
one from a delta either, because an archive driven by a message that may simply have been
|
||||
repeated is destruction on no evidence.
|
||||
|
||||
### The activity feed
|
||||
|
||||
Each item carries an already-**rendered** `summary`, which core stores verbatim. Core cannot
|
||||
phrase "gained 15,000 gold" for a game whose vocabulary it does not know, and a core that
|
||||
templated it would have re-acquired exactly the semantics the module system exists to
|
||||
remove.
|
||||
|
||||
`visibility` defaults to `'members'` — **fail closed**. The module chooses it per item; core
|
||||
enforces it on read.
|
||||
|
||||
A `dedupeKey` collision is a **successful no-op**, which is what makes a sidecar reconnect
|
||||
backfill safe to replay.
|
||||
|
||||
## Untrusted game data becomes a public page
|
||||
|
||||
This is the sharpest edge in the whole subsystem: a group name chosen by a player becomes a
|
||||
page on a public website.
|
||||
|
||||
So game-sourced names go through **reserved-name screening**, and game-sourced overrides
|
||||
through an **approval gate**. Neither is optional, and neither is something a module can
|
||||
waive.
|
||||
|
||||
## What is deliberately out of scope
|
||||
|
||||
Multi-module namespacing, Team hierarchies and alliances, cross-Team messaging, and
|
||||
platform-only Teams with no game backing.
|
||||
|
||||
**Matrix is research, not a roadmap item.** Of the five capabilities a shared interface
|
||||
would name, a Matrix implementation could honestly provide two — it has no
|
||||
channel-with-overwrites, no role object, no voice channel, and no slash-command
|
||||
registration. The settled outcome was a *capability contract*, not an integration.
|
||||
|
||||
## Canonical document
|
||||
|
||||
[`TEAMS.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/TEAMS.md)
|
||||
is normative — Part 1 for the invariants, Part 2 for the core, Parts 3–4 for pages and the
|
||||
activity feed.
|
||||
131
src/content/docs/docs/architecture/the-bridge.mdx
Normal file
@@ -0,0 +1,131 @@
|
||||
---
|
||||
title: The bridge
|
||||
description: How a game server reaches the website without ever being reachable itself — the sidecar, the loopback socket, and the rules that keep the game running.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
|
||||
The bridge exists to answer one question safely: **how does a private game server's live
|
||||
state reach a public website?**
|
||||
|
||||
The answer is a **sidecar** — a small service that owns the connection to the game and the
|
||||
durable copy of what the game said. It is not optional, and the reasons are worth
|
||||
understanding before you build one for another game.
|
||||
|
||||
## The shape
|
||||
|
||||
```
|
||||
ServUO shard ──dials out──▶ uo-link sidecar ──HTTP + WS──▶ website
|
||||
(C# plugin) 127.0.0.1:7788 (Rust) bearer + version (module)
|
||||
newline JSON
|
||||
▲ │
|
||||
└──────── the game opens NO port ──────┘
|
||||
```
|
||||
|
||||
Three properties fall out of that diagram, and each is a rule rather than an
|
||||
implementation detail.
|
||||
|
||||
## 1. The game dials out
|
||||
|
||||
**The sidecar is the listener. The game connects to it.** The shard opens no port at all,
|
||||
and nothing on the internet can reach it even in principle.
|
||||
|
||||
This inverts the intuitive design — you would expect the thing with the data to serve it —
|
||||
and the inversion is the whole security argument. Only the sidecar is exposed, and only the
|
||||
website's backend talks to the sidecar.
|
||||
|
||||
The transport is deliberately boring: **newline-delimited JSON, one object per line**, over
|
||||
loopback TCP.
|
||||
|
||||
## 2. A wedged sidecar must never stall the game
|
||||
|
||||
This is the constraint the plugin is built around.
|
||||
|
||||
On the C# side, `Emit()` **enqueues onto a bounded, drop-oldest queue and returns
|
||||
immediately**. It never touches the socket from the game's core thread. Every world read
|
||||
happens on the core thread; a dedicated writer thread drains the queue.
|
||||
|
||||
<Aside type="caution" title="Dropping events beats pausing the game">
|
||||
If the queue fills, the oldest events are discarded. That is the correct trade: a game
|
||||
server that stutters because a logging sidecar is slow is a broken game server, and no
|
||||
website feature is worth a lag spike.
|
||||
|
||||
Design your own plugin the same way. The game thread must never block on I/O — not on a
|
||||
socket, not on a lock held by a writer, not on a DNS lookup.
|
||||
</Aside>
|
||||
|
||||
Inbound commands get the mirror rule: **every inbound handler marshals to the core thread
|
||||
before touching world state.**
|
||||
|
||||
## 3. The sidecar persists before it forwards
|
||||
|
||||
The sidecar owns a durable store. It is not a proxy that translates and forgets — if the
|
||||
website is down, the game's events are still recorded, and a reconnecting website catches
|
||||
up.
|
||||
|
||||
This is what "a *thin* sidecar" means in the Integration Kit: thin in *logic*, not thin in
|
||||
responsibility. The sidecar is a **dumb forwarder** — it makes no access-control decisions
|
||||
and holds no policy. Access control and the admin-toggleable visibility scope live on the
|
||||
**website**, where an administrator can see and change them.
|
||||
|
||||
## Two ways in
|
||||
|
||||
**Live events** arrive over an outbound **WebSocket** and are routed by the module's ingest
|
||||
dispatcher. Kinds are handled differently by nature: state-changing kinds update tables,
|
||||
notable kinds append to an events log, and high-frequency kinds only update state rather
|
||||
than accumulating history.
|
||||
|
||||
**Point-in-time reads and commands** go over **REST**, through a client that never throws.
|
||||
|
||||
Every call carries `Authorization: Bearer <token>` and an `X-UOLink-Version` header. **A
|
||||
protocol mismatch fails fast with `409`** rather than being mis-parsed — see [Protocol
|
||||
versions](/docs/architecture/protocol-versions/).
|
||||
|
||||
## What the shard can say
|
||||
|
||||
The catalog spans sessions and identity, character state, economy and commerce, housing and
|
||||
IDOC, combat and PvP, progression, cheat detection and staff audit, and server lifecycle.
|
||||
A representative line looks like:
|
||||
|
||||
```json
|
||||
{"t":1752,"kind":"vendor.sale",
|
||||
"buyer":{"serial":"0x1A2B","acct":"PerryAdimn"},
|
||||
"owner":{"serial":"0x33C1","acct":"Feng"},
|
||||
"item":{"serial":"0x4001A2","type":"Longsword","amount":1},
|
||||
"price":75000,"commission":3750}
|
||||
```
|
||||
|
||||
The full catalog is [Event catalog](/docs/reference/event-catalog/).
|
||||
|
||||
## Two design details worth stealing
|
||||
|
||||
**`server.hello` is per-connection, not per-boot.** The sidecar restarts independently of
|
||||
the game, so anything it needs up front must be re-sent on **every** connect. An earlier
|
||||
draft emitted a "started" event once at boot; a sidecar that came up second never received
|
||||
it and had no idea which shard it was attached to.
|
||||
|
||||
It carries a `bootId` — a GUID generated at server start, stable across sidecar reconnects
|
||||
and changed on every game restart. That is how the sidecar tells *"I reconnected"* (keep
|
||||
cached state) from *"the game restarted"* (discard it).
|
||||
|
||||
**Rosters are sets, not signatures.** Guild membership is compared as a set rather than
|
||||
folded into a checksum, because a sum can collide: one member joining and another leaving
|
||||
between two sweeps offset each other, and the guild reads as unchanged. A set can also be
|
||||
*differenced*, which is what makes per-member leave events possible for a game that raises
|
||||
no event for leaving.
|
||||
|
||||
On a guild's **first** sweep there is no prior set, so nothing is reported as leaving — an
|
||||
unknown roster becoming known is not 155 people leaving at once.
|
||||
|
||||
## Building one for another game
|
||||
|
||||
The bridge is not UO-specific in shape, only in vocabulary. Chapters 3 and 4 of [the
|
||||
Integration Kit](/docs/modules/the-integration-kit/) cover the sidecar and the game-side
|
||||
plugin, and they are the two parts where the mistakes are most expensive.
|
||||
|
||||
## Canonical documents
|
||||
|
||||
[`link/PLAN.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md)
|
||||
§5 and §7 are the data catalog and the wire protocol;
|
||||
[`link/INTEGRATION.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/INTEGRATION.md)
|
||||
is the integration guide. Both are normative; this page is not.
|
||||
@@ -106,10 +106,17 @@ sidebar — `/admin/uo/link`. Tick *Enable the shard integration*, paste **Base
|
||||
**WebSocket URL**, **Auth token** and **Protocol**, and save. The ingest client restarts
|
||||
immediately.
|
||||
|
||||
<Aside type="caution" title="The installer prints an older path for that screen">
|
||||
It prints `…/admin/shard`. Since the shard screens became part of the `uo` module, a module
|
||||
owns one path segment and the screen moved to **`/admin/uo/link`**. Use the sidebar, or
|
||||
that path; the printed link no longer resolves.
|
||||
<Aside type="caution" title="Installer v0.1.0 prints an older path for that screen">
|
||||
v0.1.0 prints `…/admin/shard`. Since the shard screens became part of the `uo` module — and
|
||||
a module owns one path segment wherever it appears — the screen moved to
|
||||
**`/admin/uo/link`**.
|
||||
|
||||
The old path does not fail visibly: the site has no route for it, so it sends you to the
|
||||
dashboard, and that looks like the link worked. The four values you were just told to paste
|
||||
then have nowhere to go. Use the sidebar, or the path above.
|
||||
|
||||
Fixed in **v0.1.1**, which prints the real path. Only matters if you are running the older
|
||||
binary.
|
||||
</Aside>
|
||||
|
||||
The token is encrypted at rest and **never returned to any client** — losing it means
|
||||
|
||||
@@ -4,6 +4,7 @@ description: Signing in as the first admin, what the site does before anyone vis
|
||||
---
|
||||
|
||||
import { Aside, Steps } from '@astrojs/starlight/components';
|
||||
import Screenshot from '../../../../components/Screenshot.astro';
|
||||
|
||||
The site is up and nobody can see it yet. That is the intended state: a new deployment
|
||||
**starts in maintenance mode**, showing visitors a "coming soon" page while the admin panel
|
||||
@@ -33,6 +34,8 @@ minute, and see [Authentication](/docs/administration/authentication/) for what
|
||||
**Web Bot Activity** screen shows and how to lift a ban.
|
||||
</Aside>
|
||||
|
||||
<Screenshot id="admin-dashboard" />
|
||||
|
||||
## What is already there
|
||||
|
||||
The first boot seeds a working site rather than an empty one:
|
||||
|
||||
161
src/content/docs/docs/modules/building-a-module.mdx
Normal file
@@ -0,0 +1,161 @@
|
||||
---
|
||||
title: Building a module
|
||||
description: The repository layout, the server half, and the client build — including the three things about bundling that everyone gets wrong once.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
|
||||
Start from [the Integration Kit's template](/docs/modules/the-integration-kit/) rather than
|
||||
an empty directory. This page explains what the template is doing and why, so that when you
|
||||
change something you know what you are changing.
|
||||
|
||||
## The layout
|
||||
|
||||
One repository, both halves, versioned together:
|
||||
|
||||
```
|
||||
module.json id, version, coreApi, mounts, extensions
|
||||
server/index.js the entry point — exports register(ctx, api)
|
||||
server/db/schema.sql idempotent fragment, replayed every boot
|
||||
server/db/purge.sql destructive; only ever run by an explicit purge
|
||||
server/router/ routers and controllers
|
||||
server/model/ *.model.js (logic) + *.db.js (SQL) pairs
|
||||
client/src/entry.jsx registers routes, nav, providers
|
||||
client/src/shim/ the shared-dependency shims — see below
|
||||
client/dist/entry.js PREBUILT chunk, published by your CI
|
||||
```
|
||||
|
||||
`client/dist/` is committed by your **release**, not by hand — the operator never builds,
|
||||
so the built chunk has to be in the bundle.
|
||||
|
||||
## The server half
|
||||
|
||||
`server/index.js` exports one function, called once during core's require phase:
|
||||
|
||||
```js
|
||||
module.exports = function register(ctx, api) {
|
||||
const log = ctx.log('examplegame')
|
||||
|
||||
api.registerRoutes({
|
||||
public: { '/world': worldRouter(ctx) },
|
||||
})
|
||||
|
||||
api.onBoot(async (ctx) => {
|
||||
// anything that needs a live database goes HERE, not above
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
Follow core's own layering — `router → controller → model → db`, with `.model.js` (logic)
|
||||
and `.db.js` (SQL) pairs, and raw parameterised queries. There is no ORM anywhere in this
|
||||
project, and a module that introduces one is a module nobody else can read.
|
||||
|
||||
### The rule CI enforces
|
||||
|
||||
**Zero `require`/`import` may reach outside your own directory.** Not "few". Zero.
|
||||
|
||||
```bash
|
||||
npm run check:imports --prefix server
|
||||
```
|
||||
|
||||
If you need something from core that `ctx` does not offer, that is a gap in the contract —
|
||||
raise it, so the surface grows deliberately. Reaching into core's internals is how a module
|
||||
breaks on a refactor it had no part in.
|
||||
|
||||
## The client half
|
||||
|
||||
Your chunk is built with Vite in **library mode**, emitting one unhashed `dist/entry.js`.
|
||||
Unhashed deliberately: `module.json` names that file, and a hashed name would have to be
|
||||
discovered at runtime. Core answers the caching question instead, serving it `no-cache`.
|
||||
|
||||
Then three things about the bundling, each of which has already cost somebody a day.
|
||||
|
||||
### 1. Aliases replace `external` — they do not accompany it
|
||||
|
||||
This is the one that looks most like it should work.
|
||||
|
||||
Rollup asks `external` **before** Vite's alias resolver runs, so a specifier listed there is
|
||||
marked external and **never aliased**. The chunk then ships bare `import 'react'`
|
||||
specifiers, which a browser cannot resolve without an import map — and an import map has to
|
||||
be inline, which `script-src 'self'` forbids.
|
||||
|
||||
The first real module shipped with both, **built cleanly**, and emitted exactly that chunk.
|
||||
|
||||
```js
|
||||
rollupOptions: { external: [] }, // deliberately empty
|
||||
```
|
||||
|
||||
Alias only. Nothing in `external`. (`output.globals` does not rescue this either — it covers
|
||||
iife/umd and does nothing for an ES module.)
|
||||
|
||||
### 2. Use the array form of `resolve.alias`, with anchored regexes
|
||||
|
||||
Vite's **object** form does *prefix* matching, so a `react` key also rewrites
|
||||
`react/jsx-runtime` — silently, to the wrong shim. The chunk then fails at its first element
|
||||
with a message about `jsx` not being a function, which points nowhere near the cause.
|
||||
|
||||
```js
|
||||
alias: SHARED.map(({ specifier, shim }) => ({
|
||||
find: new RegExp(`^${escape(specifier)}$`),
|
||||
replacement: shim,
|
||||
}))
|
||||
```
|
||||
|
||||
`^react$` and `^react/jsx-runtime$` cannot collide.
|
||||
|
||||
### 3. Assert at resolution time, not by grepping the output
|
||||
|
||||
The risk is a missed alias welding a **second React** into your chunk. That loads fine and
|
||||
then throws about an invalid hook call somewhere unrelated.
|
||||
|
||||
The template fails the build if any shared package resolves into `node_modules`. Two details
|
||||
of how it does that are not interchangeable:
|
||||
|
||||
- It hooks **`transform`, not `load`**. `load` is first-wins, so an earlier plugin returning
|
||||
the module's contents means the guard is never called. Written against `load`, it sat in
|
||||
the build doing nothing while a deliberately-broken alias produced a green build with
|
||||
react-router welded in.
|
||||
- The list of packages that may not be bundled is stated **independently** of the alias
|
||||
list. Deriving one from the other means deleting an alias also deletes the guard against
|
||||
what that alias prevented.
|
||||
|
||||
<Aside type="caution" title="Why shims rather than plain externals">
|
||||
Each shared dependency is aliased to a two-line module re-exporting from `window.__rg`.
|
||||
|
||||
The **named** re-exports matter: `import { useState } from 'react'` compiles to a named
|
||||
import, and a shim with only a default export fails at link time in the browser with a
|
||||
message about the binding — not about the shim.
|
||||
|
||||
Route every shim through one file that reads `window.__rg` and throws a useful error when
|
||||
it is missing. Otherwise the first symptom of a core ordering fault is
|
||||
`Cannot read properties of undefined (reading 'react')` thrown from a file called
|
||||
`react.js`, which reads like *your* bundling is wrong when it is the opposite.
|
||||
</Aside>
|
||||
|
||||
Verify with:
|
||||
|
||||
```bash
|
||||
npm run build --prefix client # build BEFORE the tests — two of them read the chunk
|
||||
npm run check:externals --prefix client
|
||||
```
|
||||
|
||||
## Registering the client half
|
||||
|
||||
```js
|
||||
const { registry } = window.__rg
|
||||
|
||||
registry.registerRoutes(ID, {
|
||||
public: [{ path: 'world', element: <WorldStatus /> }],
|
||||
admin: [{ path: 'link', element: <Admin /> }],
|
||||
})
|
||||
registry.registerNav(ID, { … })
|
||||
```
|
||||
|
||||
Paths are **relative to your module's segment** — `path: 'link'` under `admin` becomes
|
||||
`/admin/<id>/link`. Check `window.__rg.version` against your `coreApi` range and refuse to
|
||||
register on a mismatch.
|
||||
|
||||
## Then
|
||||
|
||||
[Testing and release](/docs/modules/testing-and-release/) covers CI, the checks, and
|
||||
publishing the bundle and its manifest.
|
||||
115
src/content/docs/docs/modules/installing-modules.mdx
Normal file
@@ -0,0 +1,115 @@
|
||||
---
|
||||
title: Installing modules
|
||||
description: How a module reaches a deployment — the install manifest, the two surfaces that can install one, and which of them wins.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
|
||||
There is no catalog, and there is no marketplace. A module is installed by **naming the
|
||||
URL of a release's install manifest**.
|
||||
|
||||
That is a design decision rather than an unfinished feature: a catalog would make core's
|
||||
release cadence decide which modules exist, and the whole point of the module system is
|
||||
that it does not.
|
||||
|
||||
<Aside type="note" title="Doing this once, as an operator?">
|
||||
[Install a game module](/docs/getting-started/install-a-game-module/) walks the happy path,
|
||||
and [Managing modules](/docs/administration/managing-modules/) covers the screen
|
||||
afterwards. This page is about how distribution works, for people publishing one.
|
||||
</Aside>
|
||||
|
||||
## What a release publishes
|
||||
|
||||
Two artifacts:
|
||||
|
||||
- **`<id>-<version>.tar.gz`** — the bundle: `module.json`, the server half, the prebuilt
|
||||
client chunk, and the SQL fragments.
|
||||
- **An install manifest** — small JSON carrying the bundle's URL and its **`sha256`**.
|
||||
|
||||
The manifest URL is the thing an operator pastes. The bundle is downloaded, **verified
|
||||
against the `sha256`**, and unpacked into `modules/<id>/` on the mounted volume.
|
||||
|
||||
Nothing is compiled at any point in that sequence.
|
||||
|
||||
## The two surfaces
|
||||
|
||||
Both write the same `installed_modules` row, and neither needs a build step.
|
||||
|
||||
### The admin panel
|
||||
|
||||
Paste the manifest URL, press Install, then **restart** — a button on the same screen, not
|
||||
an instruction to go and restart the container. It runs the lifecycle shutdown and exits,
|
||||
and the supervisor declared in the shipped Compose file brings the process back.
|
||||
|
||||
That is why `restart: unless-stopped` is called out as load-bearing on [Install the
|
||||
site](/docs/getting-started/install-the-site/). Without a supervisor, that button takes the
|
||||
site down and leaves it down.
|
||||
|
||||
### The `MODULES` environment variable
|
||||
|
||||
For hosts managed by Compose rather than by clicking. Each entry is:
|
||||
|
||||
```
|
||||
<id>@<version>=<install manifest URL>
|
||||
```
|
||||
|
||||
Resolution runs **inside the server process**, before the volume is scanned — which is what
|
||||
lets it write the same provenance columns a panel install writes. A module already unpacked
|
||||
at the declared version is a no-op that makes **no network call at all**.
|
||||
|
||||
### By hand
|
||||
|
||||
`./modules` is a bind mount, deliberately rather than a named volume, so placing a module
|
||||
directory there yourself is a **supported install**. A named volume would have routed that
|
||||
through `docker cp`.
|
||||
|
||||
The image's own copy of `modules/` is excluded by `.dockerignore`, so a module sitting in a
|
||||
builder's working tree can never ship inside an image.
|
||||
|
||||
## Which surface wins
|
||||
|
||||
They govern different things, and the split is worth memorising:
|
||||
|
||||
- **The declaration owns what is on the volume.**
|
||||
- **The row owns whether a module runs.**
|
||||
|
||||
So uninstalling a declared module from the admin panel **returns its files at the next
|
||||
start and leaves it disabled**. The files come back because `MODULES` still declares them;
|
||||
it stays off because the row says so. That is the intended outcome, not a bug — but it
|
||||
surprises people who expect the panel to be the last word.
|
||||
|
||||
## Upgrades
|
||||
|
||||
Paste the new release's manifest URL and install over the top. The bundle is verified,
|
||||
unpacked over the old one, and takes effect at the restart.
|
||||
|
||||
An upgrade **deliberately leaves the state alone** — upgrading an enabled module must not
|
||||
silently switch it off, and re-installing a disabled one must not silently switch it on.
|
||||
|
||||
<Aside type="caution" title="Check the API range first">
|
||||
A module declares which core API versions it accepts. If a release needs a newer core than
|
||||
your image provides, upgrade the site first — see [The module
|
||||
manifest](/docs/modules/the-module-manifest/) for how that range is checked, and
|
||||
[Maintenance and upgrades](/docs/administration/maintenance-and-upgrades/) for the site
|
||||
half.
|
||||
</Aside>
|
||||
|
||||
## Removal
|
||||
|
||||
Covered in full on [Managing modules](/docs/administration/managing-modules/); the shape
|
||||
matters here because it constrains what you ship.
|
||||
|
||||
**Uninstall** is non-destructive: the row goes to `disabled`, the directory is removed, and
|
||||
the module's **tables and data are retained**.
|
||||
|
||||
**Purge** is separate, explicit, and destructive — it runs your `purge.sql`. It is offered
|
||||
in two places, and both are while the file is still on disk: as a standalone action on an
|
||||
installed module, and as an opt-in checkbox in the uninstall dialog.
|
||||
|
||||
That second placement exists because of a real ordering trap: **`purge.sql` lives inside the
|
||||
directory uninstall deletes**, so "purge afterwards" was never actually possible — it would
|
||||
have left a disabled row whose Purge button had nothing to run.
|
||||
|
||||
The consequence, accepted and stated: an operator who uninstalls without ticking the box
|
||||
keeps the tables, and getting rid of them later means reinstalling the module first. Write
|
||||
`purge.sql` on the assumption it may be run long after anyone remembers what it drops.
|
||||
103
src/content/docs/docs/modules/module-lifecycle.mdx
Normal file
@@ -0,0 +1,103 @@
|
||||
---
|
||||
title: Module lifecycle
|
||||
description: What core does to your module on boot, in what order, and what happens when any step of it throws.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
|
||||
The five states are on [Managing modules](/docs/administration/managing-modules/), from the
|
||||
operator's side. This is the same machine from inside the module — what core calls, when,
|
||||
and what it does with a throw.
|
||||
|
||||
## The scan
|
||||
|
||||
The loader reads `modules/*/module.json` from the filesystem **synchronously, at require
|
||||
time**. The database is not consulted: what is on the volume determines what mounts.
|
||||
|
||||
`MODULES_DIR` defaults to `<repo>/modules`, and Compose sets it to `/app/modules`. **A
|
||||
missing modules directory is not an error** — "no modules installed" is the normal state of
|
||||
bare core, and the loader must not make the mount mandatory to boot.
|
||||
|
||||
Modules load **alphabetically by `id`**, deterministically. There is no dependency
|
||||
resolution between modules, and alphabetical order is the honest way of saying so: any
|
||||
other order would imply a precedence nobody is computing. Do not build a module that needs
|
||||
to load before or after another one.
|
||||
|
||||
## Validation, in order
|
||||
|
||||
Each step runs against your module. A failure at any step is **your module's failure and
|
||||
nobody else's**.
|
||||
|
||||
1. `module.json` parses, has no unknown keys, and its `id` matches the directory name.
|
||||
2. `coreApi` is satisfied by core's `MODULE_API_VERSION`.
|
||||
3. Declared `mounts` prefixes are well-formed and collide with nothing.
|
||||
4. Declared `extensions` slots all exist.
|
||||
5. `schema` and `purge` files exist and are readable, and their table names are namespaced
|
||||
or allowlisted.
|
||||
6. `require()` of your server entry succeeds and exports a function.
|
||||
7. `register(ctx, api)` returns without throwing, **and registers exactly what
|
||||
`module.json` declared**.
|
||||
|
||||
Step 7 is worth reading twice. The manifest is not documentation of what you register — it
|
||||
is a claim core holds you to. Registering something you did not declare fails, and so does
|
||||
declaring something you do not register.
|
||||
|
||||
<Aside type="note" title="Collision detection probes the live routers">
|
||||
Step 3 asks the actual tier routers whether a prefix is taken, rather than consulting a
|
||||
list of core's prefixes. A hardcoded table was tried and was already one prefix stale by
|
||||
the time it was written.
|
||||
|
||||
Mounting is also a **second pass** over the modules that survived validation, not part of
|
||||
the scan loop — otherwise the first module's layers would already be on the router while
|
||||
the second was validated, and the second would be told it collided with *core*, naming the
|
||||
wrong culprit.
|
||||
</Aside>
|
||||
|
||||
## Then the module runs
|
||||
|
||||
For each module that passed:
|
||||
|
||||
1. **Schema replay** — your `schema.sql` fragment is applied. It must be idempotent; it runs
|
||||
on every boot.
|
||||
2. **Routes and registrations** mount.
|
||||
3. **`onBoot(ctx)`** is called, if you export one. This is where long-lived work belongs:
|
||||
opening a stream, starting a poller, connecting to something.
|
||||
|
||||
On shutdown, **`onShutdown()`** is called. Disabling a module from the panel dispatches it
|
||||
too, so the module actually stops — releases its sockets, closes its streams — rather than
|
||||
merely becoming unreachable.
|
||||
|
||||
Enabling is deliberately **not** the mirror image: there is no `onBoot` re-dispatch, so the
|
||||
panel offers a restart instead. If your `onBoot` is expensive or stateful, that asymmetry is
|
||||
in your favour.
|
||||
|
||||
## Failure is contained, by construction
|
||||
|
||||
**A module that fails to load never takes the site down.**
|
||||
|
||||
The loader try/catches the module's **entire** lifecycle — require, validation, registration,
|
||||
schema replay, `onBoot` — not merely failures that surface after a router object was
|
||||
returned. Any failure at any point marks that module `startup_failed`, records the reason,
|
||||
and the site comes up with that module's routes and navigation absent.
|
||||
|
||||
Two consequences to design around:
|
||||
|
||||
- **A failed module is retried on every restart.** There is no backoff and no quarantine.
|
||||
A deterministically broken module re-records its failure each boot, which is the honest
|
||||
thing for it to do.
|
||||
- **`disabled` is the only state a boot leaves alone.** Every other non-disabled module is
|
||||
reset to `enabled` at boot and then recorded as `started` or `startup_failed`. Disabling
|
||||
is an operator's decision rather than an outcome, so it survives restarts untouched.
|
||||
|
||||
<Aside type="caution" title="Fail loudly and early">
|
||||
Because failure is contained, a broken module is easy to *not notice* — the site comes up
|
||||
fine and one section is missing. Validate your own configuration in `register()` or
|
||||
`onBoot()` and throw with a message naming what is wrong. `startup_failed` with a good
|
||||
reason is a far better outcome than a module that starts and then quietly does nothing.
|
||||
</Aside>
|
||||
|
||||
## Where the loader is specified
|
||||
|
||||
[`MODULE_API.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md)
|
||||
Part 4 is the normative account of everything on this page, including the exact position of
|
||||
the `load()` call in `app.js` and why it is load-bearing in both directions.
|
||||
104
src/content/docs/docs/modules/testing-and-release.mdx
Normal file
@@ -0,0 +1,104 @@
|
||||
---
|
||||
title: Testing and release
|
||||
description: The checks a module should run before it ships, what a release artifact actually is, and how the version that ships gets decided.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
|
||||
## The checks
|
||||
|
||||
Four, and each exists because something got past review without it.
|
||||
|
||||
```bash
|
||||
npm run check:imports --prefix server # zero imports leave the module directory
|
||||
npm run build --prefix client # build FIRST — two tests read the chunk
|
||||
npm run check:externals --prefix client # no shared dependency welded into the chunk
|
||||
npm test --prefix server && npm test --prefix client
|
||||
```
|
||||
|
||||
**`check:imports`** enforces [the zero-internal-imports
|
||||
rule](/docs/modules/building-a-module/#the-rule-ci-enforces). It is the mechanical form of
|
||||
the module boundary — without it, the boundary is a convention, and conventions lose.
|
||||
|
||||
**`check:externals`** is the one that catches a chunk shipping bare `import 'react'`
|
||||
specifiers, or a second React welded in. Both build cleanly. Neither works in a browser.
|
||||
|
||||
<Aside type="caution" title="Build before you test">
|
||||
Two client tests read the built chunk. Run them against a stale `dist/` and they will
|
||||
happily pass on last week's output.
|
||||
</Aside>
|
||||
|
||||
Also worth running your OpenAPI fragment check if you publish one — the filename is fixed
|
||||
at `swagger-fragment.json` in the bundle root, so a module cannot point core at some other
|
||||
file.
|
||||
|
||||
## What a release artifact is
|
||||
|
||||
**Not source.** An operator never builds anything, and that constraint shapes everything
|
||||
here.
|
||||
|
||||
A release is **the directory core's loader expects to find at `modules/<id>/`, already
|
||||
assembled** — the prebuilt client chunk, any runtime dependency installed, the schema
|
||||
fragment, the OpenAPI fragment — packed exactly as it will be unpacked.
|
||||
|
||||
Two artifacts ship:
|
||||
|
||||
- `<id>-<version>.tar.gz`
|
||||
- an **install manifest** carrying that tarball's URL and its `sha256`
|
||||
|
||||
The admin install downloads the tarball, verifies the hash, and unpacks it. **Nothing runs
|
||||
`npm` on the way.**
|
||||
|
||||
## The version that ships is the tag
|
||||
|
||||
The template derives the next version from conventional-commit subjects since the newest
|
||||
`v*` tag:
|
||||
|
||||
| Commits since the last tag | Result |
|
||||
|---|---|
|
||||
| `feat!:` or `BREAKING CHANGE` | major |
|
||||
| `feat:` | minor |
|
||||
| `fix:` / `perf:` | patch |
|
||||
| Nothing releasable | **no release is cut** |
|
||||
| First ever run, no tag | releases what `module.json` declares |
|
||||
|
||||
Your committed `module.json` version is a **floor and a starting point, not a record of the
|
||||
last release**. Name a version there above the newest tag and that version is what releases
|
||||
— which is still the natural way to say "this one is a minor" when a `coreApi` bump forces
|
||||
the question.
|
||||
|
||||
<Aside type="note" title="Why derived rather than declared">
|
||||
The obvious alternative is to let `module.json`'s version decide: you already have that
|
||||
number, and two sources for one number is how they drift.
|
||||
|
||||
This project's reference module shipped that way and moved off it. The cost of a declared
|
||||
version is paid on **every** release, and the drift it prevents is something review catches
|
||||
anyway — a week of merged work there produced no bundle at all, because none of it happened
|
||||
to touch that line.
|
||||
</Aside>
|
||||
|
||||
## Pin the core you build against
|
||||
|
||||
Keep a `ci/core-ref.json` naming the exact core commit your module is written against, and
|
||||
have CI assert your declared `coreApi` still holds against that core's
|
||||
`MODULE_API_VERSION`.
|
||||
|
||||
Moving that sha is the moment someone re-reads what changed. It is the same mechanism [the
|
||||
Integration Kit uses](/docs/modules/the-integration-kit/#the-pin-that-forces-a-re-read), and
|
||||
the reason a contract bump upstream becomes a visible decision in your repository rather
|
||||
than a silent one.
|
||||
|
||||
## Before you tag
|
||||
|
||||
A short list, all of it learned rather than invented:
|
||||
|
||||
- **The module boots on a real deployment**, not just in tests. [Failure is
|
||||
contained](/docs/modules/module-lifecycle/#failure-is-contained-by-construction), so a
|
||||
broken module is easy to not notice — the site comes up and one section is missing.
|
||||
- **`schema.sql` is genuinely idempotent.** It runs on every boot, not once.
|
||||
- **`purge.sql` still makes sense to someone who has forgotten your module**, because
|
||||
[that is who will run it](/docs/modules/installing-modules/#removal).
|
||||
- **Your `coreApi` range covers the oldest core you actually test against**, not just the
|
||||
newest one you have.
|
||||
- **Every capability string you publish is one you intend to keep.** Something outside your
|
||||
repository is branching on them.
|
||||
107
src/content/docs/docs/modules/the-integration-kit.mdx
Normal file
@@ -0,0 +1,107 @@
|
||||
---
|
||||
title: The Integration Kit
|
||||
description: The instruction book for putting a different game on the platform — four chapters, a buildable template, and an honest account of its status.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
|
||||
The [Integration
|
||||
Kit](https://gitea.whitlocktech.com/RunicGateway/Integration-kit) is a separate repository
|
||||
whose entire job is teaching someone **outside this project** how to put a different game on
|
||||
the platform.
|
||||
|
||||
<Aside type="caution" title="The kit describes itself as a draft, and so do we">
|
||||
In its own words: *the kit is finished when someone outside this project builds a working
|
||||
module for a new game by following it alone, without reading core's source. That has not
|
||||
happened yet.*
|
||||
|
||||
We are not going to describe it as finished before that happens. If you are the person who
|
||||
tries it, the places you get stuck are the most valuable thing the repository can receive —
|
||||
[open an issue](https://gitea.whitlocktech.com/RunicGateway/Integration-kit/issues) saying
|
||||
where you left the kit and what you did next.
|
||||
</Aside>
|
||||
|
||||
## What it covers
|
||||
|
||||
Three things, because the reasons live in the joins between them:
|
||||
|
||||
```
|
||||
your game server ──dials out──▶ your sidecar ──HTTP + WS──▶ website core
|
||||
(plugin: bounded queue, (owns the socket, (loads your module,
|
||||
writer thread) persists, then forwards) serves the pages)
|
||||
```
|
||||
|
||||
| Part | What it is |
|
||||
|---|---|
|
||||
| **The website module** | A bundle core loads at boot. The bulk of the work, and the only part every module needs |
|
||||
| **The sidecar** | A small service owning the connection to your game server, and the durable copy of what the game said. **Not optional** |
|
||||
| **The game-side plugin** | Whatever runs inside your game and feeds the sidecar, without ever letting the sidecar stall the game |
|
||||
|
||||
## The four chapters
|
||||
|
||||
| # | Chapter | What it covers |
|
||||
|---|---|---|
|
||||
| 1 | Your first module in twenty minutes | Copy the template, rename it, build it, install it, see a page. No theory |
|
||||
| 2 | The website module | `module.json`, `register(ctx, api)`, the schema fragment, the client chunk, packaging, and what a module must never do |
|
||||
| 3 | The sidecar | Why the website never talks to a game server, what "persist before you forward" means, and what a *thin* sidecar is |
|
||||
| 4 | The game-side plugin | The least code and the highest stakes: never block the game thread |
|
||||
|
||||
Before any of them, the kit points at the [Rust dry
|
||||
run](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/modules/rust-dryrun.md)
|
||||
— a complete module designed on paper for a second game, and the shortest honest picture of
|
||||
the whole job.
|
||||
|
||||
## The template is built, not just quoted
|
||||
|
||||
Chapters 1 and 2 quote `template/`, a real module that CI builds against a pinned core. The
|
||||
code in those chapters is **a tree that is proved rather than prose that looks like one**.
|
||||
|
||||
Chapters 3 and 4 cite `uo-link` and `servuo-plugins` by file and identifier rather than by
|
||||
line number, deliberately: those repositories move for their own reasons, and a line number
|
||||
in a book is wrong the moment they do.
|
||||
|
||||
## The kit never re-specifies a contract
|
||||
|
||||
This is its governing rule, and it is the same one this site follows.
|
||||
|
||||
> Nothing in these chapters is normative. Where a chapter and one of these documents
|
||||
> disagree, the document is right and the chapter has a bug.
|
||||
|
||||
| Authority | For |
|
||||
|---|---|
|
||||
| `MODULE_API.md` | Everything a module may do |
|
||||
| `MODULE_SYSTEM.md` | Why the module system is shaped this way, and how a module is installed and removed |
|
||||
| `link/PLAN.md` + `INTEGRATION.md` | The game ↔ sidecar wire protocol, as one real sidecar implements it |
|
||||
|
||||
The chapters teach the order to do things in, the reasoning, and **the mistakes that cost
|
||||
this project time**.
|
||||
|
||||
## The pin that forces a re-read
|
||||
|
||||
`ci/core-ref.json` pins the exact core commit the kit is written against, and CI asserts
|
||||
that the version `template/module.json` declares **equals** that core's
|
||||
`MODULE_API_VERSION`.
|
||||
|
||||
Equality, not "satisfies". That is the mechanism, not a bug: a contract bump in the website
|
||||
repository is *meant* to turn the kit red, so that someone re-reads the chapters before the
|
||||
pin moves.
|
||||
|
||||
<Aside type="note" title="It has already earned its keep">
|
||||
Writing the chapters against 1.6.0 found that core's inverted-slot fills named three of
|
||||
`module-uo`'s slots **literally** — so the mechanism worked for that one module and silently
|
||||
did nothing for any other game, producing an empty page with nothing logged.
|
||||
|
||||
That is exactly the class of defect a book written for an audience outside this org exists
|
||||
to catch, and it was fixed in core before the pin moved.
|
||||
</Aside>
|
||||
|
||||
## Running its checks
|
||||
|
||||
Dependency-free Node scripts, from the repository root — which is also how a reader runs
|
||||
them:
|
||||
|
||||
```bash
|
||||
node scripts/checkLinks.js # every relative link resolves; no commit permalinks
|
||||
node scripts/checkRenameSites.js # the rename checklist matches the template tree
|
||||
node scripts/checkChapterPaths.js # every path a chapter names in backticks still exists
|
||||
```
|
||||
171
src/content/docs/docs/modules/the-module-api.mdx
Normal file
@@ -0,0 +1,171 @@
|
||||
---
|
||||
title: The module API
|
||||
description: The two arguments core hands your module — what you can reach, what you can register, and the rules that govern both.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
|
||||
Your server entry point exports one function:
|
||||
|
||||
```js
|
||||
module.exports = function register(ctx, api) { /* … */ }
|
||||
```
|
||||
|
||||
`ctx` is what core lends you. `api` is what you register with it. Everything crossing the
|
||||
module boundary goes through one of the two.
|
||||
|
||||
The contract version is **`MODULE_API_VERSION`**, currently **1.6.0**, and your manifest's
|
||||
[`coreApi` range](/docs/modules/the-module-manifest/#coreapi-and-what-a-range-means) is
|
||||
checked against it before your code is required.
|
||||
|
||||
## The entry point runs early
|
||||
|
||||
`register()` is called **once, synchronously, during core's require phase — not after the
|
||||
database is up.**
|
||||
|
||||
It must not `await`, must not touch the database, and must not throw for a reason a retry
|
||||
would fix. Everything needing a live database belongs in `onBoot`.
|
||||
|
||||
<Aside type="caution" title="This constraint is not stylistic">
|
||||
Core's route-manifest and OpenAPI generators both require the app with the connection pool
|
||||
pointed at a dead port. A module that queried at registration time would hang both.
|
||||
</Aside>
|
||||
|
||||
## `ctx` — what you can reach
|
||||
|
||||
Every member exists because a real module needed it. The surface is grown from demonstrated
|
||||
need, never speculation.
|
||||
|
||||
| Member | What it gives you |
|
||||
|---|---|
|
||||
| `ctx.express`, `ctx.validator` | Core's own `express` and `express-validator` namespaces |
|
||||
| `ctx.db.query`, `ctx.db.pool` | Parameterised SQL, and the pool for streaming work |
|
||||
| `ctx.log(namespace)` | `error` / `warn` / `info` / `debug`, each `(msg, meta?)` |
|
||||
| `ctx.settings` | `get`, `set`, `getInstanceName` |
|
||||
| `ctx.auth.getUserFromRequest(req)` | `{ id, username, role }` or `null` |
|
||||
| `ctx.push.publish` | Notification fan-out |
|
||||
| `ctx.secretBox` | `encrypt` / `decrypt` for secrets at rest |
|
||||
| `ctx.middleware` | `requireAuth`, `requireRole`, `siteMode`, `validate`, `noindex`, `rateLimit`, `accountChangeLimiter` |
|
||||
| `ctx.uploads` | `upload`, `UPLOAD_DIR`, `MIME_EXT` |
|
||||
| `ctx.posts` | `listAll`, `getById`, `linkAnnounceJob`, `markAnnounced` |
|
||||
| `ctx.paths.moduleRoot` | Absolute path to your own directory |
|
||||
| `ctx.activity.log` | The admin audit trail |
|
||||
| `ctx.users.getById` | Read a user |
|
||||
| `ctx.site.baseUrl` | Absolute base URL, no trailing slash |
|
||||
| `ctx.moduleId` | Your id, from the manifest |
|
||||
| `ctx.teams` | `publish`, `reconcile`, `activity.push` — see below |
|
||||
|
||||
`ctx` is frozen one level deep before you get it. That is a guard against accident, not
|
||||
against a hostile module — the boundary is organisational, [not a security
|
||||
boundary](/docs/modules/the-module-system/#the-boundary-is-not-a-sandbox).
|
||||
|
||||
### Three narrowings worth knowing
|
||||
|
||||
Core deliberately hands you **less** than the underlying utility exports.
|
||||
|
||||
- **`ctx.auth` is one function.** The full facade can mint sessions; minting is core's job.
|
||||
A module that needs an identity needs to *read* one.
|
||||
- **`ctx.settings` is three functions**, not the model's 24 — most of those are registration
|
||||
and app-links policy that is core's business.
|
||||
- **`ctx.posts` is four functions.** `create` / `update` / `remove` are the CMS, and the CMS
|
||||
is not a module's.
|
||||
|
||||
<Aside type="note" title="Why `ctx.express` has to exist">
|
||||
A module lives at `modules/<id>/`, outside `server/`, so Node's resolver never reaches
|
||||
core's `node_modules` and a plain `require('express')` simply fails. Even where it
|
||||
resolved, a second express in the process means a second `Router` prototype. Core owns one
|
||||
express, exactly as it owns one React.
|
||||
</Aside>
|
||||
|
||||
### `ctx.teams` is push-only, on purpose
|
||||
|
||||
There is no reader. A module **answers** questions about Teams; it does not ask them. Every
|
||||
Team table is core-internal, and a `getTeamRoster` would be core offering to read back the
|
||||
module's own answer — which the module already holds.
|
||||
|
||||
All three members are fire-and-forget and never reject, because they are called from inside
|
||||
game-event handlers and a storage problem of core's must not become your control flow.
|
||||
|
||||
See [Teams architecture](/docs/architecture/teams-architecture/) for the whole shape.
|
||||
|
||||
## `api` — what you register
|
||||
|
||||
```js
|
||||
api.registerRoutes({ public: {…}, admin: {…}, player: {…} })
|
||||
api.registerExtension(slot, router)
|
||||
api.registerNotificationStreams(streams)
|
||||
api.registerAnnounceLeg({ leg, label, dispatch, classify })
|
||||
api.registerPostHook({ onSaved, onDeleted })
|
||||
api.registerTeamProvider({ getTeams, getTeamMembers, getTeamLeaders })
|
||||
api.registerSlashCommands([{ name, description, options, access, handler }])
|
||||
api.onBoot(async (ctx) => {})
|
||||
api.onShutdown(async () => {})
|
||||
```
|
||||
|
||||
Every call is synchronous, and **calling one twice is an error** rather than a
|
||||
last-one-wins overwrite.
|
||||
|
||||
### Everything stages; nothing commits until you are known good
|
||||
|
||||
A claim's *shape* is checked at the call, so a malformed one throws with your own stack.
|
||||
Whether a name is *taken* can only be answered once the whole batch is in, and is checked
|
||||
when the loader commits.
|
||||
|
||||
The consequence is the one that matters: a module that registers two streams and then
|
||||
throws **has left nothing behind**. A half-registered catalog would be worse than a missing
|
||||
one — it is a subscribable stream that nothing will ever publish to.
|
||||
|
||||
### `registerRoutes` and the tier gate
|
||||
|
||||
One `express.Router()` per prefix per tier. The keys must match `module.json`'s `mounts`
|
||||
exactly, and prefixes are one segment — no nesting, no parameters.
|
||||
|
||||
**The tier gate is already applied.** A router registered under `admin` sits behind
|
||||
`noindex, isLoggedIn, requireRole('admin','editor','moderator')`; under `player`, behind
|
||||
`noindex, requireAuth`; under `public`, behind nothing, by design.
|
||||
|
||||
Add per-route gates on top of that. **Never re-implement the tier gate** — a module that
|
||||
rolls its own is a module whose access rules drift from core's.
|
||||
|
||||
Your router is mounted *inside* the tier, so it structurally cannot reach above its prefix.
|
||||
|
||||
## The client half
|
||||
|
||||
The client contract is its own thing. Core populates a global before it renders, and
|
||||
freezes it afterwards:
|
||||
|
||||
```js
|
||||
window.__rg = {
|
||||
version, // MODULE_API_VERSION — the same number as the server's
|
||||
react, // the React namespace
|
||||
reactDom, // react-dom/client
|
||||
router, // react-router-dom namespace
|
||||
jsxRuntime, // react/jsx-runtime
|
||||
registry, // routes, nav, feature providers, slots
|
||||
ui, // the shared component kit
|
||||
api, // the request primitive
|
||||
}
|
||||
```
|
||||
|
||||
Your chunk declares `react`, `react-dom` and `react-router-dom` as **externals** resolving
|
||||
to that global — a global rather than an import map precisely because an import map must be
|
||||
inline and `script-src 'self'` forbids inline script.
|
||||
|
||||
**`jsxRuntime` is not decoration.** Your bundler compiles every `.jsx` file to imports from
|
||||
`react/jsx-runtime` under the modern automatic runtime, and those must resolve to *core's*
|
||||
React like everything else. Without it on the global you would have to build with
|
||||
`jsxRuntime: 'classic'`; with it, you use the default your tooling already assumes.
|
||||
|
||||
**`version` is there so your entry can check it.** A module entry compares
|
||||
`window.__rg.version` against its own `coreApi` range and refuses to register on a
|
||||
mismatch, logging once — the client-side twin of the boot-time check.
|
||||
|
||||
You register routes, navigation and feature providers through `registry`. See [Building a
|
||||
module](/docs/modules/building-a-module/).
|
||||
|
||||
## The contract itself
|
||||
|
||||
[`MODULE_API.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md)
|
||||
is normative and complete — Part 2 for the server contract, Part 3 for the client, Part 4
|
||||
for the loader's obligations and Part 5 for how they are enforced. This page is a map of
|
||||
it, not a substitute.
|
||||
114
src/content/docs/docs/modules/the-module-manifest.mdx
Normal file
@@ -0,0 +1,114 @@
|
||||
---
|
||||
title: The module manifest
|
||||
description: Every key in module.json, what the loader does with each, and why a typo is a boot failure rather than an inert setting.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
|
||||
`module.json` sits at the root of your bundle. The loader reads it synchronously, before
|
||||
anything else about your module runs.
|
||||
|
||||
**Unknown top-level keys are rejected, not ignored.** A misspelled key is a loud failure
|
||||
rather than a silently-inert setting — which is the right trade when the alternative is a
|
||||
module that boots and mysteriously does half its job.
|
||||
|
||||
## A complete manifest
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uo",
|
||||
"name": "Ultima Online",
|
||||
"version": "1.0.0",
|
||||
"coreApi": "^1.0.0",
|
||||
"server": "server/index.js",
|
||||
"client": { "entry": "client/dist/entry.js" },
|
||||
"schema": "server/db/schema.sql",
|
||||
"purge": "server/db/purge.sql",
|
||||
"mounts": {
|
||||
"public": ["/shard", "/atlas"],
|
||||
"admin": ["/shard", "/uo-link"],
|
||||
"player": ["/shard"]
|
||||
},
|
||||
"extensions": ["admin.users.detail"],
|
||||
"capabilities": ["shard", "atlas", "market"]
|
||||
}
|
||||
```
|
||||
|
||||
## The keys
|
||||
|
||||
| Key | Required | Meaning |
|
||||
|---|---|---|
|
||||
| `id` | yes | `^[a-z][a-z0-9-]{1,31}$`. The directory name, the `installed_modules` key, the URL segment, and the client registry key — all at once. **Must equal the directory it was read from.** |
|
||||
| `name` | yes | Human label for the admin Modules screen |
|
||||
| `version` | yes | Semver. Recorded on install; shown on failure |
|
||||
| `coreApi` | yes | Semver **range**, checked against core's `MODULE_API_VERSION` |
|
||||
| `server` | no | Server entry point, relative to the module root. Absent means a client-only module |
|
||||
| `client.entry` | no | The prebuilt ESM chunk, **in a subdirectory** — the directory it sits in is what gets served. Absent means a server-only module; present-but-empty is rejected, because it claims a client half and delivers none |
|
||||
| `schema` | no | Idempotent SQL fragment, replayed every boot |
|
||||
| `purge` | no | Destructive teardown. **Required if `schema` is present** |
|
||||
| `mounts` | no | Declared route prefixes per tier |
|
||||
| `extensions` | no | Core extension slots this module mounts into |
|
||||
| `capabilities` | no | Opaque strings published to clients for feature detection |
|
||||
|
||||
## `mounts` is a claim, not a description
|
||||
|
||||
The loader compares your declaration against what your module **actually registers**, and
|
||||
rejects a mismatch in either direction. Declaring a prefix you never mount fails; mounting
|
||||
one you never declared fails too.
|
||||
|
||||
Prefixes are validated against `^/[a-z0-9][a-z0-9-]*$`, and the keys must match what you
|
||||
register exactly.
|
||||
|
||||
<Aside type="caution" title="These are API prefixes, not page URLs">
|
||||
`mounts` governs your **server** routes. Your SPA pages are registered separately by the
|
||||
client half, and *those* are namespaced under your module id.
|
||||
|
||||
That is why `module-uo` declares `admin: ["/shard", "/uo-link"]` while its admin screen
|
||||
lives at `/admin/uo/link`. Two different mechanisms, and [the module
|
||||
system](/docs/modules/the-module-system/) explains why the split is deliberate.
|
||||
</Aside>
|
||||
|
||||
## `capabilities` is for feature detection
|
||||
|
||||
Opaque strings, published by `GET /api/v1/public/modules` — and **only while the module is
|
||||
`started`**. Clients like the SPA and the Android app read them to decide what to show.
|
||||
|
||||
They are not permissions and not mount prefixes. Keep them stable: something outside your
|
||||
repository is branching on them.
|
||||
|
||||
## `coreApi` and what a range means
|
||||
|
||||
Core exports a single semver string, currently **1.6.0**. Your range is checked at boot,
|
||||
before your code is required.
|
||||
|
||||
A **minor** bump adds members without removing any or changing a signature, so `^1.3.0`
|
||||
keeps resolving against 1.6.0 — which is exactly why `module-uo` still declares `^1.3.0`
|
||||
and runs fine.
|
||||
|
||||
Use a caret range against the oldest core you actually support and test against. Pinning
|
||||
exactly buys nothing and strands you on the next additive release.
|
||||
|
||||
<Aside type="note" title="Not the same number as the protocol version">
|
||||
`coreApi` versions the **website module contract**. `PROTOCOL_VERSION` versions the **shard
|
||||
wire** and says nothing about a website module. See [Protocol
|
||||
versions](/docs/architecture/protocol-versions/).
|
||||
</Aside>
|
||||
|
||||
## Schema and purge
|
||||
|
||||
`schema` runs on **every boot**, so it must be idempotent — `CREATE TABLE IF NOT EXISTS`,
|
||||
and additive migrations written so a replay is harmless. Table names must be namespaced or
|
||||
allowlisted; the loader checks.
|
||||
|
||||
`purge` is required whenever `schema` is present, because a module that can create tables
|
||||
must offer a way to remove them. It is only ever run by an explicit purge — never as part
|
||||
of an uninstall.
|
||||
|
||||
Remember [where `purge.sql` lives](/docs/modules/installing-modules/#removal): inside the
|
||||
directory an uninstall deletes. Write it to be run by someone who no longer remembers what
|
||||
your module created.
|
||||
|
||||
## The full specification
|
||||
|
||||
[`MODULE_API.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md)
|
||||
§2.1 is normative for the manifest, and §2.6 for the schema fragments.
|
||||
103
src/content/docs/docs/modules/the-module-system.mdx
Normal file
@@ -0,0 +1,103 @@
|
||||
---
|
||||
title: The module system
|
||||
description: What a module is, why the platform is built this way, and the one rule about URLs that catches everybody once.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
|
||||
Runic Gateway's core knows nothing about any particular game. Everything that makes the
|
||||
site a *Ultima Online* site — the shard status, the atlas, the market, the guild pages —
|
||||
lives in a **module**, installed onto a running deployment.
|
||||
|
||||
This section is the builder's track. If you only want to install one, that is
|
||||
[Install a game module](/docs/getting-started/install-a-game-module/) and
|
||||
[Managing modules](/docs/administration/managing-modules/).
|
||||
|
||||
## What a module is
|
||||
|
||||
One repository producing one bundle, with a server half and a client half that version
|
||||
together — so a route and the screen that calls it can never be mismatched.
|
||||
|
||||
A module owns:
|
||||
|
||||
- **Its routes**, server and client
|
||||
- **Its schema**, as a fragment core replays on boot
|
||||
- **Its navigation entries**, interleaved into core's groups rather than parked in a
|
||||
section of their own
|
||||
- **Its vocabulary** — the words a player of *that* game expects
|
||||
|
||||
Core owns the account, the session, the roles, the posts, the uploads, notifications and
|
||||
Teams. A module reaches all of that through a defined surface, [the module
|
||||
API](/docs/modules/the-module-api/).
|
||||
|
||||
## Why it is built this way
|
||||
|
||||
Three constraints had to hold at the same time, and between them they determined almost
|
||||
everything else:
|
||||
|
||||
1. **Production is a prebuilt, pull-only image.** Operators do not build. There is no
|
||||
compile step anywhere in installing a module.
|
||||
2. **Modules live on a mounted volume**, not inside the image — a bind mount of
|
||||
`./modules`. That is what lets a module be added to an image that knows nothing about
|
||||
it.
|
||||
3. **`script-src 'self'`.** The content-security policy forbids inline script, which rules
|
||||
out an import map and is why core shares React on a global instead. See [Building a
|
||||
module](/docs/modules/building-a-module/).
|
||||
|
||||
Install and uninstall need a **restart** — never a rebuild.
|
||||
|
||||
<Aside type="note" title="One active module per deployment">
|
||||
Multi-module deployments are deliberately out of scope. `module_id` columns exist so the
|
||||
idea stays later-friendly, but nothing exercises them, and no one should design around
|
||||
them today.
|
||||
</Aside>
|
||||
|
||||
## The boundary is not a sandbox
|
||||
|
||||
A module runs **in the same Node process, with full access**. Say that plainly, because
|
||||
the word "module" invites the opposite assumption.
|
||||
|
||||
The boundary is a **code-organisation and distribution boundary, not a security
|
||||
boundary**. For a self-hosted operator installing software they chose, that is the same
|
||||
trust category as running its schema fragment — which they are also doing.
|
||||
|
||||
What the boundary buys is that modules talk to core through a *defined* surface, so a core
|
||||
refactor cannot silently break a module. That rule is enforced mechanically rather than by
|
||||
review: **a module must run with zero `require`/`import` reaching outside its own
|
||||
directory**, and CI checks it. A gap in the surface extends the surface; it is never
|
||||
worked around with a deeper import.
|
||||
|
||||
## The URL rule, and its one exception
|
||||
|
||||
**A module owns one path segment wherever it appears.** For a module with id `uo`:
|
||||
|
||||
| Surface | Path |
|
||||
|---|---|
|
||||
| Public pages | `/uo/shard`, `/uo/atlas`, `/uo/market` |
|
||||
| Admin pages | `/admin/uo/link`, `/admin/uo/visibility` |
|
||||
| Player pages | `/player/uo/…` |
|
||||
|
||||
**API routes are the exception, and keep their exact paths.** The shard admin API is still
|
||||
`/api/v1/admin/shard/*`, not `/api/v1/admin/uo/shard/*`. This is why the Android app and
|
||||
the Discord bot needed no API changes at the cutover.
|
||||
|
||||
<Aside type="caution" title="This distinction has already cost real time">
|
||||
The installer printed `<site>/admin/shard` — the pre-module path — well after the screen
|
||||
had moved to `/admin/uo/link`. It was not caught quickly because **the old path does not
|
||||
404**: the SPA has no route for it, so it redirects to the dashboard and looks like it
|
||||
worked.
|
||||
|
||||
If you are moving an existing surface into a module, the SPA paths change and the API paths
|
||||
do not. Grep for both.
|
||||
</Aside>
|
||||
|
||||
Old paths are **not** redirected. That was a deliberate call — a visible boundary in the URL
|
||||
rather than a hidden one — taken while the platform had no public deployments to break.
|
||||
|
||||
## Where the design of record lives
|
||||
|
||||
This page summarises. The normative document is
|
||||
[`MODULE_SYSTEM.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_SYSTEM.md),
|
||||
and the contract itself is
|
||||
[`MODULE_API.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md).
|
||||
Where this site and those documents disagree, they are right and this is a bug.
|
||||
73
src/content/docs/docs/reference/bridge-cfg.mdx
Normal file
@@ -0,0 +1,73 @@
|
||||
---
|
||||
title: Bridge.cfg
|
||||
description: Every key the in-game plugin reads — the connection, the sweep intervals, the feature switches and the caps that keep untrusted game data bounded.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
import { bridgeCfg } from '../../../../data/reference.mjs';
|
||||
|
||||
`Config/Bridge.cfg` in the ServUO tree configures the plugin — what it connects to, how
|
||||
often it sweeps the world, and which features it publishes.
|
||||
|
||||
[The installer](/docs/reference/installer-cli/) puts it there. Editing it is a shard
|
||||
operator's job, not a builder's.
|
||||
|
||||
## How to read this file
|
||||
|
||||
Three kinds of key, and they carry very different risk:
|
||||
|
||||
- **Connection** — where the sidecar is, and how much the plugin may buffer.
|
||||
- **Sweep intervals** — how often the plugin walks part of the world. **These are the
|
||||
performance dial.** Every sweep runs on the game's core thread, so shortening one costs
|
||||
the game, not the sidecar.
|
||||
- **Caps and switches** — feature toggles, and the bounds on anything a player can
|
||||
influence.
|
||||
|
||||
<Aside type="caution" title="The caps are a security control, not tuning">
|
||||
`TownCrierMaxLineLength`, `NewsMaxBodyLength`, `AccountNameMaxLength` and their siblings
|
||||
bound data that crosses between a public website and a game world in both directions.
|
||||
|
||||
`AdminWriteEnabled` is **off by default**, and it is the switch that decides whether the
|
||||
website may write to the game at all. Turn it on deliberately, having read
|
||||
[`ADMIN_CONTROLS.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/ADMIN_CONTROLS.md).
|
||||
</Aside>
|
||||
|
||||
## Every key
|
||||
|
||||
{Object.entries(bridgeCfg).map(([group, keys]) => (
|
||||
<div key={group}>
|
||||
<h3>{group}</h3>
|
||||
<table>
|
||||
<thead><tr><th>Key</th><th>What it is for</th></tr></thead>
|
||||
<tbody>
|
||||
{Object.entries(keys).map(([name, why]) => (
|
||||
<tr key={name}><td><code>{name}</code></td><td>{why}</td></tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
))}
|
||||
|
||||
This list is checked against the shipped `Bridge.cfg` on every build, so a key added by a
|
||||
protocol change turns this page red rather than going undocumented.
|
||||
|
||||
## Two that deserve their own note
|
||||
|
||||
**`QueueCap`** bounds the drop-oldest queue between the game and the writer thread. When it
|
||||
fills, the **oldest events are discarded** — which is the correct behaviour, because the
|
||||
alternative is a game server that stutters when a sidecar is slow. Raising it buys tolerance
|
||||
for longer sidecar outages at the cost of memory; it never buys correctness.
|
||||
|
||||
**`GuildRosterMembersPerLine`** exists because a roster is the only fat frame this bridge
|
||||
emits — a real 155-member guild measured about 10.8 KB. Rosters are **split** across lines
|
||||
rather than sent oversized. See [The bridge](/docs/architecture/the-bridge/).
|
||||
|
||||
## Canonical documents
|
||||
|
||||
The shipped
|
||||
[`Bridge.cfg`](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/src/branch/main/overlay/Config/Bridge.cfg)
|
||||
is the authority;
|
||||
[`link/PLAN.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md)
|
||||
§10 documents the config keys and
|
||||
[`SHARD_PREREQS.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/SHARD_PREREQS.md)
|
||||
covers what a shard needs before any of this works.
|
||||
70
src/content/docs/docs/reference/canonical-documents.mdx
Normal file
@@ -0,0 +1,70 @@
|
||||
---
|
||||
title: Canonical documents
|
||||
description: Where the normative specifications live — the documents that win whenever this site disagrees with them.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
import { canonicalDocs } from '../../../../data/reference.mjs';
|
||||
|
||||
Everything on this site is a **summary**. These are the documents it summarises, and where
|
||||
the two disagree, **they are right and this site has a bug**.
|
||||
|
||||
<Aside type="note" title="Why say that so bluntly">
|
||||
A documentation site that quietly re-specifies a contract becomes a second source of truth,
|
||||
and second sources of truth drift. Every page here links out for exactly this reason, and
|
||||
this page is the index of what it links to.
|
||||
|
||||
If you find a disagreement, it is worth reporting — it means a check is missing.
|
||||
</Aside>
|
||||
|
||||
## The documents
|
||||
|
||||
<table>
|
||||
<thead><tr><th>Document</th><th>Answers</th></tr></thead>
|
||||
<tbody>
|
||||
{Object.entries(canonicalDocs).map(([docPath, why]) => (
|
||||
<tr key={docPath}>
|
||||
<td>
|
||||
<a href={`https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/${docPath}`}>
|
||||
<code>{docPath}</code>
|
||||
</a>
|
||||
</td>
|
||||
<td>{why}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
Every path above is checked to still exist on every build, so a document that is renamed or
|
||||
moved turns this page red rather than leaving a dead link.
|
||||
|
||||
## Which document answers which question
|
||||
|
||||
- **"May a module do this?"** → `MODULE_API.md`. It is the contract, and it is the only thing
|
||||
that can answer yes.
|
||||
- **"Why is the module system like this?"** → `MODULE_SYSTEM.md`.
|
||||
- **"What does this API return?"** → your own deployment's `/api/docs`, then
|
||||
`BACKEND_DESIGN.md` §4.
|
||||
- **"What can the shard send?"** → `link/PLAN.md` §5, and `v4.md` for the current protocol.
|
||||
- **"Who may see this?"** → `SHARD_VISIBILITY.md` for the administrator's view,
|
||||
`modules/uo/API.md` §4 for the specification.
|
||||
- **"How do I set a shard up?"** → `installer/INSTALL.md`.
|
||||
|
||||
## Where they live
|
||||
|
||||
All of them are in
|
||||
[`RunicGateway/docs`](https://gitea.whitlocktech.com/RunicGateway/docs), which is Markdown
|
||||
only and versioned independently of the code it describes.
|
||||
|
||||
**A code change is not complete until `docs` reflects it.** That is a rule in the
|
||||
project's own contributor guidance, not an aspiration — a change to behaviour, protocol,
|
||||
endpoints, schema, configuration or the deployment model requires a matching edit there.
|
||||
|
||||
## Two things that are not in `docs`
|
||||
|
||||
**The Integration Kit** is its own repository, because its audience is outside this project
|
||||
and it teaches rather than specifies. See [The Integration
|
||||
Kit](/docs/modules/the-integration-kit/).
|
||||
|
||||
**The OpenAPI specification** is generated and committed in `website` itself, because it is
|
||||
derived from the routes rather than written alongside them.
|
||||
66
src/content/docs/docs/reference/environment-variables.mdx
Normal file
@@ -0,0 +1,66 @@
|
||||
---
|
||||
title: Environment variables
|
||||
description: Every variable the site reads, what each is for, and the four it refuses to start without.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
import { envVars } from '../../../../data/reference.mjs';
|
||||
|
||||
Every variable in `website`'s root `.env.example` — **the file a Compose deployment actually
|
||||
reads**, which is not the same file local development copies.
|
||||
|
||||
This list is checked against that file on every build, in both directions: a variable that
|
||||
disappears upstream fails, and a variable added upstream that is missing here fails too.
|
||||
|
||||
<Aside type="caution" title="Four are refused at boot in production">
|
||||
`SECRET_ENC_KEY` and `BOT_INTERNAL_KEY` are required in production and the server **will not
|
||||
start** without them — `BOT_INTERNAL_KEY` even on a deployment running no Discord bot.
|
||||
`JWT_SECRET` and the `DB_*` group are required everywhere.
|
||||
|
||||
The first boot is also when your admin account is written, so `ADMIN_USERNAME` and
|
||||
`ADMIN_PASSWORD` are set-once-before-first-boot values, not fill-in-later ones.
|
||||
</Aside>
|
||||
|
||||
## Every variable
|
||||
|
||||
<table>
|
||||
<thead><tr><th>Variable</th><th>What it is for</th></tr></thead>
|
||||
<tbody>
|
||||
{Object.entries(envVars).map(([name, why]) => (
|
||||
<tr key={name}><td><code>{name}</code></td><td>{why}</td></tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
## The three worth reading twice
|
||||
|
||||
**`SECRET_ENC_KEY`** encrypts secrets at rest — OAuth client secrets, the Discord bot token,
|
||||
the shard's auth token. Changing it does **not** re-encrypt anything: what was stored under
|
||||
the old key can no longer be read, and every stored secret has to be entered again.
|
||||
|
||||
**`COOKIE_SECURE=auto`** decides `Secure` per request, which is what lets one deployment
|
||||
work both over HTTPS through a proxy and over plain HTTP on a LAN address. Forcing it either
|
||||
way breaks one of those.
|
||||
|
||||
**`TRUST_PROXY`** is required behind a reverse proxy for secure cookies, real client IPs and
|
||||
rate limiting to work at all. Without it, every request appears to come from the proxy — so
|
||||
rate limiting and IP bans apply to your whole user base at once.
|
||||
|
||||
## Where to set them
|
||||
|
||||
A first install is [Install the site](/docs/getting-started/install-the-site/), which prints
|
||||
a complete `.env` alongside its Compose file. Afterwards,
|
||||
[Configuration](/docs/administration/configuration/) covers what is env-configured and what
|
||||
is not.
|
||||
|
||||
**Most settings are not here.** Branding, navigation, theming and the shard connection are
|
||||
**admin-managed and live in the database**, deliberately — so changing them does not mean
|
||||
redeploying a container.
|
||||
|
||||
## Canonical source
|
||||
|
||||
`website`'s
|
||||
[`.env.example`](https://gitea.whitlocktech.com/RunicGateway/website/src/branch/main/.env.example)
|
||||
is the authority, and
|
||||
[`BACKEND_DESIGN.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/BACKEND_DESIGN.md)
|
||||
§8 covers deployment.
|
||||
104
src/content/docs/docs/reference/event-catalog.mdx
Normal file
@@ -0,0 +1,104 @@
|
||||
---
|
||||
title: Event catalog
|
||||
description: What a game server can tell the website, how those events are grouped, and the five-rung ladder that decides who may see each one.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
import { visibilityLadder } from '../../../../data/reference.mjs';
|
||||
|
||||
The events a shard emits, and the mechanism that decides who may see them.
|
||||
|
||||
The exact wire shapes are in the protocol specification and are **not** restated here — a
|
||||
copy of a wire format is a copy that will be wrong after the next bump. This page is the map
|
||||
and the security model.
|
||||
|
||||
## What the shard can say
|
||||
|
||||
Nine groups, from the data catalog:
|
||||
|
||||
| Group | Covers |
|
||||
|---|---|
|
||||
| Session & identity | Logins, logouts, account linking |
|
||||
| Character state | Vitals, stats, skills, position |
|
||||
| Economy & commerce | Gold movement, vendor sales, supply totals |
|
||||
| Housing / IDOC | Decay stages, ownership, coordinates |
|
||||
| Combat, death, PvP | Kills, deaths, notable fights |
|
||||
| Progression & activity | Skill gains, points, leaderboards |
|
||||
| Cheat detection & staff audit | Fastwalk and friends; staff property edits |
|
||||
| Lifecycle | `server.hello`, shutdown, crash |
|
||||
| Known gaps | Things ServUO offers no clean hook for |
|
||||
|
||||
A representative line:
|
||||
|
||||
```json
|
||||
{"t":1752,"kind":"cheat.fastwalk","serial":"0x1A2B","acct":"PerryAdimn"}
|
||||
```
|
||||
|
||||
Note that one. **Cheat and audit events exist, and they are exactly what must never reach a
|
||||
public page.**
|
||||
|
||||
## How events are handled
|
||||
|
||||
Not all alike, and the difference is deliberate:
|
||||
|
||||
- **State-changing kinds** update tables. The current state is what a page renders.
|
||||
- **Notable kinds** additionally append to an events log, because a history is worth
|
||||
keeping.
|
||||
- **High-frequency kinds** only update state. Accumulating history for something that fires
|
||||
constantly buys nothing and costs a table that grows forever.
|
||||
|
||||
## The visibility ladder
|
||||
|
||||
Five rungs, in order, least privileged first:
|
||||
|
||||
<ol>
|
||||
{visibilityLadder.map((rung) => (<li key={rung}><code>{rung}</code></li>))}
|
||||
</ol>
|
||||
|
||||
Every feature declares the rung it is visible from, and individual **fields** can require a
|
||||
higher rung than the feature that carries them — a character's presence may be public while
|
||||
its *location* is staff-only.
|
||||
|
||||
This list and its **order** are checked against the module that enforces it on every build.
|
||||
Order matters as much as membership: reasoning about "staff and above" depends on the rungs
|
||||
being in the right sequence.
|
||||
|
||||
<Aside type="caution" title="This is a security boundary, not a filter">
|
||||
It is applied in **three** places — at routes, at SSE subscribe time, and at the navigation.
|
||||
All three, because a surface filtered in only two of them leaks through the third.
|
||||
|
||||
Defaults **fail closed**: an unresolvable viewer is anonymous, not privileged, and a feature
|
||||
with no configuration is not public by accident.
|
||||
</Aside>
|
||||
|
||||
## Two SSE channels
|
||||
|
||||
Ingested events fan out to browsers over two streams:
|
||||
|
||||
- a **public** stream, carrying only allowlisted kinds;
|
||||
- an **admin** stream, which additionally carries staff audit, cheat detection and login
|
||||
attempts with IP addresses.
|
||||
|
||||
**The catalog is the module's; the boundary is core's.** A module declares which of its kinds
|
||||
are public-safe, and core enforces the split — a sensitive kind cannot reach the public
|
||||
channel.
|
||||
|
||||
A viewer's rung is resolved **once, when the stream opens, and frozen for its life**. A
|
||||
long-lived connection must not silently gain privilege because the session changed
|
||||
underneath it. Configuration changes, by contrast, *do* take effect live.
|
||||
|
||||
## Administering it
|
||||
|
||||
[The shard connection](/docs/administration/the-shard-connection/) covers the admin screens,
|
||||
and the visibility ladder is administrator-configurable per feature and per field.
|
||||
|
||||
## Canonical documents
|
||||
|
||||
[`link/PLAN.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md)
|
||||
§5 is the data catalog and §7 the wire protocol;
|
||||
[`link/v4.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v4.md)
|
||||
is the current protocol;
|
||||
[`SHARD_VISIBILITY.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/SHARD_VISIBILITY.md)
|
||||
is the administrator's guide to the ladder, and
|
||||
[`modules/uo/API.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/modules/uo/API.md)
|
||||
§4 specifies it.
|
||||
85
src/content/docs/docs/reference/http-api.mdx
Normal file
@@ -0,0 +1,85 @@
|
||||
---
|
||||
title: HTTP API
|
||||
description: How the site's API is organised, where the live specification is, and the gate each tier sits behind.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
|
||||
The site's backend API is **OpenAPI 3.0**, and the specification is generated from the routes
|
||||
themselves rather than maintained beside them.
|
||||
|
||||
<Aside type="note" title="Your own deployment serves the authoritative copy">
|
||||
Every route, parameter and response shape is at **`/api/docs`** on your site, generated from
|
||||
the code that is actually running — including any module you have installed.
|
||||
|
||||
That is the copy to trust. This page is a map of how it is organised; it does not restate
|
||||
the routes, and a reference section that tried to would be wrong within a week.
|
||||
</Aside>
|
||||
|
||||
## The tiers
|
||||
|
||||
Every route lives under `/api/v1/<tier>/`, and **the tier decides the gate**.
|
||||
|
||||
| Tier | Routes | Sits behind |
|
||||
|---|---|---|
|
||||
| `admin` | ~93 | `noindex`, `isLoggedIn`, `requireRole('admin','editor','moderator')` |
|
||||
| `auth` | ~38 | Public by necessity; heavily rate-limited and bot-scored |
|
||||
| `player` | ~24 | `noindex`, `requireAuth` — role-agnostic self-service |
|
||||
| `public` | ~19 | Nothing, by design |
|
||||
| `settings` | 2 | `requireAuth` + `noindex`, no role gate |
|
||||
|
||||
Plus two outside the versioned surface: **`/api/health`** and **`/api/csp-report`**.
|
||||
|
||||
Those two are deliberately not under `/api/v1`. A browser learns the CSP report path from the
|
||||
policy header rather than from a client build, so it is not part of the versioned client
|
||||
contract.
|
||||
|
||||
## Two things the tier table implies
|
||||
|
||||
**`player` is role-agnostic.** It is self-service for whoever is signed in, gated on
|
||||
`requireAuth` alone and never on "is not staff". Staff are a *superset* of players — an
|
||||
administrator has characters too, and a `player` route that excluded them would 403 an admin
|
||||
off their own account.
|
||||
|
||||
**A module's routes inherit their tier's gate** and add their own on top. A module never
|
||||
re-implements the tier gate; see [The module
|
||||
API](/docs/modules/the-module-api/#registerroutes-and-the-tier-gate).
|
||||
|
||||
## Authentication
|
||||
|
||||
Three ways in, [one session model](/docs/architecture/authentication-architecture/):
|
||||
|
||||
- **Cookie** — `httpOnly` JWT, for the browser.
|
||||
- **Bearer** — short access tokens plus rotated, hashed, revocable refresh tokens, for the
|
||||
native app.
|
||||
- **SSO** — OAuth2/OIDC with PKCE, and **link-only**: an external identity must already be
|
||||
attached to an existing account.
|
||||
|
||||
Admin roles are **re-validated against the database on every request**, so a demoted user
|
||||
loses access immediately rather than at token expiry.
|
||||
|
||||
## The sidecar's API is a different thing
|
||||
|
||||
The uo-link sidecar exposes its own small REST and WebSocket surface, reached **only** by the
|
||||
website's backend. It carries `X-UOLink-Version` and answers `409` on a protocol mismatch.
|
||||
|
||||
It is not part of this API and is not served from your site. See [The
|
||||
bridge](/docs/architecture/the-bridge/).
|
||||
|
||||
## Keeping the spec current
|
||||
|
||||
For contributors: the specification is generated from `#swagger.*` annotations next to each
|
||||
route, and the output is committed.
|
||||
|
||||
```bash
|
||||
cd website/server && npm run swagger
|
||||
```
|
||||
|
||||
A route that is not in the specification is not finished. Modules publish their own
|
||||
fragment, at a fixed filename in the bundle root, so a module's routes appear in the same
|
||||
documentation as core's.
|
||||
|
||||
## Canonical document
|
||||
|
||||
[`BACKEND_DESIGN.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/BACKEND_DESIGN.md)
|
||||
§4 is the API contract, including §4.0's authoritative route list.
|
||||
85
src/content/docs/docs/reference/installer-cli.mdx
Normal file
@@ -0,0 +1,85 @@
|
||||
---
|
||||
title: Installer CLI
|
||||
description: The four commands the installer offers, what each does to a host, and the environment variable that makes a full run safe to rehearse.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
import { installerCommands } from '../../../../data/reference.mjs';
|
||||
|
||||
The installer is one binary per operating system that deploys the **shard side only**. It
|
||||
never contacts the website.
|
||||
|
||||
Downloads and the walkthrough are [Connect a game
|
||||
server](/docs/getting-started/connect-a-game-server/). This page is the command surface.
|
||||
|
||||
## The commands
|
||||
|
||||
<table>
|
||||
<thead><tr><th>Command</th><th>What it does</th></tr></thead>
|
||||
<tbody>
|
||||
{Object.entries(installerCommands).map(([name, why]) => (
|
||||
<tr key={name}><td><code>{name.toLowerCase()}</code></td><td>{why}</td></tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
`doctor`, `update` and `uninstall` are the day-two commands.
|
||||
|
||||
## Rehearsing a run
|
||||
|
||||
Two mechanisms, and they answer different questions.
|
||||
|
||||
```bash
|
||||
runicgateway-installer install --servuo /path/to/ServUO --verify
|
||||
```
|
||||
|
||||
**`--verify` writes nothing.** It reports what would change — the diff against the ServUO
|
||||
tree — which is the right thing to run first against a shard that has players on it.
|
||||
|
||||
```bash
|
||||
RUNICGATEWAY_STATE_DIR=/tmp/rehearsal runicgateway-installer install --servuo …
|
||||
```
|
||||
|
||||
**`RUNICGATEWAY_STATE_DIR` relocates everything the installer writes** — state, data, and
|
||||
the sidecar binary — *and suppresses service registration*. That is how a full run is
|
||||
exercised without root, and it is what the project's own tests use.
|
||||
|
||||
## What an install actually does
|
||||
|
||||
1. Resolves a **bundle** — an exact, protocol-checked sidecar and overlay pair published by
|
||||
CI. Never "latest of each"; see [Protocol
|
||||
versions](/docs/architecture/protocol-versions/).
|
||||
2. Syncs the plugin overlay into the ServUO tree, backing up whatever it is about to
|
||||
overwrite.
|
||||
3. Offers the opt-in patch tier.
|
||||
4. Installs the sidecar and registers its service.
|
||||
5. Prints four values to paste into the site's shard screen.
|
||||
|
||||
<Aside type="caution" title="Installer v0.1.0 prints an older path in step 5">
|
||||
It names `<site>/admin/shard`. The screen moved to **`/admin/uo/link`** when the shard
|
||||
surface became part of the `uo` module. Fixed in v0.1.1.
|
||||
</Aside>
|
||||
|
||||
## Platforms
|
||||
|
||||
Linux `x86_64`, Linux `aarch64`, and Windows `x86_64`.
|
||||
|
||||
**macOS and Windows-on-ARM are deliberately absent**: the game server and the sidecar must
|
||||
share a host, and no ServUO host is either.
|
||||
|
||||
Releases are **unsigned**, and `SHA256SUMS` is the trust anchor — verify before running.
|
||||
Windows will show a SmartScreen prompt, which is expected for an unsigned binary.
|
||||
|
||||
<Aside type="note" title="Why the library target is called `rgdeploy`">
|
||||
Windows UAC refuses to launch an unsigned executable whose name contains `install`
|
||||
(`os error 740`), and Cargo names test harnesses after their target. It is deliberate, and
|
||||
not something to tidy up.
|
||||
</Aside>
|
||||
|
||||
## Canonical documents
|
||||
|
||||
[`INSTALL.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/installer/INSTALL.md)
|
||||
is the operator guide — including Appendix A, hand deployment, for hosts that cannot run the
|
||||
binary — and
|
||||
[`PLAN.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/installer/PLAN.md)
|
||||
is the design of record.
|
||||
68
src/content/docs/docs/reference/sidecar-toml.mdx
Normal file
@@ -0,0 +1,68 @@
|
||||
---
|
||||
title: sidecar.toml
|
||||
description: The sidecar's entire configuration — four keys — and why the file is generated rather than shipped.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
import { sidecarConfig } from '../../../../data/reference.mjs';
|
||||
|
||||
The uo-link sidecar's configuration. It is deliberately tiny: the sidecar is a **dumb
|
||||
forwarder**, and policy lives on the website where an administrator can see it.
|
||||
|
||||
## The file is written, not shipped
|
||||
|
||||
The sidecar **writes `sidecar.toml` on first run**, including a generated auth token. There
|
||||
is no committed sample that is authoritative, and nothing is compiled into the binary.
|
||||
|
||||
Point it elsewhere with `$UOLINK_CONFIG`.
|
||||
|
||||
<Aside type="caution" title="Authentication is always on">
|
||||
A blank token is not "no authentication" — it is auto-generated and written back, so the web
|
||||
surface is authenticated from first boot. There is no way to turn it off, which is the
|
||||
correct default for the one component that is exposed.
|
||||
</Aside>
|
||||
|
||||
## The keys
|
||||
|
||||
<table>
|
||||
<thead><tr><th>Key</th><th>What it is for</th></tr></thead>
|
||||
<tbody>
|
||||
{Object.entries(sidecarConfig).map(([name, why]) => (
|
||||
<tr key={name}><td><code>{name}</code></td><td>{why}</td></tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
This list is checked against the sidecar's own config structs on every build, so a key added
|
||||
upstream turns this page red rather than quietly going undocumented.
|
||||
|
||||
## What is *not* in here
|
||||
|
||||
Worth stating, because the absences are the design:
|
||||
|
||||
- **No allowlist, no audience rules, no visibility settings.** Those are the website's, and
|
||||
admin-toggleable. The sidecar forwards; the site decides who may see what.
|
||||
- **No website URL.** The website reaches the sidecar, not the other way round.
|
||||
- **No protocol version.** It is compiled in, because a sidecar that could be *configured*
|
||||
to claim a different protocol would defeat the check. See [Protocol
|
||||
versions](/docs/architecture/protocol-versions/).
|
||||
|
||||
## Running it
|
||||
|
||||
```bash
|
||||
cargo run # writes sidecar.toml on first run
|
||||
RUST_LOG=debug cargo run # verbose, including heartbeats
|
||||
```
|
||||
|
||||
Normally you do not run it by hand — [the installer](/docs/reference/installer-cli/)
|
||||
installs it and registers its service.
|
||||
|
||||
## Canonical documents
|
||||
|
||||
The structs in
|
||||
[`sidecar/src/config.rs`](https://gitea.whitlocktech.com/RunicGateway/link/src/branch/main/sidecar/src/config.rs)
|
||||
are the authority;
|
||||
[`link/PLAN.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md)
|
||||
is the design of record and
|
||||
[`ADMIN_CONTROLS.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/ADMIN_CONTROLS.md)
|
||||
covers what the site may command the game to do.
|
||||
@@ -27,8 +27,8 @@
|
||||
|
||||
"releases": {
|
||||
"link": "v2.0.0",
|
||||
"installer": "v0.1.0",
|
||||
"Module-uo": "v1.0.1",
|
||||
"installer": "v0.1.1",
|
||||
"Module-uo": "v1.0.2",
|
||||
"Android-app": "v0.5.0"
|
||||
},
|
||||
|
||||
|
||||
@@ -110,22 +110,21 @@ export const env = [
|
||||
];
|
||||
|
||||
/**
|
||||
* `SECRET_ENC_KEY` is in this quickstart and NOT in upstream's `.env.example`, which is why
|
||||
* it needs a declaration rather than passing quietly.
|
||||
* Keys this quickstart sets that upstream's `.env.example` does not, each with the reason.
|
||||
*
|
||||
* Found by booting this exact file against the published image (phase 7): the server calls
|
||||
* `resolveKey()` in `utils/secretBox.js` at require time and throws
|
||||
* `SECRET_ENC_KEY must be set in production`, so the container crash-loops before it ever
|
||||
* listens. It is documented in `server/.env.example` — the file local development copies —
|
||||
* and missing from the root `.env.example` that Compose actually reads.
|
||||
* **Empty, and that is the point.** Its one entry was `SECRET_ENC_KEY`: phase 7 booted this
|
||||
* exact file against the published image and the container crash-looped before it ever
|
||||
* listened, because `resolveKey()` in `utils/secretBox.js` throws
|
||||
* `SECRET_ENC_KEY must be set in production` at require time. The variable was documented in
|
||||
* `server/.env.example` — the file local development copies — and missing from the root
|
||||
* `.env.example` that Compose actually reads.
|
||||
*
|
||||
* The check treats the omission as upstream's bug, not as licence: it fails the moment the
|
||||
* variable appears in `.env.example`, so this note cannot outlive the defect it describes.
|
||||
* The declaration was written so it could not outlive the defect: the check fails the moment
|
||||
* a declared key appears upstream. website#163 fixed `.env.example`, this repo went red on
|
||||
* the next run, and the entry was deleted. Keep the export — the next divergence gets an
|
||||
* entry here rather than passing quietly.
|
||||
*/
|
||||
export const notInUpstreamEnvExample = {
|
||||
SECRET_ENC_KEY:
|
||||
"the app refuses to start in production without it (utils/secretBox.js), but website's root .env.example does not list it",
|
||||
};
|
||||
export const notInUpstreamEnvExample = {};
|
||||
|
||||
/**
|
||||
* Variables upstream's `.env.example` carries that the quickstart leaves out, each with the
|
||||
|
||||
190
src/data/reference.mjs
Normal file
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* The Reference section's enumerations.
|
||||
*
|
||||
* §1 says a Reference page is "a navigable summary plus a link to the canonical document —
|
||||
* never a re-specification". This file is the line between those two things, and it is
|
||||
* worth being explicit about where it falls:
|
||||
*
|
||||
* * The NAMES are here — every environment variable, every config key, every command,
|
||||
* every event kind. A reference section that cannot answer "what variables are there?"
|
||||
* without a click-through is a link farm.
|
||||
* * The SEMANTICS are not. One terse line each, saying what a thing is FOR. Shapes,
|
||||
* defaults that matter, interactions, and every "why" stay in the canonical document.
|
||||
*
|
||||
* Everything below is checked against its source by `scripts/checkReference.mjs`, in both
|
||||
* directions — a name that disappears upstream fails, and a name that appears upstream and
|
||||
* is missing here fails too. That is the whole reason it is safe to write names down at
|
||||
* all: the enumeration cannot rot into fiction without turning the build red.
|
||||
*
|
||||
* Descriptions are NOT checked, and cannot be. They are the part a human has to keep
|
||||
* honest, which is why they are kept short enough to re-read.
|
||||
*/
|
||||
|
||||
/** `website` root `.env.example` — the file a Compose deployment actually reads. */
|
||||
export const envVars = {
|
||||
IMAGE_TAG: 'Which published image tag to run',
|
||||
NODE_ENV: 'production or development — several refusals are production-only',
|
||||
PORT: 'The port the app listens on',
|
||||
INTERNAL_PORT: 'The internal-only listener, for the bot channel',
|
||||
UPLOAD_DIR: 'Where uploads are written',
|
||||
|
||||
LOG_LEVEL: 'Console log level',
|
||||
FILE_LOG_LEVEL: 'File log level, set separately',
|
||||
LOG_TO_FILE: 'Whether to write a log file at all',
|
||||
LOG_DIR: 'Directory for the log file',
|
||||
LOG_FILE: 'Log file name',
|
||||
|
||||
BRAND_NAME: 'Site name — branding is data, not a build',
|
||||
BRAND_SHORT_NAME: 'Short form, for tight spaces',
|
||||
BRAND_TAGLINE: 'One line under the name',
|
||||
BRAND_DESCRIPTION: 'Meta description',
|
||||
BRAND_CONTACT_EMAIL: 'Published contact address',
|
||||
BRAND_URL: 'Canonical public URL',
|
||||
BRAND_ACCENT_COLOR: 'Accent colour',
|
||||
BRAND_LOGO: 'Logo path',
|
||||
BRAND_HERO: 'Hero image path',
|
||||
BRAND_FAVICON: 'Favicon path',
|
||||
|
||||
DB_HOST: 'Database host',
|
||||
DB_PORT: 'Database port',
|
||||
DB_NAME: 'Database name',
|
||||
DB_USER: 'Database user',
|
||||
DB_PASSWORD: 'Database password',
|
||||
DB_ROOT_PASSWORD: "The database container's root password",
|
||||
|
||||
JWT_SECRET: 'Signs session tokens. Rotating it logs everyone out',
|
||||
SECRET_ENC_KEY:
|
||||
'Encrypts secrets at rest. Required in production, and rotating it ORPHANS every stored secret',
|
||||
JWT_EXPIRES_IN: 'Session lifetime',
|
||||
COOKIE_SECURE: 'auto decides Secure per request, so HTTPS and LAN HTTP both work',
|
||||
COOKIE_NAME: 'Session cookie name. Changing it invalidates existing sessions',
|
||||
TRUST_PROXY: 'Needed behind a reverse proxy for secure cookies, real IPs and rate limiting',
|
||||
DEBUG_TRUST_PROXY: 'Diagnostic for the above',
|
||||
TOTP_CHALLENGE_TTL: 'How long a pending 2FA challenge is valid',
|
||||
|
||||
ADMIN_USERNAME: 'First admin, created only when no users exist',
|
||||
ADMIN_PASSWORD: 'First admin password. Set it before the first boot, not after',
|
||||
|
||||
CLIENT_ORIGIN: 'Dev only — the Vite origin allowed through CORS',
|
||||
|
||||
BOT_INTERNAL_URL: 'Where the Discord bot listens',
|
||||
BOT_INTERNAL_KEY:
|
||||
'Authenticates the site↔bot channel. Required in production EVEN IF you run no bot',
|
||||
|
||||
NTFY_BASE_URL: 'Push notification relay base URL',
|
||||
};
|
||||
|
||||
/** `link/sidecar/src/config.rs` → the TOML the sidecar writes on first run. */
|
||||
export const sidecarConfig = {
|
||||
'shard.bind': 'Loopback address the game plugin dials out to',
|
||||
'web.bind': 'Address the website reaches the sidecar on',
|
||||
'web.auth_token': 'Shared secret the website must present. Generated on first run if blank',
|
||||
'store.path': "The sidecar's own durable store",
|
||||
};
|
||||
|
||||
/** `installer` — `src/cli.rs`'s `Command`. */
|
||||
export const installerCommands = {
|
||||
Install: 'Set up the shard side: sync the overlay, install the sidecar, register its service',
|
||||
Doctor: 'Diagnose an existing install',
|
||||
Update: 'Move to a newer bundle',
|
||||
Uninstall: 'Remove what install put there',
|
||||
};
|
||||
|
||||
/** `servuo-plugins/overlay/Config/Bridge.cfg` — the plugin's config, grouped for reading. */
|
||||
export const bridgeCfg = {
|
||||
Connection: {
|
||||
Host: 'Sidecar address the shard dials out to',
|
||||
Port: 'Sidecar port',
|
||||
QueueCap: 'Bounded queue depth. Full means drop-oldest — never block the game',
|
||||
PublicConnectAddress: 'Address players connect to, published to the site',
|
||||
LinkUrl: 'Where in-game account linking sends a player',
|
||||
},
|
||||
Sweeps: {
|
||||
StatSweepSeconds: 'Character stat sweep interval',
|
||||
DecaySweepSeconds: 'House decay sweep',
|
||||
EconomySweepSeconds: 'Economy totals sweep',
|
||||
ChampSweepSeconds: 'Champion spawn sweep',
|
||||
PageSweepSeconds: 'Staff page sweep',
|
||||
GuildSweepSeconds: 'Guild roster sweep',
|
||||
CitySweepSeconds: 'City / governor sweep',
|
||||
PresenceSweepSeconds: 'Who is online',
|
||||
HousingSweepSeconds: 'Housing sweep',
|
||||
},
|
||||
Guilds: {
|
||||
GuildRosterMembersPerLine: 'Frame cap — a roster is split rather than sent oversized',
|
||||
GuildRosterGuildsPerTick: 'How many guilds are swept per tick',
|
||||
},
|
||||
Points: {
|
||||
PointsSweepSeconds: 'Points sweep interval',
|
||||
PointsLeaderboardEnabled: 'Publish a leaderboard at all',
|
||||
PointsTopN: 'Leaderboard length',
|
||||
PointsSystems: 'Which point systems to include',
|
||||
PointsProfileEnabled: 'Show points on a character profile',
|
||||
PointsProfileRank: 'Show rank as well as total',
|
||||
},
|
||||
Market: {
|
||||
MarketEnabled: 'Publish player vendor listings',
|
||||
MarketSweepSeconds: 'Market sweep interval',
|
||||
MarketSweepBatch: 'Vendors per sweep',
|
||||
MarketMaxListings: 'Cap on listings published',
|
||||
},
|
||||
Ruleset: {
|
||||
RulesetEnabled: 'Publish the shard ruleset',
|
||||
RulesetIncludeSchedule: 'Include the event schedule with it',
|
||||
},
|
||||
'Town crier': {
|
||||
TownCrierMaxLines: 'Lines per notice',
|
||||
TownCrierMaxLineLength: 'Characters per line',
|
||||
TownCrierMaxActive: 'Concurrent notices',
|
||||
TownCrierMaxDurationSec: 'Longest a notice may run',
|
||||
},
|
||||
News: {
|
||||
NewsMaxTitleLength: 'Title cap',
|
||||
NewsMaxBodyLength: 'Body cap',
|
||||
NewsMaxExternal: 'How many site posts are carried in-game',
|
||||
NewsAnnounceDurationSec: 'How long an announcement shows',
|
||||
},
|
||||
'Admin commands': {
|
||||
AdminWriteEnabled: 'Whether the site may write to the game at all. Off by default',
|
||||
AdminAccessFloor: 'Minimum in-game access level for admin actions',
|
||||
AdminBroadcastMaxLength: 'Broadcast cap',
|
||||
AdminReasonMaxLength: 'Reason field cap',
|
||||
AdminBanMaxDurationSec: 'Longest ban the site may set',
|
||||
},
|
||||
Accounts: {
|
||||
SignupMode: 'How game accounts may be created',
|
||||
AccountCreateEnabled: 'Allow creation at all',
|
||||
RequireIpForCreate: 'Require a real client IP',
|
||||
AccountNameMaxLength: 'Account name cap',
|
||||
AccountPasswordMaxLength: 'Account password cap',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The five-rung visibility ladder, from `module-uo`'s `server/utils/shardVisibility.js`.
|
||||
*
|
||||
* This one is a SECURITY boundary, not a convenience filter, which is why it is enumerated
|
||||
* rather than described: a reader needs to see the whole ladder at once to reason about it.
|
||||
*/
|
||||
export const visibilityLadder = ['anonymous', 'logged_in', 'player', 'staff', 'admin'];
|
||||
|
||||
/** Canonical documents, by the question each answers. Checked to still exist in `docs`. */
|
||||
export const canonicalDocs = {
|
||||
'website/ARCHITECTURE.md': 'How the website fits together — the canonical diagram',
|
||||
'website/BACKEND_DESIGN.md': 'The API, schema and security contract',
|
||||
'website/MODULE_SYSTEM.md': 'Why the module system is shaped this way',
|
||||
'website/MODULE_API.md': 'Everything a module may do — the contract',
|
||||
'website/TEAMS.md': 'Teams as a platform primitive',
|
||||
'website/SHARD_VISIBILITY.md': 'The audience ladder, for administrators',
|
||||
'website/THEMING_AND_NAV.md': 'Admin-configurable theme, assets and navigation',
|
||||
'website/TRUSTED_DEVICES_MFA.md': 'Trusted devices and the second factor',
|
||||
'link/PLAN.md': 'The sidecar design of record, the data catalog and the wire protocol',
|
||||
'link/INTEGRATION.md': 'Integrating with the sidecar',
|
||||
'link/v4.md': 'Protocol 4, and its cross-repository obligations',
|
||||
'link/ADMIN_CONTROLS.md': 'What the site may command the game to do',
|
||||
'installer/INSTALL.md': 'The operator guide for setting a shard up',
|
||||
'installer/PLAN.md': "The installer's design of record",
|
||||
'modules/rust-dryrun.md': 'A second module designed on paper, to test that the contract generalises',
|
||||
'modules/uo/API.md': "module-uo's own API, including its audience rules",
|
||||
'android/PLAN.md': 'The Android app',
|
||||
};
|
||||
240
src/data/screens.mjs
Normal file
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* screens.mjs — every screenshot the site ships, and where it came from.
|
||||
*
|
||||
* PLAN.md §13 phase 9, D4 / D42–D45.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* ONE LIST, THREE READERS
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* `scripts/captureScreens.mjs` reads this to know what to shoot and where to click before
|
||||
* it shoots; `src/components/Screenshot.astro` reads it to render one figure by id; and
|
||||
* `scripts/checkScreens.mjs` reads it to prove that every file exists at the declared size
|
||||
* and that nothing in `public/screens/` is orphaned. A screenshot is therefore a data
|
||||
* change: add an entry, re-run the capture, and the check tells you if you missed a step.
|
||||
*
|
||||
* That split is what makes a re-capture cheap. §1 says the site must not describe a
|
||||
* product that no longer looks like that, and the way a screenshot goes stale is that
|
||||
* nobody remembers how it was taken. The route, the viewport, the scroll offset and the
|
||||
* signed-in state are all here, so the answer to "how do I retake this" is one command.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHY EVERY WEB SHOT IS THE SAME SIZE
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* A 1280×800 viewport at 1.5× device pixels — 1920×1200 in the file. Uniform because the
|
||||
* marketing pages lay them out in a grid and a grid of mixed aspect ratios reads as an
|
||||
* accident, and because a check that asserts one pair of numbers cannot drift the way a
|
||||
* per-file table can. Where a page's interesting part is below the fold, `scrollY` moves
|
||||
* the frame rather than the size changing.
|
||||
*
|
||||
* The phone shots are the device's own portrait size and are declared per family for the
|
||||
* same reason (see `PHONE`).
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHAT IS IN THEM
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* A demo deployment of this platform, wired to a real ServUO shard over a real sidecar
|
||||
* (D42): the marketplace rows are player vendors the game actually holds, the atlas is
|
||||
* parsed from the shard's own spawn files, the guild rosters came over the bridge. The
|
||||
* deployment is branded "Runic Gateway Demo" rather than a real community's name (D43) —
|
||||
* the screenshots show the platform, not somebody's private shard.
|
||||
*
|
||||
* Nothing here is a mock-up, and nothing here was drawn.
|
||||
*/
|
||||
|
||||
/** Web capture geometry. The capture script and the check both read these. */
|
||||
export const WEB = { viewport: { width: 1280, height: 800 }, scale: 1.5, width: 1920, height: 1200 };
|
||||
|
||||
/**
|
||||
* Phone capture geometry — the emulator's own portrait pixels, unscaled.
|
||||
*
|
||||
* An API 35 device rather than the API 36 the plan named: the API 36 image on this machine
|
||||
* had 200 MB left on its data partition and refused the install, and wiping somebody's
|
||||
* development device to take a screenshot is not a trade worth making. The app targets both.
|
||||
*/
|
||||
export const PHONE = { width: 1440, height: 3088 };
|
||||
|
||||
/**
|
||||
* @typedef {object} Screen
|
||||
* @property {string} id File stem under `public/screens/`, and the handle a page uses.
|
||||
* @property {string} route Route on the demo deployment. The capture script's only input.
|
||||
* @property {boolean} admin Capture signed in as an administrator rather than anonymously.
|
||||
* @property {number} [scrollY] Pixels to scroll before the shot, when the subject is below the fold.
|
||||
* @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.
|
||||
*/
|
||||
|
||||
/** @type {Screen[]} */
|
||||
export const screens = [
|
||||
// ── The product's public surfaces ───────────────────────────────────────────────────
|
||||
{
|
||||
id: 'shard-status',
|
||||
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.',
|
||||
caption:
|
||||
'The shard console. Every number on it came over the bridge from a running game server — including the player who was standing in Britain when this was taken.',
|
||||
family: 'web',
|
||||
},
|
||||
{
|
||||
id: 'marketplace',
|
||||
route: '/uo/market',
|
||||
admin: false,
|
||||
scrollY: 470,
|
||||
alt: 'The marketplace page, listing items for sale by player vendors with their prices, shop names and locations, above a search box and price filters.',
|
||||
caption:
|
||||
'Player vendors, searchable from the website — the same index the in-game vendor search reads, honouring the same per-vendor opt-out.',
|
||||
family: 'web',
|
||||
},
|
||||
{
|
||||
id: 'spawn-atlas',
|
||||
route: '/uo/atlas',
|
||||
admin: false,
|
||||
scrollY: 430,
|
||||
alt: 'The spawn atlas, listing creatures with how many of them spawn and on which facets, above a search box and facet filters.',
|
||||
caption:
|
||||
"The spawn atlas is parsed from the shard's own spawn files, so it stays accurate whether or not the server is up.",
|
||||
family: 'web',
|
||||
},
|
||||
{
|
||||
id: 'guilds',
|
||||
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.',
|
||||
caption:
|
||||
'Guilds arrive from the shard, not from a form — and a Team on the website is one of them, with its own forum and roster.',
|
||||
family: 'web',
|
||||
},
|
||||
{
|
||||
id: 'houses',
|
||||
route: '/uo/houses',
|
||||
admin: false,
|
||||
alt: 'The houses page, listing homes that have entered their final decay stage with their owner and location.',
|
||||
caption:
|
||||
'Houses in danger, from the same decay data the game uses — a live process, not a nightly export.',
|
||||
family: 'web',
|
||||
},
|
||||
|
||||
{
|
||||
id: 'news',
|
||||
route: '/site/news',
|
||||
admin: false,
|
||||
alt: 'The news page, listing posts with their category, date, headline and summary.',
|
||||
caption:
|
||||
'News, five-on-friday and the newsletter are one posting system with three categories — and none of it knows what game you run.',
|
||||
family: 'web',
|
||||
},
|
||||
|
||||
// ── The administration screens the documentation describes ──────────────────────────
|
||||
{
|
||||
id: 'admin-dashboard',
|
||||
route: '/admin',
|
||||
admin: true,
|
||||
alt: 'The administration dashboard, showing the site mode, counts of posts, wiki pages and users, and a feed of recent administrative activity.',
|
||||
caption: 'The dashboard on first sign-in: site mode, what the site holds, and who has done what.',
|
||||
family: 'web',
|
||||
},
|
||||
{
|
||||
id: 'admin-users',
|
||||
route: '/admin/users',
|
||||
admin: true,
|
||||
alt: 'The users screen, listing accounts with their role and status and the controls to change them.',
|
||||
caption: 'Users and roles. A change of role takes effect on the next request, not the next login.',
|
||||
family: 'web',
|
||||
},
|
||||
{
|
||||
id: 'admin-shard',
|
||||
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.',
|
||||
caption:
|
||||
'The shard connection, showing a live sidecar. The token is write-only: it is never sent back to any client, including this screen.',
|
||||
family: 'web',
|
||||
},
|
||||
{
|
||||
id: 'admin-modules',
|
||||
route: '/admin/modules',
|
||||
admin: true,
|
||||
alt: 'The modules screen, showing the Ultima Online module running with the routes it mounts, and the list of hosts modules may be installed from.',
|
||||
caption:
|
||||
'A module is installed from a release URL and runs inside the server, so only listed hosts are allowed to serve one.',
|
||||
family: 'web',
|
||||
},
|
||||
{
|
||||
id: 'admin-appearance',
|
||||
route: '/admin/appearance',
|
||||
admin: true,
|
||||
alt: 'The appearance screen, showing the colour and typography controls a deployment uses to set its own theme.',
|
||||
caption: 'Branding is data. One image runs as any community, and the app takes its colours from here too.',
|
||||
family: 'web',
|
||||
},
|
||||
|
||||
// ── The Android app, against the same deployment on the same day (D26) ──────────────
|
||||
//
|
||||
// Captured from an emulator pointed at the demo stack through `adb reverse`, signed in as
|
||||
// an ordinary player. The app takes its name, colours and navigation from the site it is
|
||||
// connected to, so these are not a neutral app: they are one deployment's app.
|
||||
{
|
||||
id: 'app-home',
|
||||
route: '/',
|
||||
admin: false,
|
||||
alt: "The app's home screen, showing the deployment's emblem and name, a live online indicator and its description.",
|
||||
caption:
|
||||
"The app is one screen of setup: type the address of a site, and it becomes that community's app.",
|
||||
family: 'phone',
|
||||
},
|
||||
{
|
||||
id: 'app-shard',
|
||||
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.",
|
||||
caption:
|
||||
'The same bridge feeds the phone: the same player online, and an activity list that is live — those houses entered their final decay stage while it was open.',
|
||||
family: 'phone',
|
||||
},
|
||||
{
|
||||
id: 'app-market',
|
||||
route: '/market',
|
||||
admin: false,
|
||||
alt: "The app's marketplace, listing weapons for sale with their price, the shop selling them and where it stands.",
|
||||
caption: 'Every player vendor on the shard, searchable from a phone.',
|
||||
family: 'phone',
|
||||
},
|
||||
{
|
||||
id: 'app-wiki',
|
||||
route: '/wiki',
|
||||
admin: false,
|
||||
alt: "The app's wiki index, listing pages with their category and summary above a search box.",
|
||||
caption:
|
||||
"The wiki, the news and the rules are the site's own content, rendered natively rather than in a web view.",
|
||||
family: 'phone',
|
||||
},
|
||||
{
|
||||
id: 'app-account',
|
||||
route: '/account',
|
||||
admin: false,
|
||||
alt: "The app's account screen for a signed-in player, with username and password controls, two-factor authentication, trusted devices and recovery codes.",
|
||||
caption:
|
||||
'Self-service, and the same session model as the website: two-factor, trusted devices and recovery codes all live here.',
|
||||
family: 'phone',
|
||||
},
|
||||
{
|
||||
id: 'app-drawer',
|
||||
route: '/',
|
||||
admin: false,
|
||||
alt: "The app's navigation drawer for a signed-in player, listing the deployment's own pages above the player's own account, characters, vendors and houses.",
|
||||
caption:
|
||||
"The drawer is the deployment's own navigation, not a fixed menu — a site that renames or reorders its pages renames and reorders them here.",
|
||||
family: 'phone',
|
||||
},
|
||||
];
|
||||
|
||||
/** One entry by id, or `undefined`. */
|
||||
export function screenById(id) {
|
||||
return screens.find((shot) => shot.id === id);
|
||||
}
|
||||
|
||||
/** Every entry in a family, in declaration order. */
|
||||
export function screensOf(family) {
|
||||
return screens.filter((shot) => shot.family === family);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
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 platform from '../data/platform.json';
|
||||
import {
|
||||
@@ -55,6 +56,26 @@ import {
|
||||
assertCapabilityCoverage(platform.moduleUoCapabilities);
|
||||
assertDetailCoverage();
|
||||
|
||||
/**
|
||||
* Which screenshots sit under which group (PLAN.md §13 phase 9, D44).
|
||||
*
|
||||
* Deliberately here and not in `capabilities.mjs`. That file is a contract — two build-time
|
||||
* assertions read it and `/modules/` and the homepage render from it — and a group is
|
||||
* defined by what the software does, not by what somebody has got round to photographing.
|
||||
* A group with no figure is the normal case, not an omission: Infrastructure is about where
|
||||
* the container runs and who owns the database, and a picture of a web page says nothing
|
||||
* true about either.
|
||||
*
|
||||
* `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.';
|
||||
@@ -110,6 +131,14 @@ const description =
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{groupScreens[group.id] && (
|
||||
<div class="group__shots">
|
||||
{groupScreens[group.id].map((id: string) => (
|
||||
<Screenshot id={id} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
))
|
||||
}
|
||||
@@ -118,6 +147,16 @@ const description =
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
/* One up, at the column's full width, even where a group has four of them.
|
||||
Two-up was tried first and is the obvious layout for a set of figures — but these are
|
||||
screenshots of a dense interface, and halving the width puts the product's own type at
|
||||
around a third of its real size, which reads as a thumbnail of something rather than a
|
||||
picture of it. A long section of legible evidence beats a tidy grid of unreadable
|
||||
tiles. */
|
||||
.group__shots {
|
||||
margin-top: 1.75rem;
|
||||
}
|
||||
|
||||
.group__head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { brand } from '../lib/brand.mjs';
|
||||
|
||||
import Hero from '../components/home/Hero.astro';
|
||||
import DataPath from '../components/home/DataPath.astro';
|
||||
import WhatItLooksLike from '../components/home/WhatItLooksLike.astro';
|
||||
import SelfHosted from '../components/home/SelfHosted.astro';
|
||||
import Capabilities from '../components/home/Capabilities.astro';
|
||||
import GetStarted from '../components/home/GetStarted.astro';
|
||||
@@ -28,6 +29,7 @@ import GetStarted from '../components/home/GetStarted.astro';
|
||||
<Base title={`${brand.siteName} — ${brand.tagline}`} description={brand.tagline} bareTitle>
|
||||
<Hero />
|
||||
<DataPath />
|
||||
<WhatItLooksLike />
|
||||
<SelfHosted />
|
||||
<Capabilities />
|
||||
<GetStarted />
|
||||
|
||||