8 Commits

Author SHA1 Message Date
a6fc0431d7 feat(screens): retake the two frames a signed-in character changes
The org lead signed a character into the shard by hand — the step automation
could not reach — so shard-status and app-shard were retaken. The shard page
now reads 1 player online in Britain, and the app's card agrees.

Two things worth noticing in the retake. Presence reaches the public page as
counts and regions rather than names, which is the visibility framework working
unprompted. And the guild board's online column did not move: it refreshes only
when a guild's signature changes, which is the stale-roster defect wearing a
different hat.

A sixth defect, visible in the shipped phone capture: the app interpolates a
count into a fixed plural and says "1 players online". Raised, not fixed —
it wants a plurals resource in the app.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-25 10:08:41 -05:00
c29ec94f46 feat(screens): phase 9 — real screenshots, from a real shard
All checks were successful
PR checks / checks (pull_request) Successful in 1m25s
D4 asked for screenshots of the review stack rather than placeholders. Seventeen
of them: eleven of the site in a browser, six of the app on a phone, all from one
demo deployment wired to a running ServUO shard over a real sidecar, captured on
one day (D42).

The deployment is branded "Runic Gateway Demo" rather than a real community (D43),
and the captures sit beside the claims they support — the homepage, /features/, and
five of the administration pages phase 7 could describe but not show (D44).

The rig is committed rather than remembered (D45):

  scripts/seedDemo.mjs        content, by driving the site's own API — never SQL,
                              because a row the product could not have produced is
                              a screenshot of a product that does not exist
  src/data/screens.mjs        every capture: route, viewport, scroll, alt, caption
  scripts/captureScreens.mjs  npm run screens:capture
  scripts/checkScreens.mjs    the ninth check script, in CI

Shard-side dressing is servuo-plugins' scaffolding (D46), never deployed.

The rig found five things nothing else had. One is fixed upstream — a fresh
module-uo install pinned wire protocol 3 against a sidecar speaking 4, released as
v1.0.2, which this repo's own facts check then caught in platform.json. Four are
raised as product observations and worked around in the rig: a renamed guild
member never reaches the site, a guild deleted while the shard is down is a ghost
row forever, "Houses in danger" cannot show a house that was already collapsing,
and the app's news list prints raw ISO timestamps.

Players online reads 0. Logging a character in needs a UO client driven by hand,
and that is where this stopped — PLAN.md §10 says exactly why, and how to retake
the two frames that would change.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-25 09:55:55 -05:00
31d914ba44 Merge pull request 'docs(builder): phase 8 — modules, architecture and reference' (#12) from feat/phase-8-docs into main
All checks were successful
PR checks / checks (push) Successful in 1m11s
Reviewed-on: #12
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-24 17:33:22 +00:00
b3cb6bf1eb Merge pull request 'fix(docs): clear the quickstart drift the upstream fixes caused' (#11) from fix/quickstart-drift-and-installer-note into main
Some checks failed
PR checks / checks (push) Has been cancelled
Reviewed-on: #11
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-24 17:32:49 +00:00
d89ce06bb8 docs(builder): phase 8 — modules, architecture and reference
All checks were successful
PR checks / checks (pull_request) Successful in 1m13s
Twenty pages completing the tree section 10 planned: Modules (8), Architecture
(5) and Reference (7). Four decisions, D38-D41, recorded in PLAN.md section 10.

D39 is the one that shaped the phase. Section 1 forbids re-specifying a
contract, and a Reference section is exactly where that rule is most tempting to
break, so the line is drawn at names: every environment variable, config key,
installer command, visibility rung and canonical document is listed with one
terse line saying what it is FOR, while shapes, semantics and every "why" stay
in the canonical document.

That is only safe because the names are checked. checkReference.mjs compares six
enumerations against the repositories that own them, over the Gitea API, as set
comparisons in BOTH directions -- and the second direction is the one that earns
its keep, because a reference page does not usually rot by describing something
that vanished, it rots by quietly not mentioning what was added since.

The check went green on its first run, which is the least trustworthy possible
outcome, so it was verified by breaking it: seven mutations, all caught. The one
worth keeping is the visibility ladder REORDERED with its membership unchanged
-- it is a security boundary, and a set comparison alone would have passed it.

D41 turns plannedSidebar from a checklist into a checked invariant, and finding
out why was the phase's first defect: it had already drifted, because phase 7
added the Content page under D37 and never updated the list. Nothing failed,
because nothing read it. checkSidebar.mjs now asserts the two trees agree on
groups, labels and order -- order because the order of Getting started IS the
installation path.

Two more things the writing found. PLAN.md's page count was wrong and had been
since section 10 was written ("roughly 38, 37 planned" for a tree of forty).
And module.json's `mounts` and the SPA's paths are different mechanisms that no
single document stated plainly -- module-uo declares admin: ["/shard",
"/uo-link"] while its screen lives at /admin/uo/link, because API routes are
deliberately NOT namespaced while SPA routes are. That is precisely the
distinction the installer got wrong in v0.1.0, and it now has a named home.

D40: the docs link to /architecture/'s drawn diagrams rather than importing
them. Those components carry marketing chrome and depend on diagram.css, which
Starlight does not load; the docs use text diagrams, which paste into an issue.

npm run verify green: 40 pages across 5 groups agree with plannedSidebar, 2390
internal links resolve, 123 repository links point at a branch, 19 facts, 59
quickstart checks, 22 reference enumerations, astro check 0 errors, 36 tests.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-24 12:17:31 -05:00
e8cb6061fe fix(facts): installer v0.1.1 is released, so the note names a version
All checks were successful
PR checks / checks (pull_request) Successful in 9m22s
Recovering installer's orphan tag published v0.1.1, which moved the platform
under this branch and turned checkFacts red -- the check working exactly as it
should, since a version this site quotes had changed.

  FAIL  release installer
        platform.json says : v0.1.0
        installer releases/latest says : v0.1.1

The 500 that orphaned the tag was a race with the tag push one second earlier,
not a structural failure: re-running the workflow took the built-in orphan-tag
recovery path and published all four assets unchanged.

So the stale-path Aside stops saying "v0.1.0 is still the current download",
which is no longer true, and says the durable thing instead -- v0.1.0 prints
the old path, v0.1.1 prints the real one -- which stays correct however many
releases follow. platform.json and the PLAN.md version table move to v0.1.1,
and the phase 7 findings record the pipeline defect as a fourth finding.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-24 11:50:38 -05:00
a993b872ac fix(docs): clear the quickstart drift the upstream fixes caused
All checks were successful
PR checks / checks (pull_request) Successful in 1m1s
The three defects phase 7 found are fixed and merged: website#163
(SECRET_ENC_KEY missing from the root .env.example, plus BOT_INTERNAL_KEY in
the README's "set at least" list) and installer#22 + docs#174 (the handoff
printing /admin/shard).

website#163 turned checkQuickstart red here, which is precisely what the
declaration was built to do -- it fails the moment a declared key appears
upstream, so the note describing the omission cannot outlive the defect. The
SECRET_ENC_KEY entry is deleted and notInUpstreamEnvExample is now empty; the
export stays so the next divergence gets an entry rather than passing quietly.

The stale-path Aside on Connect a game server is pinned to v0.1.0 rather than
calling the installer permanently wrong, and now says WHY the old path is worse
than a 404: the SPA has no route for it, so it redirects to the dashboard and
the link looks like it worked.

v0.1.0 is still the current download, and not only because releases lag. The
release run for installer#22 built every artifact and pushed tag v0.1.1, then
took a 500 creating the release -- so the tag is orphaned and no binaries were
published. Raised on installer; nothing is worked around here.

This also recovers 084ee0b, which was pushed to feat/phase-7-docs after PR #10
had already merged f499f2b, and so never reached main.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-24 11:39:36 -05:00
bcb633403f Merge pull request 'docs(journey): phase 7 — the installation path and administration' (#10) from feat/phase-7-docs into main
All checks were successful
PR checks / checks (push) Successful in 1m7s
Reviewed-on: #10
2026-08-24 15:24:18 +00:00
62 changed files with 5094 additions and 87 deletions

View File

@@ -65,6 +65,30 @@ jobs:
# the shorter form would pass locally and break only here. # the shorter form would pass locally and break only here.
run: npm test 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 - name: Production build
run: npm run build run: npm run build
@@ -115,3 +139,22 @@ jobs:
env: env:
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }} GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: npm run check:quickstart 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
View File

@@ -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` | | **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 | | 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 | | Plugin overlay | **v1.0.0** (2026-08-19) | release; in bundle 2026.08.19 |
| Installer | **v0.1.0** (2026-08-07) | release | | Installer | **v0.1.1** (2026-08-24) | release |
| `module-uo` | **v1.0.1** (2026-08-19) | 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` | | 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` | | 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) | | `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 **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 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 | | # | Where | What it settled |
|---|---|---| |---|---|---|
@@ -246,6 +246,8 @@ somewhere other than the thing it decided. The count of record is **thirty-seven
| D26D29 | §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 | | D26D29 | §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 |
| D30D33 | §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 | | D30D33 | §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 |
| D34D37 | §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 | | D34D37 | §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 |
| D38D41 | §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 |
| D42D46 | §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 data change rather than a design task. Rejected: shipping the fourteen, and pulling phase 9's rig
forward into phase 5. 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 **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 `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 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 | | `/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) | | `/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 | | `/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) | | `/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 | | `/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 | | `/privacy/`, `/terms/` | §9 |
@@ -837,7 +844,7 @@ Reference Environment variables · Installer CLI · sidecar.toml ·
Bridge.cfg · HTTP API · Event catalog · Canonical documents 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. document** — never a re-specification, per §1.
### The installation path ### 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 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 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 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 - **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 `<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 one path segment, and the screen is **`/admin/uo/link`**, labelled *Shard (uo-link)*. The old path
binary (`installer` `main:src/sidecar.rs`) and the guide are stale; the journey documents the real does not even 404 — the SPA sends the operator to the dashboard, so the link looks like it worked
path and says the printed one does not resolve. 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 - **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 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 `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 ## 11. Visual direction
**"Modern infrastructure software with an arcane identity."** Dark-first. Marketing pages are **"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 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 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. 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). - **`scripts/checkTokens.mjs`** — no colour literal outside the token file (§7).
- `astro check` plus a production build, in CI on every PR. - `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) | | **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) | | **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** | | **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) | | **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 | | **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) | | **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 Phases 5 and 6 are deliberately adjacent and early: the beta cannot start without `/privacy`, and

838
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -20,11 +20,15 @@
"check:links": "node scripts/checkLinks.mjs", "check:links": "node scripts/checkLinks.mjs",
"check:datasafety": "node scripts/playDataSafety.mjs --check", "check:datasafety": "node scripts/playDataSafety.mjs --check",
"check:quickstart": "node scripts/checkQuickstart.mjs", "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", "play:datasafety": "node scripts/playDataSafety.mjs",
"beta": "node scripts/beta.mjs", "beta": "node scripts/beta.mjs",
"test": "node --test test/beta.test.mjs test/legal.test.mjs", "test": "node --test test/beta.test.mjs test/legal.test.mjs",
"brand:assets": "node scripts/buildBrandAssets.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": { "dependencies": {
"@astrojs/node": "^11.1.4", "@astrojs/node": "^11.1.4",
@@ -38,7 +42,8 @@
"devDependencies": { "devDependencies": {
"@astrojs/check": "^0.9.10", "@astrojs/check": "^0.9.10",
"opentype.js": "^2.0.0", "opentype.js": "^2.0.0",
"yaml": "^2.8.1", "puppeteer-core": "^23.11.1",
"typescript": "^6.0.3" "typescript": "^6.0.3",
"yaml": "^2.8.1"
} }
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

BIN
public/screens/guilds.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

BIN
public/screens/houses.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

BIN
public/screens/news.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

189
scripts/captureScreens.mjs Normal file
View 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
View 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
View 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
View 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
View 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',
);

View 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>

View File

@@ -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 — * 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: * `docs/android/screenshots/` on the `docs` repository — and they are the wrong fourteen: a
* a trusted-device and recovery-code smoke test from 2026-07-22, captured against 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 * 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. * 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 * Shipping them would have broken D4 and §1 at once, so D26 reserved the slot for the phase
* review stack rather than placeholders, and §1, because they would show an app that no * that stands up the review stack anyway. The shape was defined then and the data arrived
* longer looks like that. D26 records the decision — the slot is reserved, phase 9 fills * now, which is exactly what it was for: filling it was a data change.
* 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.
* *
* --------------------------------------------------------------------------------------- * ---------------------------------------------------------------------------------------
* 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 * The draft carried its own `shots` array, written before there was anywhere else to put
* seeded review stack, one per idea rather than one per screen: the shard hub with live * one. There is now: `src/data/screens.mjs` holds every capture the site ships, web and
* data, the marketplace, a character sheet, the news list, the notification settings, and * phone alike, and `scripts/checkScreens.mjs` proves each one exists at the size the markup
* the drawer showing a deployment's own navigation. Six is plenty. Fourteen was never a * claims. A second list here would be the one nothing checks.
* target — it was the number that happened to exist.
* *
* They belong in `public/`, not `brand-default/`: these are editorial content shipped with * The phone captures come from an emulator pointed at the same seeded deployment the web
* the image, not branding an operator overrides (§7). * 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.
*/ */
/** const shots = screensOf('phone');
* 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[] = [];
--- ---
{ {
@@ -63,8 +38,8 @@ const shots: Shot[] = [];
<section class="page section shots"> <section class="page section shots">
<h2>What it looks like</h2> <h2>What it looks like</h2>
<p class="prose shots__lede"> <p class="prose shots__lede">
Captured against a real deployment with real content, not mocked up. The app takes Captured against a real deployment with real content, not mocked up. The app takes its
its colours, type and navigation from the site it is connected to, so these show one colours, type and navigation from the site it is connected to, so these show one
community's app rather than a neutral one. community's app rather than a neutral one.
</p> </p>
@@ -72,10 +47,10 @@ const shots: Shot[] = [];
{shots.map((shot) => ( {shots.map((shot) => (
<li class="shots__item"> <li class="shots__item">
<img <img
src={shot.src} src={`/screens/${shot.id}.webp`}
alt={shot.alt} alt={shot.alt}
width={shot.width} width={PHONE.width}
height={shot.height} height={PHONE.height}
loading="lazy" loading="lazy"
decoding="async" decoding="async"
/> />

View 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>

View File

@@ -44,12 +44,54 @@ export const docsSidebar = [
{ label: 'Troubleshooting', slug: 'docs/administration/troubleshooting' }, { 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 * The tree §10 planned, kept as the record of what was intended — every page it names now
* checklist in the place they will be working. Not exported into the Starlight config — * exists, as of phase 8.
* it names pages that do not exist yet. *
* 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 = { export const plannedSidebar = {
'Getting started': [ 'Getting started': [
@@ -65,6 +107,7 @@ export const plannedSidebar = {
'Configuration', 'Configuration',
'Branding and theming', 'Branding and theming',
'Navigation and pages', 'Navigation and pages',
'Content',
'Users and roles', 'Users and roles',
'Authentication', 'Authentication',
'Teams', 'Teams',

View File

@@ -4,6 +4,7 @@ description: Colours, fonts and corners from the Appearance screen; logo, hero a
--- ---
import { Aside } from '@astrojs/starlight/components'; 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 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 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 - **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. from this site's public branding. Changing it here changes them.
<Screenshot id="admin-appearance" />
## Brand assets ## Brand assets
The same screen uploads three images, and each applies as soon as the upload finishes — The same screen uploads three images, and each applies as soon as the upload finishes —

View File

@@ -4,10 +4,13 @@ description: The five states a module can be in, installing and upgrading, disab
--- ---
import { Aside } from '@astrojs/starlight/components'; 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/). Installing your first module is [Getting started](/docs/getting-started/install-a-game-module/).
This is what the screen means afterwards. This is what the screen means afterwards.
<Screenshot id="admin-modules" />
## The five states ## The five states
`installed → enabled → started`, with `disabled` and `startup_failed` as recoverable `installed → enabled → started`, with `disabled` and `startup_failed` as recoverable

View File

@@ -4,6 +4,7 @@ description: The module's shard screen — connection settings, what the status
--- ---
import platform from '../../../../data/platform.json'; import platform from '../../../../data/platform.json';
import Screenshot from '../../../../components/Screenshot.astro';
import { Aside } from '@astrojs/starlight/components'; import { Aside } from '@astrojs/starlight/components';
With the `uo` module installed, **Shard (uo-link)** appears in the admin sidebar at 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 Setting it up for the first time is
[Connect a game server](/docs/getting-started/connect-a-game-server/). [Connect a game server](/docs/getting-started/connect-a-game-server/).
<Screenshot id="admin-shard" />
## Connection ## Connection
Four fields, all four printed by the installer, plus the switch that turns the integration Four fields, all four printed by the installer, plus the switch that turns the integration

View File

@@ -4,6 +4,7 @@ description: The four roles and what each one reaches, creating accounts, and in
--- ---
import { Aside } from '@astrojs/starlight/components'; import { Aside } from '@astrojs/starlight/components';
import Screenshot from '../../../../components/Screenshot.astro';
## The four roles ## 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 Demoting an account takes effect at once — the open session does not keep its access until
it expires. it expires.
<Screenshot id="admin-users" />
## Creating an account ## Creating an account
**Admin → Users → + Add user** creates one directly: username, password, role, and it is **Admin → Users → + Add user** creates one directly: username, password, role, and it is

View File

@@ -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.

View 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.

View 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.

View 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 34 for pages and the
activity feed.

View 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.

View File

@@ -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 **WebSocket URL**, **Auth token** and **Protocol**, and save. The ingest client restarts
immediately. immediately.
<Aside type="caution" title="The installer prints an older path for that screen"> <Aside type="caution" title="Installer v0.1.0 prints an older path for that screen">
It prints `…/admin/shard`. Since the shard screens became part of the `uo` module, a module v0.1.0 prints `…/admin/shard`. Since the shard screens became part of the `uo` module — and
owns one path segment and the screen moved to **`/admin/uo/link`**. Use the sidebar, or a module owns one path segment wherever it appears — the screen moved to
that path; the printed link no longer resolves. **`/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> </Aside>
The token is encrypted at rest and **never returned to any client** — losing it means The token is encrypted at rest and **never returned to any client** — losing it means

View File

@@ -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 { 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 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 **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. **Web Bot Activity** screen shows and how to lift a ban.
</Aside> </Aside>
<Screenshot id="admin-dashboard" />
## What is already there ## What is already there
The first boot seeds a working site rather than an empty one: The first boot seeds a working site rather than an empty one:

View 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.

View 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.

View 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.

View 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.

View 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
```

View 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.

View 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.

View 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.

View 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.

View 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.

View 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.

View 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.

View 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.

View 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.

View 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.

View File

@@ -27,8 +27,8 @@
"releases": { "releases": {
"link": "v2.0.0", "link": "v2.0.0",
"installer": "v0.1.0", "installer": "v0.1.1",
"Module-uo": "v1.0.1", "Module-uo": "v1.0.2",
"Android-app": "v0.5.0" "Android-app": "v0.5.0"
}, },

View File

@@ -110,22 +110,21 @@ export const env = [
]; ];
/** /**
* `SECRET_ENC_KEY` is in this quickstart and NOT in upstream's `.env.example`, which is why * Keys this quickstart sets that upstream's `.env.example` does not, each with the reason.
* it needs a declaration rather than passing quietly.
* *
* Found by booting this exact file against the published image (phase 7): the server calls * **Empty, and that is the point.** Its one entry was `SECRET_ENC_KEY`: phase 7 booted this
* `resolveKey()` in `utils/secretBox.js` at require time and throws * exact file against the published image and the container crash-looped before it ever
* `SECRET_ENC_KEY must be set in production`, so the container crash-loops before it ever * listened, because `resolveKey()` in `utils/secretBox.js` throws
* listens. It is documented in `server/.env.example` — the file local development copies — * `SECRET_ENC_KEY must be set in production` at require time. The variable was documented in
* and missing from the root `.env.example` that Compose actually reads. * `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 * The declaration was written so it could not outlive the defect: the check fails the moment
* variable appears in `.env.example`, so this note cannot outlive the defect it describes. * 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 = { 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",
};
/** /**
* Variables upstream's `.env.example` carries that the quickstart leaves out, each with the * Variables upstream's `.env.example` carries that the quickstart leaves out, each with the

190
src/data/reference.mjs Normal file
View 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
View File

@@ -0,0 +1,240 @@
/**
* screens.mjs — every screenshot the site ships, and where it came from.
*
* PLAN.md §13 phase 9, D4 / D42D45.
*
* ---------------------------------------------------------------------------------------
* 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);
}

View File

@@ -2,6 +2,7 @@
import Base from '../layouts/Base.astro'; import Base from '../layouts/Base.astro';
import PageHeader from '../components/PageHeader.astro'; import PageHeader from '../components/PageHeader.astro';
import NotBuilt from '../components/NotBuilt.astro'; import NotBuilt from '../components/NotBuilt.astro';
import Screenshot from '../components/Screenshot.astro';
import platform from '../data/platform.json'; import platform from '../data/platform.json';
import { import {
@@ -55,6 +56,26 @@ import {
assertCapabilityCoverage(platform.moduleUoCapabilities); assertCapabilityCoverage(platform.moduleUoCapabilities);
assertDetailCoverage(); 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 title = 'Features';
const description = const description =
'What a Runic Gateway deployment does — core, and what the installed game module adds.'; 'What a Runic Gateway deployment does — core, and what the installed game module adds.';
@@ -110,6 +131,14 @@ const description =
</li> </li>
))} ))}
</ul> </ul>
{groupScreens[group.id] && (
<div class="group__shots">
{groupScreens[group.id].map((id: string) => (
<Screenshot id={id} />
))}
</div>
)}
</section> </section>
)) ))
} }
@@ -118,6 +147,16 @@ const description =
</Base> </Base>
<style> <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 { .group__head {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;

View File

@@ -4,6 +4,7 @@ import { brand } from '../lib/brand.mjs';
import Hero from '../components/home/Hero.astro'; import Hero from '../components/home/Hero.astro';
import DataPath from '../components/home/DataPath.astro'; import DataPath from '../components/home/DataPath.astro';
import WhatItLooksLike from '../components/home/WhatItLooksLike.astro';
import SelfHosted from '../components/home/SelfHosted.astro'; import SelfHosted from '../components/home/SelfHosted.astro';
import Capabilities from '../components/home/Capabilities.astro'; import Capabilities from '../components/home/Capabilities.astro';
import GetStarted from '../components/home/GetStarted.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> <Base title={`${brand.siteName} — ${brand.tagline}`} description={brand.tagline} bareTitle>
<Hero /> <Hero />
<DataPath /> <DataPath />
<WhatItLooksLike />
<SelfHosted /> <SelfHosted />
<Capabilities /> <Capabilities />
<GetStarted /> <GetStarted />