Merge pull request 'feat(docs): phase 14 — game-neutral core docs, an Ultima Online group, and checkTerms' (#34) from feat/phase-14-vocabulary into main
Some checks failed
Build and publish the image / build (push) Failing after -1m50s
Build and publish the image / deploy (push) Has been skipped
PR checks / checks (push) Successful in 8m56s

Reviewed-on: #34
This commit is contained in:
2026-10-22 19:41:17 +00:00
52 changed files with 1415 additions and 557 deletions

View File

@@ -78,6 +78,16 @@ jobs:
# No token, no network, no build — so it runs early and fails fast.
run: npm run check:sidebar
- name: Game vocabulary
# PLAN.md §16, D63/D64 — the twelfth check, phase 14. The site may NAME the games it
# supports anywhere, but a game's vocabulary (shard, guild, clan, uo-link, Oxide…)
# belongs in that game's own docs group, its sidebar group, or a <GameExample> block
# that labels it (D68). Core pages use core's words: a Team, a game server, the
# sidecar, the bridge plugin, the game module.
#
# No token, no network, no build — so it runs early and fails fast.
run: npm run check:terms
- 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

View File

@@ -57,7 +57,7 @@ absent `brand/` produces exactly the stock site, and `data/` is created on first
## The checks
There are eleven, plus two test suites. `npm run verify` runs all of them in dependency order, and
There are twelve, plus two test suites. `npm run verify` runs all of them in dependency order, and
that is what CI does on every pull request.
```bash

78
PLAN.md
View File

@@ -1,6 +1,6 @@
# runicgateway.com — design of record
**Status:** phases 0–12 are built and deployed. **Revision 4 (2026-10-22) plans phases 13–18** —
**Status:** phases 0–14 are built (13 and 14 as part of revision 4). **Revision 4 (2026-10-22) plans phases 13–18** —
the second game, RunicNPC, and taking game vocabulary out of the site at large — in §16.
**Date:** 2026-08-19 (revision 3) · 2026-10-22 (revision 4)
**Repo:** `RunicGateway/runicgateway.com`
@@ -237,7 +237,7 @@ Taken by the org lead (Colby Whitlock) on 2026-08-19. Recorded so they are not r
**Decisions after D13 are recorded where they were taken**, in the section describing the phase that
raised them, rather than appended here — a decision is only re-litigated when its reasoning is
somewhere other than the thing it decided. The count of record is **sixty-six**:
somewhere other than the thing it decided. The count of record is **sixty-nine**:
| # | Where | What it settled |
|---|---|---|
@@ -256,6 +256,8 @@ somewhere other than the thing it decided. The count of record is **sixty-six**:
| D59 | §6, "How phase 12 delivered it" | The container publishes on every interface (amending D55), with `SITE_BIND_ADDR` to narrow it |
| D60–D65 | §16, "The six decisions" | Per-game sidebar groups, RunicNPC inside Rust with its own page, the RunicNPC API carried in full with a drift check, game names allowed but game vocabulary confined, a twelfth check that enforces it (amending D53), Rust screenshots last and from the rigs |
| D66 | §16, "How phase 13 built it" | The hero and footer show the Module API only; a protocol and a bundle belong to a game |
| D67 | §16, "One rule the phases need" | A screenshot belongs to its game: a capture tagged with a game may carry its words, beside the game's name |
| D68–D69 | §16, "How phase 14 built it" | A game's real example on a core page is a tagged, labelled block; *Verify the whole stack* stays in Getting started, game-neutral (amending D60) |
---
@@ -1669,6 +1671,10 @@ a mechanism rather than diligence:
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/checkTerms.mjs`** — added in phase 14 for D63 and D64, the twelfth check. No game's
vocabulary outside that game's section: its docs group, its sidebar group, or a
`<GameExample>` block (D68). Reads the docs and both sidebar trees; phase 17 widens it to all of
`src/`.
- `astro check` plus a production build, in CI on every PR.
---
@@ -1925,7 +1931,9 @@ screen and the NPC profile editor.
### One rule the phases need, recorded so review can object
**A screenshot belongs to its game.** Phase 9's UO captures carry the word "Shard" in the pixels and
Adopted by the org lead on 2026-10-22 as **D67**.
**D67 — A screenshot belongs to its game.** Phase 9's UO captures carry the word "Shard" in the pixels and
in their alt text. Alt text has to describe the image honestly, so a capture tagged with a game in
`screens.mjs` may use that game's words in its caption and alt. The page showing it must name the
game next to it. `checkTerms.mjs` reads the tag. The homepage's "What it looks like" therefore either
@@ -2022,6 +2030,70 @@ cover, and phase 17 is the phase that writes that list. Phase 13 does the half t
check: `checkFacts` now holds Module-Rust's declared capabilities against `platform.json`, so a
capability the module adds turns this repo red today. The page-side assertion lands with the page.
### How phase 14 built it
Three decisions, taken on 2026-10-22 when the restructure reached pages D60 had not placed. The
count of record is now **sixty-nine**. D67 is the screenshot rule above, adopted that day.
**D68 — a game's real example on a core page is a tagged, labelled block.** Some core pages need
a real example rather than an invented one: *The module manifest* shows a manifest, *Scheduled
events* names a module's budget dimensions, and the *Installer CLI* has to print
`--game <servuo|rust>`, because that is the flag. §1 prefers the real thing, since an invented
example is checked by nothing. So the example keeps its game's words and says whose they are:
`src/components/GameExample.astro` prints the game's name above the block, read from
`platform.json` so the label cannot disagree with the attribute, and `checkTerms.mjs` allows that
game's words, and only that game's, between the tags. It is D67's bargain applied to text: a
game's words are honest on a core page when the game is named right beside them. The
alternatives were an invented neutral example (the Integration Kit's template could not serve: it
mounts `/clans`) and prose only, which would have left the core pages less concrete.
**D69 — *Verify the whole stack* stays in Getting started, game-neutral.** This amends D60, which
had Getting started end on the hand-off page. The core page now teaches the four links in the
chain — the plugin loaded, the game server reached the sidecar, the sidecar is healthy, the site
is ingesting — which are the same for both games, and sends the reader to their game for the
commands. The Ultima Online commands became that group's *Verify the bridge*. The Ultima Online
host requirements moved onto *Connect a ServUO shard*, and the Ultima Online rows of
*Troubleshooting* became the group's own *Troubleshooting* page.
**What moved, and what stayed.** The Ultima Online group has nine pages: an overview (*The Ultima
Online module*, which took *Install a game module*'s "what the `uo` module adds"), *Connect a
ServUO shard*, *Verify the bridge*, *The shard connection*, *Client files*, *Troubleshooting*,
*Bridge.cfg*, *sidecar.toml* and the *Shard event catalog*. Five pages changed URL, and each old
URL answers with a real `301` from the server: `src/config/redirects.mjs`, asserted by
`test/headers.test.mjs`. *Connect a game server* did **not** move. Its URL is now the
game-neutral hand-off page (*Connect your game server*), which is a better landing for an old link
than a redirect into one game. Until phase 15, its Rust row links to `docs`
`main:rust-link/INSTALL.md`.
**The core pages use core's words** (D63): a Team, a game server, the sidecar, the bridge plugin,
the game module. Detail that was really one game's moved into that game's pages rather than being
dropped. *The bridge*'s guild-roster passage, for example, is now a general rule about Team rosters,
with the Ultima Online specifics in its event catalog. Three passages were checked against Rust before they were
generalised: both bridges dial loopback newline-delimited JSON with one writer thread, both
sidecars answer `/health` with *plugin connected*, both plugins ship `EventsEnabled` off, and
both modules encrypt their sidecar token with core's key. `canonicalDocs` is now grouped by
game, and the Rust group carries four documents. Discord's *guild* became *Discord server*
everywhere except the one label core's Discord Bot screen really prints, *Guild (server) ID*,
which is the check's only allowlist entry, as D64 predicted.
**`checkTerms.mjs` is the twelfth check.** It reads every docs page, frontmatter included, and
both sidebar trees as data. It knows 16 words, each tagged with its game. Link targets, bare URLs,
imports and `{…}` expressions are not read, because an address is not prose and an expression
renders a value from `src/data/`, which phase 17 brings into scope. Fenced code is read. An
allowlist entry that stops matching fails the check, so the list cannot rot. Before it was
trusted it was run against a planted page: five violations (a word in prose, in a title, in link
text, in a code fence, and inside another game's block) all failed, and a URL and an expression
carrying the same words both passed.
**Two findings outside this repository**, reported rather than fixed (they belong to other
repositories):
- **Core's own Teams panel says "shard".** `website` `client/src/lib/teamAdmin.js` tells an
administrator the list "is not a confirmed empty shard". The docs page now paraphrases it
rather than quoting it.
- **The installer's `--help` says "Which game's shard side"** for `--game`, and `--site-url` names
"Admin → Shard (uo-link)" for both games.
---
[api]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md

View File

@@ -13,7 +13,7 @@ it before changing anything here.
**Status: phase 12 of 12 — delivery. The site is built.** Fifty pages: ten marketing, legal and
app pages and forty of documentation, with real screenshots of the product, full-text search, a
per-page Content-Security-Policy, eleven checks that fail the build when the platform moves out from
per-page Content-Security-Policy, twelve checks that fail the build when the platform moves out from
under a claim, and a closed-beta signup backed by SQLite on a bind mount. This phase is the part
that makes it a deployment rather than a repository — the container image, the compose file, the
publishing workflow and [`DEPLOY.md`](DEPLOY.md).
@@ -51,11 +51,12 @@ a publication**.
## The checks, and why they are not optional
Eleven of them, from `PLAN.md` §12. None is a linter; each one enforces a promise the site makes
Twelve of them, from `PLAN.md` §12. None is a linter; each one enforces a promise the site makes
that would otherwise decay quietly.
```bash
npm run check:sidebar # the rendered docs tree still matches the planned one
npm run check:terms # no game's vocabulary outside that game's section
npm run check:screens # every screenshot has an entry, at the size declared
npm run check:tokens # no colour literal outside the token file
npm run check:brand # the branding pipeline's two quiet failures

View File

@@ -4,6 +4,7 @@ import node from '@astrojs/node';
import starlight from '@astrojs/starlight';
import { inlineScriptHashes, inlineStyleHashes } from './src/config/cspHashes.mjs';
import { docsRedirects } from './src/config/redirects.mjs';
import { docsSidebar } from './src/config/sidebar.mjs';
/**
@@ -34,6 +35,10 @@ export default defineConfig({
format: 'directory',
},
// Pages that moved into a game's own sidebar group (PLAN.md §16, D60). See the file.
redirects: docsRedirects,
security: {
csp: {
directives: [

View File

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

View File

@@ -168,7 +168,7 @@ async function run() {
//
// 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)) {
for (const docPath of Object.values(canonicalDocs).flatMap((group) => Object.keys(group))) {
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}`);

271
scripts/checkTerms.mjs Normal file
View File

@@ -0,0 +1,271 @@
#!/usr/bin/env node
/**
* checkTerms.mjs — PLAN.md §16, D63 and D64. The twelfth check, added in phase 14.
*
* D63: outside a game's own section, the site may NAME the games it supports, but it may not
* use their VOCABULARY. Core concepts keep core's names — a Team, a game server, the sidecar,
* the bridge plugin, the game module — because that is what core itself calls them, and a
* site that says "shard" on a core page is describing one game's deployment as though it were
* the platform.
*
* D64: a convention that holds only while everyone remembers it does not hold. This is the
* same argument `checkTokens.mjs` makes for colours, applied to words. The vocabulary problem
* was measured before this check existed — "shard" 227 times in 45 files — and none of that
* was anybody's decision; it was the residue of a site written while there was one game.
*
* What it reads, as of phase 14:
*
* - every page under `src/content/docs`, frontmatter included (a title and a description
* are what a reader sees first, in the sidebar and in search results);
* - `src/config/sidebar.mjs`, as DATA rather than as text: every group label and every
* page label, in both trees. Its comments are not scanned, because a comment is not
* something a reader of the site ever sees.
*
* Phase 17 widens it to all of `src/`, once the marketing pages have a game page to move
* their game content to. That is also when it learns D67's screenshot tag.
*
* Where a game's words are allowed:
*
* - in that game's own docs directory (`GAME_DIRS`), and its own sidebar group — a group
* whose label is the game's `name` in `platform.json`;
* - between `<GameExample game="…">` tags on a core page (D68). The component prints the
* game's name above the block, so the words arrive already labelled.
*
* What is NOT read, deliberately:
*
* - link targets and bare URLs. A repository is called what it is called, and an address is
* not prose. The LINK TEXT is read, so `[Module-uo](…)` fails and `[releases](…)` passes;
* - `import` lines and `{…}` expressions. They are code, and what an expression renders is
* a value from `platform.json` or `src/data/`, which phase 17 brings into scope.
*
* node scripts/checkTerms.mjs
*
* No token and no network: everything it reads is in this repository.
*/
import { readFileSync } from 'node:fs';
import { readdir } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
import { docsSidebar, plannedSidebar } from '../src/config/sidebar.mjs';
const ROOT = fileURLToPath(new URL('..', import.meta.url));
const platform = JSON.parse(readFileSync(path.join(ROOT, 'src/data/platform.json'), 'utf8'));
/**
* The words, each tagged with the game it belongs to. Matching is case-insensitive and on
* word boundaries, so "shard" does not match `SHARD_VISIBILITY` and "wipe" does not match
* "wiped" unless it is listed.
*
* A word is on this list because it is a game's name for something core has its own name for
* (shard → game server, guild and clan → Team), or because it names one game's component
* (uo-link → the sidecar, ServUO → the game server). Game NAMES — Ultima Online, UO, Rust —
* are not on it: D63 allows them everywhere. Steam is not on it either: Steam sign-in is a
* core identity provider, even though Rust was its first user.
*/
const TERMS = [
{ game: 'uo', word: 'shard', re: /\bshards?\b/gi },
{ game: 'uo', word: 'ServUO', re: /\bservuo\b/gi },
{ game: 'uo', word: 'uo-link', re: /\buo-?link\b/gi },
{ game: 'uo', word: 'Module-uo', re: /\bmodule-uo\b/gi },
{ game: 'uo', word: 'Bridge.cfg', re: /\bbridge\.cfg\b/gi },
{ game: 'uo', word: 'cliloc', re: /\bclilocs?\b/gi },
{ game: 'uo', word: 'guild', re: /\bguilds?\b/gi },
{ game: 'uo', word: 'IDOC', re: /\bidocs?\b/gi },
{ game: 'rust', word: 'rust-link', re: /\brust-?link\b/gi },
{ game: 'rust', word: 'Rust-Plugins', re: /\brust-plugins\b/gi },
{ game: 'rust', word: 'Module-Rust', re: /\bmodule-rust\b/gi },
{ game: 'rust', word: 'Oxide', re: /\boxide\b/gi },
{ game: 'rust', word: 'Carbon', re: /\bcarbon\b/gi },
{ game: 'rust', word: 'uMod', re: /\bumod\b/gi },
{ game: 'rust', word: 'clan', re: /\bclans?\b/gi },
{ game: 'rust', word: 'wipe', re: /\bwipes?\b/gi },
];
/** Each game's own docs directory. A page under one may use that game's words. */
const GAME_DIRS = {
uo: 'src/content/docs/docs/ultima-online/',
rust: 'src/content/docs/docs/rust/',
};
/**
* The allowlist: an exact phrase in one file that may use a word the rule would otherwise
* refuse. Short on purpose, and every entry says why. An entry that no longer matches its
* file fails the check too, so the list cannot rot into a set of permanent exemptions.
*/
const ALLOW = [
{
file: 'src/content/docs/docs/administration/notifications-and-email.mdx',
text: 'Guild (server) ID',
why: "Discord's own word, not a game's: it is the label on core's Discord Bot screen.",
},
];
for (const game of Object.keys(GAME_DIRS)) {
if (!platform.games[game]) throw new Error(`checkTerms: GAME_DIRS names "${game}", which platform.json does not.`);
}
for (const t of TERMS) {
if (!platform.games[t.game]) throw new Error(`checkTerms: "${t.word}" is tagged "${t.game}", which platform.json does not know.`);
}
const failures = [];
const fail = (where, detail) => failures.push({ where, detail });
/** Replace a span with spaces, keeping newlines, so later line numbers stay true. */
const blank = (text) => text.replace(/[^\n]/g, ' ');
/** The game whose directory holds this file, or null for a core page. */
function gameOfPath(relative) {
const posix = relative.split(path.sep).join('/');
for (const [game, dir] of Object.entries(GAME_DIRS)) if (posix.startsWith(dir)) return game;
return null;
}
/**
* Turn an MDX source into the text a reader would actually see, with everything that is not
* prose blanked out. Returns that text plus the GameExample regions, as offsets into it.
*/
function proseOf(source, relative) {
let text = source;
// GameExample regions first, while the tags are still intact. Recorded, then the tags
// themselves are blanked so the attribute does not read as prose.
const regions = [];
for (const m of text.matchAll(/<GameExample\s+game="([^"]*)"\s*>([\s\S]*?)<\/GameExample>/g)) {
const game = m[1];
if (!platform.games[game]) {
fail(`${relative}:${lineOf(source, m.index)}`, `<GameExample game="${game}"> names a game platform.json does not have`);
}
regions.push({ game, start: m.index, end: m.index + m[0].length });
}
text = text.replace(/<\/?GameExample\b[^>]*>/g, blank);
text = text.replace(/<!--[\s\S]*?-->/g, blank); // HTML comments
text = text.replace(/^import\s.*$/gm, blank); // MDX imports
text = text.replace(/\]\([^)\s]*\)/g, (m) => '] ' + blank(m.slice(2))); // link targets, not their text
text = text.replace(/\b(?:href|src)=(?:"[^"]*"|\{`[^`]*`\})/g, blank); // JSX/HTML link targets
text = text.replace(/\bhttps?:\/\/[^\s)>"'`]+/g, blank); // bare URLs
// `{…}` expressions, outside code fences only — a fenced JSON example's braces are prose.
// Innermost first, repeatedly, so nested and multi-line expressions both go.
const parts = text.split(/(^```[\s\S]*?^```)/m);
for (let i = 0; i < parts.length; i += 2) {
let prev;
do {
prev = parts[i];
parts[i] = parts[i].replace(/\{[^{}]*\}/g, blank);
} while (parts[i] !== prev);
}
text = parts.join('');
return { text, regions };
}
function lineOf(source, index) {
return source.slice(0, index).split('\n').length;
}
/** Hits for every term the given context may not use. */
function scan(text, allowedGame) {
const hits = [];
for (const t of TERMS) {
if (t.game === allowedGame) continue;
t.re.lastIndex = 0;
for (const m of text.matchAll(t.re)) hits.push({ term: t, value: m[0], index: m.index });
}
return hits;
}
async function* walk(dir) {
let entries;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) yield* walk(full);
else if (/\.mdx?$/.test(entry.name)) yield full;
}
}
// ── 1. The documentation pages ──────────────────────────────────────────────
let files = 0;
const allowUsed = new Set();
for await (const file of walk(path.join(ROOT, 'src', 'content', 'docs'))) {
const relative = path.relative(ROOT, file).split(path.sep).join('/');
const posix = relative;
const source = readFileSync(file, 'utf8').replace(/\r\n/g, '\n');
files++;
let { text, regions } = proseOf(source, relative);
for (const [i, entry] of ALLOW.entries()) {
if (entry.file !== posix) continue;
if (text.includes(entry.text)) {
allowUsed.add(i);
text = text.split(entry.text).join(blank(entry.text));
}
}
const pageGame = gameOfPath(relative);
for (const hit of scan(text, pageGame)) {
const region = regions.find((r) => hit.index >= r.start && hit.index < r.end);
if (region && region.game === hit.term.game) continue;
const where = region ? `inside <GameExample game="${region.game}">` : pageGame ? `in the ${platform.games[pageGame].name} group` : 'on a core page';
fail(
`${relative}:${lineOf(source, hit.index)}`,
`"${hit.value}" is ${platform.games[hit.term.game].name} vocabulary, ${where}`,
);
}
}
for (const [i, entry] of ALLOW.entries()) {
if (!allowUsed.has(i)) {
fail(`allowlist: ${entry.file}`, `"${entry.text}" no longer appears there — remove the entry`);
}
}
// ── 2. The sidebar, as data ─────────────────────────────────────────────────
const gameByName = new Map(Object.entries(platform.games).map(([key, g]) => [g.name, key]));
const trees = [
['docsSidebar', docsSidebar.map((g) => [g.label, g.items.map((i) => i.label)])],
['plannedSidebar', Object.entries(plannedSidebar)],
];
let labels = 0;
for (const [treeName, groups] of trees) {
for (const [groupLabel, items] of groups) {
const game = gameByName.get(groupLabel) ?? null;
for (const label of [groupLabel, ...items]) {
labels++;
for (const hit of scan(label, game)) {
fail(
`src/config/sidebar.mjs ${treeName} → ${groupLabel}`,
`"${label}" uses "${hit.value}", ${platform.games[hit.term.game].name} vocabulary, ${game ? `in the ${platform.games[game].name} group` : 'in a core group'}`,
);
}
}
}
}
// ── Report ──────────────────────────────────────────────────────────────────
if (failures.length === 0) {
console.log(
`checkTerms: ${files} docs pages and ${labels} sidebar labels use only their own game's words ` +
`(${TERMS.length} terms, ${ALLOW.length} allowlisted phrase${ALLOW.length === 1 ? '' : 's'}).`,
);
} else {
console.error(`\ncheckTerms: ${failures.length} game word(s) outside their game's section:\n`);
for (const f of failures) console.error(` ✗ ${f.where}\n ${f.detail}`);
console.error(`
PLAN.md §16, D63: name the games anywhere, but keep their vocabulary in their own section.
Use core's word instead — a Team, a game server, the sidecar, the bridge plugin, the game
module. If a core page genuinely needs a game's real example, wrap it in
<GameExample game="…"> (D68), which labels it with the game's name. Add to the allowlist
in this script only for a word that is not a game's at all, and say why.
`);
process.exit(1);
}

View File

@@ -0,0 +1,37 @@
---
import platform from '../data/platform.json';
/**
* A worked example that belongs to one game, quoted on a core page (PLAN.md §16, D68).
*
* D63 keeps a game's vocabulary inside that game's section, but a core page sometimes needs a
* real example rather than an invented one: a module manifest, an event dimension, the flag
* that tells the installer which game it is setting up. §1 prefers the real thing, because an
* invented example is checked by nothing and goes stale without anybody noticing.
*
* So the example keeps its game's words and says whose they are. This prints the game's name
* above the block — the same bargain D67 strikes for a screenshot: a game's words are honest
* on a core page when the game is named right beside them. `scripts/checkTerms.mjs` reads the
* `game` attribute and allows that game's words, and only that game's, between the tags.
*
* The name comes from `platform.json` rather than from the caller, so the label cannot say
* one game while the attribute that the check reads says another.
*/
interface Props {
/** A key of `platform.json`'s `games`: `uo` or `rust`. */
game: keyof typeof platform.games;
}
const { game } = Astro.props;
const entry = platform.games[game];
if (!entry) {
throw new Error(
`GameExample: unknown game "${game}". Use a key of platform.json's games: ${Object.keys(platform.games).join(', ')}.`,
);
}
---
<figure class="game-example" data-game={game}>
<figcaption class="game-example__label">{entry.name}</figcaption>
<div class="game-example__body"><slot /></div>
</figure>

22
src/config/redirects.mjs Normal file
View File

@@ -0,0 +1,22 @@
/**
* Docs pages that moved, and where they went — PLAN.md §16, D60.
*
* Every page that moves keeps its old URL working, because the old URL is in somebody's
* bookmarks, in an installer's output and in search results. With the Node adapter these are
* not files in `dist/client`: the server answers each one with a real `301`.
*
* Its own file, rather than inline in `astro.config.mjs`, so that `test/headers.test.mjs` can
* read the same list and assert every entry against the running server — the config cannot be
* imported by plain Node, because Starlight ships TypeScript.
*
* Phase 14 moved the Ultima Online pages into their own group. `connect-a-game-server` did
* NOT move: its URL is now the game-neutral hand-off page, which is a better landing for an
* old link than a redirect into one game would be.
*/
export const docsRedirects = {
'/docs/administration/the-shard-connection': '/docs/ultima-online/the-shard-connection/',
'/docs/administration/client-files': '/docs/ultima-online/client-files/',
'/docs/reference/bridge-cfg': '/docs/ultima-online/bridge-cfg/',
'/docs/reference/sidecar-toml': '/docs/ultima-online/sidecar-toml/',
'/docs/reference/event-catalog': '/docs/ultima-online/event-catalog/',
};

View File

@@ -1,5 +1,6 @@
/**
* The documentation sidebar — PLAN.md §10 "Documentation", five groups, ~37 pages.
* The documentation sidebar — PLAN.md §10 "Documentation": five core groups, then one group
* per game after them (§16, D60).
*
* The pages themselves land in phases 7 and 8. This file exists in phase 1 because the
* shape of the journey is a design decision that is already made, and because a sidebar
@@ -22,7 +23,7 @@ export const docsSidebar = [
{ label: 'Install the site', slug: 'docs/getting-started/install-the-site' },
{ label: 'First run', slug: 'docs/getting-started/first-run' },
{ label: 'Install a game module', slug: 'docs/getting-started/install-a-game-module' },
{ label: 'Connect a game server', slug: 'docs/getting-started/connect-a-game-server' },
{ label: 'Connect your game server', slug: 'docs/getting-started/connect-a-game-server' },
{ label: 'Verify the whole stack', slug: 'docs/getting-started/verify-the-whole-stack' },
],
},
@@ -42,8 +43,6 @@ export const docsSidebar = [
{ label: 'Engagement rules', slug: 'docs/administration/engagement-rules' },
{ label: 'Message templates', slug: 'docs/administration/message-templates' },
{ label: 'Managing modules', slug: 'docs/administration/managing-modules' },
{ label: 'The shard connection', slug: 'docs/administration/the-shard-connection' },
{ label: 'Client files', slug: 'docs/administration/client-files' },
{ label: 'Maintenance and upgrades', slug: 'docs/administration/maintenance-and-upgrades' },
{ label: 'Troubleshooting', slug: 'docs/administration/troubleshooting' },
],
@@ -77,13 +76,28 @@ export const docsSidebar = [
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: 'Shard event catalog', slug: 'docs/reference/event-catalog' },
{ label: 'Canonical documents', slug: 'docs/reference/canonical-documents' },
],
},
// One group per game, after Reference (PLAN.md §16, D60). Everything above this line is
// core and is held to core's words by checkTerms.mjs (D63, D64). A group whose label is a
// game's `name` in platform.json is that game's group, and may use that game's words — so
// the label is load-bearing: renaming it here without renaming the game turns it core.
{
label: 'Ultima Online',
items: [
{ label: 'The Ultima Online module', slug: 'docs/ultima-online' },
{ label: 'Connect a ServUO shard', slug: 'docs/ultima-online/connect-a-servuo-shard' },
{ label: 'Verify the bridge', slug: 'docs/ultima-online/verify-the-bridge' },
{ label: 'The shard connection', slug: 'docs/ultima-online/the-shard-connection' },
{ label: 'Client files', slug: 'docs/ultima-online/client-files' },
{ label: 'Troubleshooting', slug: 'docs/ultima-online/troubleshooting' },
{ label: 'Bridge.cfg', slug: 'docs/ultima-online/bridge-cfg' },
{ label: 'sidecar.toml', slug: 'docs/ultima-online/sidecar-toml' },
{ label: 'Shard event catalog', slug: 'docs/ultima-online/event-catalog' },
],
},
];
/**
@@ -105,7 +119,7 @@ export const plannedSidebar = {
'Install the site',
'First run',
'Install a game module',
'Connect a game server',
'Connect your game server',
'Verify the whole stack',
],
Administration: [
@@ -122,8 +136,6 @@ export const plannedSidebar = {
'Engagement rules',
'Message templates',
'Managing modules',
'The shard connection',
'Client files',
'Maintenance and upgrades',
'Troubleshooting',
],
@@ -148,10 +160,18 @@ export const plannedSidebar = {
Reference: [
'Environment variables',
'Installer CLI',
'sidecar.toml',
'Bridge.cfg',
'HTTP API',
'Shard event catalog',
'Canonical documents',
],
'Ultima Online': [
'The Ultima Online module',
'Connect a ServUO shard',
'Verify the bridge',
'The shard connection',
'Client files',
'Troubleshooting',
'Bridge.cfg',
'sidecar.toml',
'Shard event catalog',
],
};

View File

@@ -16,7 +16,7 @@ Two places hold configuration, and the line between them is not arbitrary.
The rule behind the split: **anything that needs a restart or a shell is environment;
anything an administrator should be able to change without either is in the panel.** That
is why the Discord bot token, the OAuth client secrets and the shard's auth token are *not*
is why the Discord bot token, the OAuth client secrets and the game module's sidecar token are *not*
environment variables — they are entered in the panel and stored encrypted.
## Settings

View File

@@ -27,7 +27,7 @@ glance.
<Aside type="caution" title="Publishing a news post announces it">
Publishing is what triggers the announcement pipeline — the Discord `#news` leg, and any leg
an installed module adds, such as the `uo` module's in-game town crier. It fires on
an installed module adds, such as the Ultima Online module's in-game town crier. It fires on
publication, so an accidental publish is an accidental announcement. See
[Notifications and email](/docs/administration/notifications-and-email/).
</Aside>

View File

@@ -168,8 +168,8 @@ which is where a person's record belongs.
A module declares its own triggers and its own audiences, in its own vocabulary, and it may
ship rules and message bodies to go with them. The Ultima Online module ships a large family
of them — houses falling to ruin, vendors running out of gold, a governor being seated, a
guild's fortunes — written in the voice of an in-world office rather than a system alert.
Team's fortunes — written in the voice of an in-world office rather than a system alert.
All of them arrive **disabled**, like every other seeded rule. Read the list in
**Admin → Engagement → Triggers**, turn on the ones your shard should send, and check the
**Admin → Engagement → Triggers**, turn on the ones your site should send, and check the
send log the first time each one fires.

View File

@@ -4,6 +4,7 @@ description: Author an event as phases and steps, price it against this deployme
---
import { Aside } from '@astrojs/starlight/components';
import GameExample from '../../../../components/GameExample.astro';
An **event** is a scheduled, bounded, audited change to a live game world. You write it once
as a sequence of phases, publish a version of it, put it on the calendar, and it runs — at
@@ -71,8 +72,13 @@ start doing things to your world.
Each row has two controls: whether the action is permitted on this deployment at all, and its
**per-run caps** — how much of a budget dimension one run may consume. Dimensions are
declared by the module (`uo.creatures`, `uo.bosses`, `uo.rewards` and so on), and consumption
is counted in the database with a conditional update, not checked in application code.
declared by the module, and consumption is counted in the database with a conditional
update, not checked in application code.
<GameExample game="uo">
The Ultima Online module declares `uo.creatures`, `uo.bosses`, `uo.rewards` and others —
one per kind of thing an event can spend.
</GameExample>
That distinction matters more than it sounds. A stolen admin session has already passed every
role check there is; it still cannot exceed the cap, because the cap is a condition on the
@@ -135,18 +141,23 @@ restart puts every borrowed value back too.
## The game server has its own switches
They live on the shard host, outside the site's reach, and the site cannot turn them on.
They live in the bridge plugin's config on the game server's host, outside the site's reach,
and the site cannot turn them on.
**`EventsEnabled` is off by default, and it is a different switch from `AdminWriteEnabled`.**
Turning the admin plane on is consenting to staff moderation driven from a screen somebody is
looking at. Turning this on is consenting to the site changing and watching your world
unattended. One switch could not honestly express both.
**`EventsEnabled` is off by default**, in both games. Turning it on is consenting to the site
changing and watching your world unattended.
Beside it sit the game's own ceilings — how many creatures one call may spawn, how long a gate
may stand, how much one run may own in total, how often the world may be saved. **They refuse
rather than clamp**, for the same reason the caps do: a quietly shortened request leaves the
two halves disagreeing about what actually happened. See
[Bridge.cfg](/docs/reference/bridge-cfg/) for every key.
<GameExample game="uo">
`EventsEnabled` is a different switch from `AdminWriteEnabled`. Turning the admin plane on is
consenting to staff moderation driven from a screen somebody is looking at; turning events on
is consenting to unattended changes. One switch could not honestly express both. Every key
is in [`Bridge.cfg`](/docs/ultima-online/bridge-cfg/).
</GameExample>
Beside it sit the game's own ceilings — how much one call may place, how long something it
creates may stand, how much one run may own in total. **They refuse rather than clamp**, for
the same reason the caps do: a quietly shortened request leaves the two halves disagreeing
about what actually happened.
## What players see

View File

@@ -102,7 +102,7 @@ Three rules for whatever proxy you use:
- **Terminate TLS at the proxy.** The app speaks HTTP; it is not meant to hold a
certificate.
## Upgrading the shard side
## Upgrading the game server side
A different deployment on a different host, and it moves on its own schedule:
@@ -111,22 +111,18 @@ sudo runicgateway update # re-resolves the bundle; --verify to see it first
sudo runicgateway doctor # confirm afterwards
```
`update` replaces the sidecar and restarts its service, re-syncs the overlay, and tells you
when ServUO needs restarting — it never restarts your shard itself. Because it resolves a
**bundle**, the sidecar and the plugin move together and cannot end up disagreeing about the
protocol.
`update` replaces the sidecar and restarts its service, redeploys the bridge plugin, and
tells you when the game server needs restarting — it never restarts your game server itself.
Because it resolves a **bundle**, the sidecar and the plugin move together and cannot end up
disagreeing about the protocol. Add the game's own flags as you did at install; see the
[Installer CLI](/docs/reference/installer-cli/).
<Aside type="caution" title="After you patch the UO client, press one more button">
Creature portraits, item pictures and the name table are read from that client, and the
site deliberately never re-reads them on its own — a restart does not, and neither does
`update`. They keep serving the old artwork until somebody presses *Update* on
**Admin → Client files**. It is one round trip when nothing has changed.
</Aside>
Each game can have its own day-two chores on top of this. Ultima Online's are in [its
group](/docs/ultima-online/client-files/#after-a-client-update).
<Aside type="note" title="Update the two sides in either order, but verify after each">
They are independent deployments joined by a version-checked contract: a mismatch is
rejected with a `409` rather than mis-parsed. So the worst case is a bridge that refuses to
pair until both sides are current — visible on
[the shard connection screen](/docs/administration/the-shard-connection/), and not silent
corruption.
pair until both sides are current — visible on the game module's connection screen, and
not silent corruption.
</Aside>

View File

@@ -7,7 +7,7 @@ import { Aside } from '@astrojs/starlight/components';
Every message your site sends — password resets, invitations, notifications — is a
**template** you can edit. They ship working, so a fresh site mails correctly before you
open this screen at all. You come here when you want it to sound like your shard.
open this screen at all. You come here when you want it to sound like your community.
**Admin → Engagement → Templates.**
@@ -105,7 +105,7 @@ Test sends are recorded in the send log like any other message, including when t
You do not start from a blank page. Pick a message that is close to what you want, press
**Duplicate**, and give the copy a key.
The **key** is how a rule refers to the template — `notify.house-idoc`, say. Lowercase
The **key** is how a rule refers to the template — `notify.team-welcome`, say. Lowercase
letters, digits, dots and dashes, and it cannot be changed later, so pick one that will
still make sense in a year.

View File

@@ -10,7 +10,7 @@ not overlap.
| Screen | Is about | Comes from |
|---|---|---|
| **Moderation** | Your **Discord** guild — bans, kicks, mutes, warnings, joins, leaves, filter and spam hits | the bot, captured live |
| **Moderation** | Your **Discord** server — bans, kicks, mutes, warnings, joins, leaves, filter and spam hits | the bot, captured live |
| **Reports** | **Team forum content** members have reported | the site |
| **Appeals** | Sanctions people are asking you to reverse | the site |

View File

@@ -82,7 +82,7 @@ at the wrong host.
Which legs exist depends on what has registered one:
- **Discord `#news`** is core's, and needs the bot below.
- **A module may add its own.** The `uo` module adds an in-game town crier, so a news post
- **A module may add its own.** The Ultima Online module adds an in-game town crier, so a news post
is announced to players who are logged into the game and never visit the site.
A leg brings its own settings with it — the town crier's duration is a module setting, not
@@ -90,7 +90,7 @@ a core one — which is why they are documented with the module rather than here
## The Discord bot
**Admin → Discord Bot**: enable it, give it the guild (server) ID and the bot token, and
**Admin → Discord Bot**: enable it, give it the **Guild (server) ID** and the bot token, and
save. The token is stored **encrypted in the database** and is never an environment
variable.

View File

@@ -9,8 +9,8 @@ Teams are a core platform primitive: membership, roles, forums, notifications, m
and the Discord integrations are all core's, and none of it knows what a Team *is* in your
game.
**Core cannot create a Team.** Teams arrive from the installed module — with the `uo`
module, they are the shard's guilds. On a deployment with no module, the Team machinery is
**Core cannot create a Team.** Teams arrive from the installed game module, which maps
whatever the game calls its groups of players onto a Team. On a deployment with no module, the Team machinery is
present and permanently empty. That is not a bug to work around; it is the contract that
lets the same forum, notification and moderation code serve any game.
@@ -25,15 +25,14 @@ reconciles them.
**Admin → Teams** shows a sync panel per module: last attempt, last success, consecutive
failures and the last error, with **Sync now** and **Resync now**.
The wording on that panel is exact and worth reading:
> Core has never had an answer it could trust. What is shown below is not a confirmed empty
> shard.
The wording on that panel is exact and worth reading. Until a sync has succeeded, it says
that core has never had an answer it could trust, and that the list below it is not a
confirmed empty one.
An empty list therefore means one of two very different things — there are no Teams, or
nobody could ask. The panel tells you which, and a *last success: never* with a *last
error* of `no uo-link configured` is the second. Fix
[the shard connection](/docs/administration/the-shard-connection/) and sync again.
nobody could ask. The panel tells you which: a *last success: never* with a *last error*
that names the game connection is the second. Fix the game module's connection to its game
server and sync again. Each game's group in the sidebar covers its own connection screen.
## Forums
@@ -105,8 +104,8 @@ event leaves the site.
**Voice channels** — gives each Team a Discord voice channel of its own, with access
granted by a per-Team role, so a Team's members can see and join theirs and nobody else
can. It needs the bot reachable, and members need a linked Discord account and guild
membership.
can. It needs the bot reachable, and members need a linked Discord account and membership of
your Discord server.
Its three settings deserve a thought each:
@@ -114,10 +113,10 @@ Its three settings deserve a thought each:
|---|---|
| **Minimum members** | How large a Team must be to get a channel. Every active member counts, linked account or not. |
| **Grace window (days)** | How long a Team keeps its channel after it stops qualifying. A Team that recovers inside the window keeps the same channel; zero removes it on the next pass. |
| **Staff roles** | Roles that can see and join every Team's channel. Guild administrators already can, so this is for staff who are not administrators. |
| **Staff roles** | Roles that can see and join every Team's channel. Discord server administrators already can, so this is for staff who are not administrators. |
<Aside type="caution" title="Voice channels are a per-guild ceiling, not a per-Team one">
Discord's role and channel limits apply to the whole guild, so a site with many small Teams
<Aside type="caution" title="Voice channels are a per-server ceiling, not a per-Team one">
Discord's role and channel limits apply to the whole Discord server, so a site with many small Teams
can exhaust them. The minimum-members setting is the lever that keeps the count sane, and
it is easier to raise it before provisioning than to unpick channels afterwards.
</Aside>

View File

@@ -67,50 +67,30 @@ deployment has nothing supervising it — `npm start` in a terminal, a container
## The game screens are empty or say offline
Work outwards from the game, and stop at the first check that fails.
Work outwards from the game, and stop at the first check that fails. The order is the same
for every game; the commands are each game's own.
1. **In game:** `[bridge status` — `connected=False` means the shard cannot reach the
sidecar.
2. **On the shard host:** `curl -s http://127.0.0.1:8080/health` — `plugin_connected: true`
is the value that matters.
3. **On the shard host:** `runicgateway doctor` — checks the install record, every overlay
file hash, the service, and that the sidecar and overlay agree on a protocol.
4. **On the site:** the [shard connection screen](/docs/administration/the-shard-connection/)
— its four indicators say which link is broken.
1. **On the game server:** the bridge plugin says whether it is connected to the sidecar.
2. **On the game server's host:** the sidecar's `/health` answers without a token, and
*plugin connected* is the value that matters.
3. **On the game server's host:** `runicgateway doctor` checks the install record, the
files it deployed, the service, and that the sidecar and the bridge plugin agree on a
protocol.
4. **On the site:** the game module's connection screen says which link is broken.
Two log lines with specific meanings: **`409`** is a protocol mismatch (set the Protocol
field to what `/health` reports), and **`401`** is the auth token (read the live one back
with `uo-link-sidecar --print-config`; do not retype it from a screenshot).
Two responses from a sidecar mean the same thing for every game: **`409`** is a protocol
mismatch (the sidecar refuses rather than mis-parsing), and **`401`** is the auth token.
<Aside type="note" title="“Nothing changed and it stopped working” usually means a ServUO update">
An update to the server tree can revert `Scripts.csproj`, at which point the plugin sits in
the tree and never compiles — and ServUO ignores the script build's exit code, so the boot
looks clean. `doctor` catches it by comparing file hashes against the install record.
</Aside>
## The bestiary has no pictures, or items show numbers
Those come out of the UO client on the shard host, and **nothing imports them on a
restart** — a button on **Admin → Client files** is the only thing that does. Check that
page first: it reports why rather than failing.
| What it says | What to do |
|---|---|
| Counts are zero and no import is recorded | Press *Update*. On a shard that was linked before this existed, nobody ever has. |
| *…set `AssetsEnabled` on the shard* | The asset plane is off in `Bridge.cfg`. It is a separate switch on purpose. |
| *The shard host cannot render images* | A Linux host with no `libgdiplus`. Install it and press *Update* again. Names are unaffected either way. |
| *The shard is busy with another client-file request* | Not a fault. Something ordinary holds the slot; it frees itself. |
| Pictures were fine and went blank | Check the uploads volume before anything else — the database still remembers pictures that are no longer on disk, and *Re-import everything* is the button for exactly that. |
Items reading as numbers rather than names is the same page, different section: it means the
cliloc table has not been imported. See [Client files](/docs/administration/client-files/).
Each game's group in the sidebar has its own troubleshooting page with the exact commands:
[Ultima Online](/docs/ultima-online/troubleshooting/) today.
## Teams are missing
Check the sync panel on **Admin → Teams** before anything else: *last success: never* with
`no uo-link configured` means the shard connection, not the Team machinery. And on a site
with **no module installed**, an empty Team list is correct and final — core cannot create
a Team. See [Teams](/docs/administration/teams/).
an error naming the game connection means the module could not ask its game server, not
that the Team machinery is broken. And on a site with **no module installed**, an empty
Team list is correct and final — core cannot create a Team. See
[Teams](/docs/administration/teams/).
## Email and announcements never arrive

View File

@@ -97,8 +97,8 @@ A module that serves game data brings its **own** audience rules, and core does
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
The Ultima Online module'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.

View File

@@ -114,8 +114,9 @@ something must not take that path.
are re-validated against the database on every request, so a demotion takes effect on the
next click.
The game's switches are the layer the site cannot reach: `EventsEnabled` and
`AdminWriteEnabled` live in a file on the shard host and are off out of the box, and the
The game's switches are the layer the site cannot reach: they live in the bridge plugin's
own config on the game server's host — `EventsEnabled`, in both games — and are off out of
the box, and the
game's own ceilings **refuse rather than clamp** — because a silently shortened request leaves
the two halves disagreeing about what happened.
@@ -135,7 +136,7 @@ That is why the caps and the leases matter more here than the role check does.
same versioned wire the game already speaks, through the same sidecar. Core still holds no
game connection.
- **[Teams](/docs/architecture/teams-architecture/)** — "this Team's members" is already a
registered audience, so a guild-scoped event needs no event-side feature at all.
registered audience, so an event scoped to one Team needs no event-side feature at all.
- **News** — an event does not write posts. A core action links an *existing* post to a run and
enqueues it through the announcement pipeline, so the in-game town crier and Discord arrive
as legs that already exist.

View File

@@ -1,34 +1,47 @@
---
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.
description: One number per game, declared in three places, that decides whether a game server and a sidecar are allowed to talk to each other.
---
import { Aside } from '@astrojs/starlight/components';
import GameExample from '../../../../components/GameExample.astro';
import platform from '../../../../data/platform.json';
The loopback wire protocol between the game plugin and the sidecar is a **versioned
The loopback wire protocol between the bridge 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.
Each game has its own protocol, numbered independently: {platform.games.uo.name} is on
**{platform.games.uo.protocol}** and {platform.games.rust.name} on
**{platform.games.rust.protocol}**. The two numbers are not comparable — they version two
different wires. The examples below are the {platform.games.uo.name} bridge's; the
{platform.games.rust.name} bridge declares its number in the same three kinds of place.
different wires. Everything below holds for both.
## Three declaration sites
The same number is written down in three places, and they must move together.
The same number is written down in three places, and they must move together:
1. **The sidecar's source** declares `PROTOCOL_VERSION` — what the sidecar speaks.
2. **The bridge plugin's release manifest** declares `protocol` — what the plugin speaks.
3. **The bundle manifest** copies the plugin's number in CI, so a released pair carries its
own claim.
<GameExample game="uo">
| Where | What declares it |
|---|---|
| `link/sidecar/src/main.rs` | `PROTOCOL_VERSION`, currently {platform.games.uo.protocol} — what the sidecar speaks |
| `servuo-plugins/overlay.toml` | `protocol`, currently {platform.games.uo.protocol} — what the plugin overlay speaks |
| The bundle manifest | Copied from `overlay.toml` by CI, so a released pair carries its own claim |
| `link` `sidecar/src/main.rs` | `PROTOCOL_VERSION`, currently {platform.games.uo.protocol} |
| `servuo-plugins` `overlay.toml` | `protocol`, currently {platform.games.uo.protocol} |
</GameExample>
<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**.
<GameExample game="rust">
| Where | What declares it |
|---|---|
| `Rust-Link` `sidecar/src/main.rs` | `PROTOCOL_VERSION`, currently {platform.games.rust.protocol} |
| `Rust-Plugins` `overlay.toml` | `protocol`, currently {platform.games.rust.protocol} |
</GameExample>
<Aside type="caution" title="Bump the plugin's manifest in the same PR as the emitters">
CI folds the plugin's manifest into the release manifest, and **the installer refuses to
pair a bridge plugin 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.
@@ -38,19 +51,28 @@ compose into a bundle, and the next release simply does not appear.
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.
**Sidecar ↔ website.** Every sidecar response carries its protocol version in a header. A
mismatch is rejected with **`409`** rather than mis-parsed, and the game module's admin
screen is where an administrator sees it.
**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
**Bridge plugin ↔ sidecar.** The installer resolves a **bundle** — an exact,
protocol-checked pair of sidecar and bridge plugin 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. The most recent
bump — **8**, which taught the bridge to carry a game's own client files — touched four
Changing a message shape means editing every side plus the specification: the bridge plugin,
the sidecar, the game module, and the protocol document in `docs`.
**`website` is usually not on that list, and its absence is the interesting part.** Core
holds no game connection and names no game noun, so most protocol bumps do not reach it at
all. One reaches core only when what changes is the shape of something core owns — a ledger,
a place to store a picture — rather than a game noun.
<GameExample game="uo">
Protocol 8 taught the bridge to carry a game's own client files, and touched four
repositories:
| Repository | What had to change |
@@ -60,24 +82,23 @@ repositories:
| `module-uo` | The importers, the admin screen, and the pages that render a picture |
| `docs` | The protocol document and the integration guide |
**`website` is not on that list, and its absence is the interesting part.** Core holds no
game connection and names no game noun, so most protocol bumps do not reach it at all. The
one before this did, because what changed then was not a game *noun* but the shape of a
thing core owns the ledger for. This one did not reach core because everything it needed —
somewhere to put a picture — core already offered every module. Its entire share of eight
phases of work was a **deletion**: a developer tool it no longer needed.
Core's entire share of eight phases of work was a **deletion**: a developer tool it no longer
needed. Everything the bump needed — somewhere to put a picture — core already offered every
module.
</GameExample>
**A protocol bump can also require a store migration**, because the sidecar persists what it
forwards. That is not automatic, and it has happened once: version 4 added a column to a
table that already existed. Versions 5, 6, 7 and 8 needed none, because every frame is
persisted whole — a bump that only widens a frame, or adds a kind, or adds a guarantee about
how a command is executed, asks nothing of a store that defines no schema for a frame's
contents. That is the dumb-forwarder property paying for itself.
forwards. That is not automatic, and it is rare: every frame is persisted whole, so a bump
that only widens a frame, or adds a kind, or adds a guarantee about how a command is
executed, asks nothing of a store that defines no schema for a frame's contents. That is the
dumb-forwarder property paying for itself.
Version 8 puts it more sharply still. It is the largest bump this protocol has had, and it
moves megabytes of artwork rather than events — and it changed **no line** of the sidecar's
store, because the things it carries are answers to requests rather than events to keep. A
forwarder that holds no opinion about what it forwards has nothing to migrate.
<GameExample game="uo">
It has happened once: version 4 added a column to a table that already existed. Versions 5
to 8 needed none. Version 8 is the largest bump the protocol has had, and it moves megabytes
of artwork rather than events — and it changed **no line** of the sidecar's store, because
the things it carries are answers to requests rather than events to keep.
</GameExample>
## This is not the module API version
@@ -85,7 +106,7 @@ Two different numbers, versioning two different contracts, and confusing them is
| | Versions | Lives in | Checked |
|---|---|---|---|
| **`PROTOCOL_VERSION`** | The game ↔ sidecar wire | `link`, `servuo-plugins`, the bundle | `X-UOLink-Version`, and the installer's pairing check |
| **`PROTOCOL_VERSION`** | The game ↔ sidecar wire, one per game | The sidecar, the bridge plugin, the bundle | The sidecar's version header, 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
@@ -113,11 +134,11 @@ What is worth inheriting is the **shape**:
## Canonical documents
Each game's protocol has its own record in the
[`docs` repository](https://gitea.whitlocktech.com/RunicGateway/docs). For Ultima Online,
[`link/v8.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v8.md)
is the current protocol's record, including its cross-repository obligations, and
[`link/v7.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v7.md)
the one before it;
[`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.
§7 is the wire protocol. For Rust,
[the Rust bridge protocol](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/rust-link/PROTOCOL.md)
is both.

View File

@@ -4,28 +4,49 @@ description: The whole platform in one place — what each repository is, what t
---
import { Aside } from '@astrojs/starlight/components';
import GameExample from '../../../../components/GameExample.astro';
The drawn version of this, for evaluators, is on
[Architecture](/architecture/). This page is the detailed account.
## Ten repositories, deployed independently
## Fourteen 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.
Seven of them are core, and serve every game:
| 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 |
| `installer` | Deploys a game server's side: the bridge plugin, and the sidecar with its service |
| `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 |
| `docs` | Canonical design docs and the protocol specs |
| `runicgateway.com` | This site |
| `.profile` | The organisation landing page |
The other seven belong to a game. Each game has the same three: **a game module** that adds
its screens to the site, **a sidecar** that is the only network-facing part of its bridge,
and **a bridge plugin** that runs inside the game server and feeds the sidecar.
<GameExample game="uo">
| Repository | Its part |
|---|---|
| `Module-uo` | The game module |
| `link` | The sidecar, uo-link, in Rust |
| `servuo-plugins` | The bridge plugin, a C# overlay ServUO compiles at boot |
</GameExample>
<GameExample game="rust">
| Repository | Its part |
|---|---|
| `Module-Rust` | The game module |
| `Rust-Link` | The sidecar, rust-link, in Rust |
| `Rust-Plugins` | The bridge plugin, for Oxide and Carbon |
| `runicnpc-rust` | RunicNPC, an NPC plugin the bridge drives, which also works on its own |
</GameExample>
## The layers
```
@@ -59,9 +80,8 @@ particular game. Routes, tables, pages, navigation and push streams for a game a
[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.
That is why the architecture below describes a game module as something core *loads* rather
than as part of the platform. There are two, and core depends on neither.
## The invariants
@@ -69,29 +89,29 @@ 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.
The game server's bridge plugin **dials out** to the sidecar over loopback TCP,
newline-delimited JSON, on `127.0.0.1`. 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.
In the bridge plugin, `Emit()` enqueues onto a **bounded, drop-oldest** queue and returns
immediately. It never touches the socket from the game's main thread. Every world read
happens on the main 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.
site still renders with the game 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
the client and reported as unavailable, so a wrong key degrades the game's screens instead of
500-ing it — and the admin config screen keeps working, which is the screen you need in order
to recover.
@@ -128,7 +148,7 @@ variables](/docs/reference/environment-variables/).
## 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
installer binary sets up the game server 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.

View File

@@ -1,12 +1,13 @@
---
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.
description: Teams is a contract, not a surface — how core owns every game's groups of players 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.
Most games have groups of players, and every game has its own word for them. Runic Gateway
supports them as a **core platform primitive**, the **Team**, while core itself never learns
what your game calls one.
The administrator's view is [Teams](/docs/administration/teams/).
@@ -25,7 +26,8 @@ fill** — `registry.declareModuleSlot(id, name, { core })`, with core offering
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
The first version had core's fills naming three of the first game module's slots
**literally**. It
worked for exactly one module and silently did nothing for any other game — an empty page
with nothing logged.

View File

@@ -15,11 +15,11 @@ 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 ──────┘
game server ──dials out──▶ sidecar ──HTTP + WS──▶ website
(bridge plugin) 127.0.0.1 bearer + version (game module)
newline JSON
▲ │
└────────── the game opens NO port ───────┘
```
Three properties fall out of that diagram, and each is a rule rather than an
@@ -27,7 +27,7 @@ implementation detail.
## 1. The game dials out
**The sidecar is the listener. The game connects to it.** The shard opens no port at all,
**The sidecar is the listener. The game connects to it.** The game server 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 —
@@ -41,9 +41,9 @@ loopback TCP.
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.
In the bridge plugin, `Emit()` **enqueues onto a bounded, drop-oldest queue and returns
immediately**. It never touches the socket from the game's main thread. Every world read
happens on the main 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
@@ -54,7 +54,7 @@ Design your own plugin the same way. The game thread must never block on I/O —
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
Inbound commands get the mirror rule: **every inbound handler marshals to the main thread
before touching world state.**
## 3. The sidecar persists before it forwards
@@ -77,66 +77,58 @@ than accumulating history.
**Point-in-time reads and commands** go over **REST**, through a client that never throws.
**Bulk reads** — a game's own client artwork, its string table, its spawn files — are the
newest and the least obvious. They go over the request/reply path in **pages**, with **one
**Bulk reads** — a game's own files, such as its client artwork or its string table — are
the newest and the least obvious, and so far only one game's bridge needs them. They go over the request/reply path in **pages**, with **one
request in flight at a time** and a hard cap on how large a single line may be.
<Aside type="note" title="Why bulk data must not ride the event stream">
It is the tempting shortcut, and it is wrong for a structural reason rather than a
performance one: the sidecar **persists every event and broadcasts it to every connected
client**. That is exactly what you want for "a house went IDOC" and exactly what you do not
client**. That is exactly what you want for "a player logged in" and exactly what you do not
want for hundreds of megabytes of artwork, which is an *answer to a question somebody
asked* rather than news.
Sending it as replies instead is what let the same bump move megabytes without the sidecar's
store changing by a line. The single slot is the other half: it is what keeps the queue
Sending it as replies instead is what lets a protocol bump move megabytes without the
sidecar's store changing by a line. The single slot is the other half: it is what keeps the queue
between the game and the writer thread shallow, so rule 2 above still holds while a
transfer is running.
</Aside>
Every call carries `Authorization: Bearer <token>` and an `X-UOLink-Version` header. **A
Every call carries `Authorization: Bearer <token>` and a protocol-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
## What a game server 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:
Each game has its own event catalog, because each game has its own things worth telling a
website about. Every catalog has the same structure, though: kinds the module declares,
each one either safe for the public stream or kept to the admin one.
```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 the [Shard event catalog](/docs/reference/event-catalog/).
- **Ultima Online:** [its event catalog](/docs/ultima-online/event-catalog/), in the Ultima Online group
- **Rust:** [the Rust bridge protocol](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/rust-link/PROTOCOL.md) in the `docs` repository
## 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 and had no idea which game server 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.
**Rosters are sets, not signatures.** A bridge that reports a Team's members should send
the membership as a set rather than fold it into a checksum, because a sum can collide: one
member joining and another leaving between two sweeps offset each other, and the Team 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. The Ultima Online bridge does exactly
this; its [event catalog](/docs/ultima-online/event-catalog/#rosters-are-sets-not-signatures)
has the details.
## Building one for another game
The bridge is not UO-specific in shape, only in vocabulary. Chapters 3 and 4 of [the
Both games' bridges have this shape; only their vocabulary differs. 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.

View File

@@ -1,139 +1,52 @@
---
title: Connect a game server
description: The installer binary on the shard host — what it deploys, what it asks, and the four values it prints for the website.
title: Connect your game server
description: The second install, on the machine that runs your game server — what every game's has in common, and where each game's own steps are.
---
import platform from '../../../../data/platform.json';
import { Aside, Steps } from '@astrojs/starlight/components';
This is the second of the two installs, and it happens on the machine that runs your game
server. One binary deploys the plugin, installs the sidecar, registers its service, and
prints four values for you to paste into the website.
server. It never contacts your website, and it never starts or stops your game server.
It never contacts your website, and it never starts or stops your shard.
Every game's version of this step has the same shape:
## What gets deployed
| # | Component | Where it goes |
|---|---|---|
| 1 | **The plugin overlay** — C# source ServUO compiles at boot | into your ServUO tree |
| 2 | **The uo-link sidecar** — a small Rust service | a system directory, plus a service |
| 3 | **A record of the run** | `install.json`, with per-file hashes and backups |
1. **The bridge plugin** goes onto the game server, where the game's own plugin system loads
it.
2. **The sidecar** goes onto the same host, as a service. It is the only part of the bridge
that listens on a network.
3. **You paste what the install prints into the site** — the sidecar's address and its auth
token, plus whatever else that game's screen asks for — on the game module's admin screen.
```
ServUO shard ──loopback TCP 127.0.0.1:7788──► uo-link sidecar ──HTTP + WebSocket──► website
game server ──loopback, dials out──► sidecar ──HTTP + WebSocket──► website
```
The shard **dials out**. It never listens for the website and is never reachable from the
internet; only the sidecar is exposed, and only to your site.
The game server **dials out** to the sidecar over loopback. It never listens for the website
and is never reachable from the internet; only the sidecar is exposed, and only to your
site.
## Install
The [installer](/docs/reference/installer-cli/) ({platform.releases.installer}) does all of
this for both games, told which one it is setting up. It installs a **bundle** — an exact,
protocol-checked pair of sidecar and bridge plugin — never "the latest of each".
<Steps>
## Pick your game
1. **Download the binary for your OS, and `SHA256SUMS`**, from the
[installer releases page](https://gitea.whitlocktech.com/RunicGateway/installer/releases)
({platform.releases.installer}).
Releases are **unsigned** — there is no code-signing certificate, so that checksum file
is the whole trust anchor. Check it:
```bash
sha256sum -c SHA256SUMS --ignore-missing
chmod +x runicgateway-installer-linux-x86_64
```
On Windows, `(Get-FileHash .\runicgateway-installer-windows-x86_64.exe -Algorithm SHA256).Hash`
and compare. Windows will also show a SmartScreen prompt on first run, for the same
reason.
2. **Stop the shard.** `ServUO.exe` locks `Scripts.dll` and rewrites `Saves/` on exit, so
the installer refuses to deploy under a running server.
3. **Run it, elevated.**
```bash
sudo ./runicgateway-installer-linux-x86_64 install
```
Add `--verify` first if you want to see every change it would make and write nothing.
It asks four things: your ServUO root, whether to apply the optional patch tier, the
hostname your website should use to reach this machine, and your site's URL (used only
to print a link at the end).
4. **Read the summary.** It reports the overlay sync file by file, the sidecar binary and
its verified hash, the config and database paths, and the service state. Then it says
what you must do next — restart ServUO yourself, because it will not do that for you.
</Steps>
<Aside type="note" title="It installs a bundle, not “latest of each”">
The three components version independently but must agree on one wire protocol, so what it
resolves is a **bundle**: an exact, protocol-checked pair of sidecar and overlay versions
({platform.games.uo.bundle.tag} today — sidecar {platform.games.uo.bundle.sidecar}, overlay {platform.games.uo.bundle.overlay}).
`--bundle <tag>` pins an exact past combination, so a reinstall in six months reproduces
today's install rather than tomorrow's.
</Aside>
## The patch tier is optional
Most of the plugin is *added* files, which is why the base install is a safe copy. Two
features need edits to stock ServUO sources, and those are opt-in, off unless you say yes,
and refused where the target lines are not stock. Skipping the tier costs you vendor-sale
events and in-game moderation audit forwarding; everything else works.
The tier is written and tested against stock ServUO {platform.games.uo.bundle.servuoMin}. On any
other version it is unsupported and untested, and the prompt makes you answer past a
warning.
## Paste the four values into the site
A successful run ends by printing the one step it cannot do for you:
```
Base URL http://shard.example.com:8080
WebSocket URL ws://shard.example.com:8080/ws
Protocol version 4
Auth token 4f9c… (also in sidecar.toml)
```
Every value comes from asking the installed sidecar itself, so it cannot drift from what
the service actually runs.
On the site, sign in as an administrator and open **Shard (uo-link)** in the admin
sidebar — `/admin/uo/link`. Tick *Enable the shard integration*, paste **Base URL**,
**WebSocket URL**, **Auth token** and **Protocol**, and save. The ingest client restarts
immediately.
<Aside type="caution" title="Installer v0.1.0 prints an older path for that screen">
v0.1.0 prints `…/admin/shard`. Since the shard screens became part of the `uo` module — and
a module owns one path segment wherever it appears — the screen moved to
**`/admin/uo/link`**.
The old path does not fail visibly: the site has no route for it, so it sends you to the
dashboard, and that looks like the link worked. The four values you were just told to paste
then have nowhere to go. Use the sidebar, or the path above.
Fixed in **v0.1.1**, which prints the real path. Only matters if you are running the older
binary.
</Aside>
The token is encrypted at rest and **never returned to any client** — losing it means
reading it back from `sidecar.toml` on the shard host, not from the website.
| Game | Its steps |
|---|---|
| **{platform.games.uo.name}** | [Connecting the server](/docs/ultima-online/connect-a-servuo-shard/), then [verifying its bridge](/docs/ultima-online/verify-the-bridge/), in the Ultima Online group |
| **{platform.games.rust.name}** | [The Rust bridge install guide](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/rust-link/INSTALL.md) in the `docs` repository: the installer, a Pterodactyl egg, or by hand |
## If the website is on a different machine
The sidecar binds `127.0.0.1:8080`, reachable only from the shard host. If the site runs
elsewhere, widen the bind and then narrow the access:
The sidecar listens on `127.0.0.1` by default, reachable only from the game server's host.
If the site runs elsewhere, widen the sidecar's web bind and then narrow the access:
1. Set `[web] bind` in `sidecar.toml` to `0.0.0.0:8080` and restart the service.
2. **Firewall that port to your website's address only.** The auth token is always on, but
1. **Firewall that port to your website's address only.** The auth token is always on, but
it travels as a plain bearer token — the sidecar speaks HTTP, not HTTPS.
3. If the two hosts are not on a trusted network, put the sidecar behind a TLS reverse
2. If the two hosts are not on a trusted network, put the sidecar behind a TLS reverse
proxy or a VPN link and give the website the `https://` / `wss://` URLs.
Leave `[shard] bind` on `127.0.0.1:7788`. That socket accepts *inbound commands to the
**Leave the sidecar's game-facing socket on loopback.** It accepts *inbound commands to the
game*, and being loopback-only is what makes that safe.
Next: [Verify the whole stack](/docs/getting-started/verify-the-whole-stack/) — because a

View File

@@ -6,12 +6,19 @@ description: Everything game-specific is a module. Installing one, what it adds,
import platform from '../../../../data/platform.json';
import { Aside, Steps } from '@astrojs/starlight/components';
Core knows nothing about any game. Every game-specific screen — shard status, the map
atlas, the player marketplace, character sheets — comes from a **module**, a directory on a
mounted volume that the server loads at start.
Core knows nothing about any game. Every game-specific screen — a game server's live
status, its map, its leaderboards, its players' characters — comes from a **game module**, a
directory on a mounted volume that the server loads at start.
Today there is one: **`uo`**, for ServUO shards, published as
[`Module-uo`](https://gitea.whitlocktech.com/RunicGateway/Module-uo) ({platform.releases['Module-uo']}).
There are two, both built inside this project, and a site runs **one** of them:
| Game | Module | Latest release |
|---|---|---|
| **{platform.games.uo.name}** | [releases](https://gitea.whitlocktech.com/RunicGateway/Module-uo/releases) | {platform.releases['Module-uo']} |
| **{platform.games.rust.name}** | [releases](https://gitea.whitlocktech.com/RunicGateway/Module-Rust/releases) | {platform.releases['Module-Rust']} |
What each one adds is in its own group in the sidebar:
[Ultima Online](/docs/ultima-online/) today.
## Install it
@@ -22,9 +29,8 @@ Today there is one: **`uo`**, for ServUO shards, published as
2. **Paste the URL of a release's install manifest** into *Release install-manifest URL*
and press **Install**.
For the current `uo` release that is the `module-uo-<version>.json` asset on
[its releases page](https://gitea.whitlocktech.com/RunicGateway/Module-uo/releases).
The site downloads the bundle, checks it against the `sha256` the manifest declares, and
Every module release carries one: a `.json` asset named after the module and its
version, on the releases page linked above. The site downloads the bundle, checks it against the `sha256` the manifest declares, and
unpacks it onto the modules volume.
There is no catalog to browse, deliberately: a catalog would make core's release cadence
@@ -50,28 +56,21 @@ must be in the allowlist at the bottom of the same screen — re-checked on ever
It is seeded with `gitea.whitlocktech.com`, and an empty list forbids every install.
</Aside>
## What the `uo` module adds
## What a module adds
Watch the log at the restart and you will see exactly what it mounted:
Watch the log at the restart and you will see exactly what it mounted: its routes, its
database schema, and the line that says it started. Its screens appear in the navigation,
and its admin screens in the admin sidebar.
```
[uo] registered routes: public:/shard,/atlas admin:/shard,/uo-link player:/shard
[modules] schema ensured for module "uo"
[modules] module "uo" started
```
Its capabilities are {platform.games.uo.module.capabilities.join(', ')} — the shard console, the
map atlas, the player-vendor marketplace, city governors, guilds, houses and IDOCs, champion
boards, and the cliloc strings that make item names readable.
A module owns **one path segment** wherever it appears, so its pages live under `/uo/…`,
`/admin/uo/…` and `/player/uo/…`. That boundary is visible in the URL on purpose.
A module owns **one path segment** wherever it appears, so a module with the id `<id>` has
its pages under `/<id>/…`, `/admin/<id>/…` and `/player/<id>/…`. That boundary is visible
in the URL on purpose.
<Aside type="caution" title="A module with no game server behind it is empty, not broken">
Installing `uo` does not connect anything. Its screens exist and report the shard as
Installing a module does not connect anything. Its screens exist and report the game as
offline until you
[connect a game server](/docs/getting-started/connect-a-game-server/) — which is the same
thing the public site does when the shard goes down, and is designed to be unremarkable.
[connect your game server](/docs/getting-started/connect-a-game-server/) — which is the same
thing the public site does when the game goes down, and is designed to be unremarkable.
</Aside>
## The declarative alternative

View File

@@ -93,7 +93,7 @@ it down. Docker Compose is the supervisor here, and this line is what makes it o
## Two variables worth reading twice
**`SECRET_ENC_KEY`** encrypts secrets at rest — OAuth client secrets, the Discord bot
token, the shard's auth token. In production the server **refuses to start** without it.
token, the game module's sidecar token. In production the server **refuses to start** without it.
Changing it later 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.

View File

@@ -3,11 +3,10 @@ title: Requirements
description: What you need on the website host, and what you need on the game server host, before you begin.
---
import platform from '../../../../data/platform.json';
import { Aside } from '@astrojs/starlight/components';
Two hosts, two lists. They can be the same machine, but they are separate deployments and
have nothing in common except the four values you will paste between them.
have nothing in common except the few values you will paste between them.
## The website host
@@ -29,41 +28,29 @@ mounts and the reverse-proxy notes all read that way.
## The game server host
Only if you are connecting a game server. Today that means a ServUO shard, which is what
the [`uo` module](/docs/getting-started/install-a-game-module/) and the installer support.
Only if you are connecting a game server. Each game lists what its own host needs on its
connect page, because each game's server asks for different things. These hold for every
game:
| Requirement | Detail |
|---|---|
| **A working ServUO install** | It must currently boot and compile scripts cleanly. The installer deploys onto a healthy shard; it does not repair a broken one. |
| **ServUO {platform.games.uo.bundle.servuoMin}** *(patch tier only)* | The base install works on any reasonably current ServUO. The optional patch tier is written and tested against stock {platform.games.uo.bundle.servuoMin}; on any other version it is unsupported, and skipping it still leaves you with a working bridge. |
| **`libgdiplus`** *(Linux shard hosts only)* | Only needed for **artwork**. ServUO runs under Mono on Linux, and the library it decodes sprites with is a thin layer over this one — in the decode path, not merely the encode. Without it creature portraits and item pictures stay empty, and names and the spawn atlas are unaffected because neither touches a pixel. `sudo apt-get install libgdiplus`; `doctor` checks for it. **Windows hosts need nothing.** |
| **The shard stopped** | `ServUO.exe` locks `Scripts.dll` and rewrites `Saves/` on exit. The installer refuses to deploy under a running shard. |
| **Administrator / root** | It writes into system directories and registers a service. |
| **Outbound HTTPS** | To fetch the bundle and its two artifacts. No Gitea account and no git client are needed. |
| **The sidecar on the same host as the shard** | The shard connects to `127.0.0.1:7788`. Splitting them is not supported — that loopback socket *is* the trust boundary for inbound commands. |
| **A working game server** | It must boot and load plugins cleanly before you start. The installer deploys onto a healthy server; it does not repair a broken one. |
| **Administrator / root** | The installer writes into system directories and registers a service. |
| **Outbound HTTPS** | To fetch the bundle and its artifacts. No Gitea account and no git client are needed. |
| **The sidecar on the same host as the game server** | The bridge plugin dials the sidecar over loopback. That loopback socket *is* the trust boundary for inbound commands to the game, so the two are never split across machines. |
<Aside type="note" title="You do not need to install a game client for this">
You already have one. A ServUO shard cannot boot without a UO client — it resolves one at
startup to read the world's own data — so the artwork, the animations and the name table
the site shows are already sitting on that host. The shard reads them there and hands the
results over the bridge; nothing is converted on a desktop and nothing is uploaded. See
[Client files](/docs/administration/client-files/).
</Aside>
<Aside type="caution" title="Back up before the shard install">
The overlay overwrites `Scripts/Scripts.csproj`, a stock file, and the optional patch tier
edits stock sources. A copy of `Scripts/` and `Config/` costs nothing and is the difference
between an experiment and a gamble. The installer keeps its own backups too — see
[Connect a game server](/docs/getting-started/connect-a-game-server/).
</Aside>
Then the game's own list: [Ultima Online](/docs/ultima-online/connect-a-servuo-shard/#what-the-shard-host-needs),
or the *Before you begin* section of
[the Rust bridge install guide](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/rust-link/INSTALL.md#before-you-begin).
## What you do not need
- **A Gitea account.** Everything the installers fetch is a public release asset.
- **A build toolchain.** Not on either host. The site pulls images; the module arrives as a
verified tarball; the shard plugin is C# source that ServUO itself compiles at boot.
- **An inbound port on the game host** — for the *game*. The shard never listens for the
website. If the website runs on a different machine from the shard, the **sidecar** needs
to be reachable by the website, and that is the one hole you will open deliberately.
verified tarball; the bridge plugin is source that the game server compiles itself.
- **An inbound port on the game host** — for the *game*. The game server never listens for
the website. If the website runs on a different machine from the game server, the
**sidecar** needs to be reachable by the website, and that is the one hole you will open
deliberately.
Next: [Install the site](/docs/getting-started/install-the-site/).

View File

@@ -1,110 +1,54 @@
---
title: Verify the whole stack
description: Four checks, one per link in the chain, that distinguish "files copied" from "the bridge works".
description: Four checks, one per link in the chain, that distinguish "files copied" from "the bridge works" — the same four for every game.
---
import { Aside, Steps } from '@astrojs/starlight/components';
import { Aside } from '@astrojs/starlight/components';
A successful install is not a working bridge, and the failure is quiet in a specific way:
**ServUO shells out to `dotnet build`, prints the output, ignores the exit code, and
reloads the existing `Scripts.dll`.** A broken script build looks exactly like a clean boot.
A successful install is not a working bridge. Files can be in the right place and the game
server can boot cleanly while nothing reaches the site, and the site is *designed* to keep
working when the game is unreachable — so a broken bridge does not announce itself.
So verify each link in the chain, in order. Each check tells you which one to fix.
So verify each link in the chain, in order, starting at the game. Each check tells you which
link to fix.
<Steps>
| # | The link | What you check | What "working" means |
|---|---|---|---|
| 1 | **The bridge plugin loaded** | The game server's own console or log at boot | The plugin announces itself, with the sidecar address it will dial |
| 2 | **The game server reached the sidecar** | The bridge plugin's status command, on the game server | It reports itself connected, and is not dropping events |
| 3 | **The sidecar is healthy** | `curl -s http://127.0.0.1:<port>/health` on the game server's host. It needs no token | *plugin connected* is true. It is the one value in the whole sequence that distinguishes "files copied" from "the bridge works" |
| 4 | **The website is ingesting** | The game module's connection screen, under **Admin** | It reads connected, and live events start arriving within seconds |
1. **The plugin compiled — watch the boot output.**
The order is the same for every game. The commands and the screen names are not, so each
game has its own page with the exact steps:
Start your shard the way you always do. You want the build to succeed *and* the bridge
to announce itself:
- **Ultima Online:** [Verify the bridge](/docs/ultima-online/verify-the-bridge/)
- **Rust:** the *Connect the website* and *Day two* sections of
[the Rust bridge install guide](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/rust-link/INSTALL.md)
```
Core: Compiling scripts...
Build succeeded.
[Bridge] enabled=True endpoint=127.0.0.1:7788 queueCap=10000 …
```
## Two responses that mean the same for every game
If you scrolled past it, force the question:
```bash
dotnet build Scripts/Scripts.csproj -c Release -p:Platform=x64 # must be 0 errors
```
2. **The shard is connected — ask it in game.**
As an administrator:
```
[bridge status
```
It reports `connected=True depth=0 sent=… dropped=0`. `connected=False` means the shard
cannot reach the sidecar. `dropped` climbing means the sidecar is wedged and the shard
is shedding events rather than stalling — which is what it is designed to do, and why a
broken bridge never freezes your game.
`[bridge reload` re-reads `Bridge.cfg` without a restart; `[bridge sweepnow` forces one
pass of every stream.
3. **The sidecar is healthy — ask it over HTTP.**
`/health` needs no auth, so it is safe to curl on the shard host:
```bash
curl -s http://127.0.0.1:8080/health
```
```json
{"status":"ok","protocol":4,"plugin_connected":true,"database":"ok","uptime":"2m"}
```
**`plugin_connected: true` is the one that matters.** It is the only value in this whole
sequence that distinguishes "files copied" from "the bridge works".
4. **The website is ingesting — look at the shard screen.**
On the site, open **Shard (uo-link)** (`/admin/uo/link`). The header should read
**Connected**, with *Shard link: up* and *WS ingest: online*, and the live feed at the
bottom should start showing events within seconds rather than sitting on
*Waiting for shard events…*.
Then check the public side: the shard status page should stop reporting the game as
offline.
</Steps>
## When one of them fails
| What you see | What it means |
| What the site logs | What it means |
|---|---|
| Shard boots clean, nothing reaches the site | The classic silent failure — a stale `Scripts.dll`. Run the `dotnet build` line above and read the errors. |
| `[bridge` is not a command | The plugin did not compile, or the bridge is disabled in `Bridge.cfg`. |
| `connected=False` | The sidecar is not listening on `127.0.0.1:7788`. Check the service, and that `[shard] bind` matches `Host`/`Port` in `Bridge.cfg`. |
| `/health` is fine locally, the site says offline | The website cannot reach port 8080 — bind address or firewall. The site is *designed* to render normally with the shard down, so this fails quietly. |
| The site logs `409` from the sidecar | Protocol mismatch. Set the Protocol field to what `/health` reports rather than guessing; the sidecar rejects rather than mis-parsing. |
| `401` from the sidecar | Wrong or missing token. Read the live one back with `uo-link-sidecar --print-config`; do not retype it from a screenshot. |
| `409` from the sidecar | Protocol mismatch. The sidecar and the game module disagree on a wire protocol, and the sidecar refuses rather than mis-parsing. Set the protocol to what `/health` reports, or update whichever side is behind. |
| `401` from the sidecar | Wrong or missing token. Read the live one back on the game server's host rather than retyping it from a screenshot. |
<Aside type="note" title="`runicgateway doctor` answers most of this in one command">
Run on the shard host, it checks the install record, the ServUO tree, every overlay file
hash, the patch tier, the sidecar, its service, `/health`, that the sidecar and overlay
agree on a protocol, and — on Linux — that the host can decode an image at all. Its output is the first thing anyone helping you will ask for. It
exits non-zero when a check failed, so a monitoring system can run it too.
</Aside>
<Aside type="note" title="A bridge can be green and still show no artwork">
Creature portraits and item pictures are a separate switch and a separate import, so a
perfectly healthy bridge shows a bestiary of text until somebody presses *Update* on
**Admin → Client files**. That is the expected first-run state, not a fault — see [Client
files](/docs/administration/client-files/).
Run on the game server's host, it checks the install record, the files it deployed, the
sidecar, its service, `/health`, and that the sidecar and the bridge plugin agree on a
protocol. Its output is the first thing anyone helping you will ask for. It exits non-zero
when a check failed, so a monitoring system can run it too.
</Aside>
## What "working" looks like a week later
- The public shard page shows live status, and the admin dashboard shows events arriving.
- `dropped` in `[bridge status` stays at zero. A climbing number means the sidecar is
wedged, not that the shard is unhealthy.
- `doctor` is still green after a shard update — that is what catches an overlay file
reverted by hand or by a ServUO upgrade.
- The game module's public pages show live status, and the admin dashboard shows events
arriving.
- The bridge plugin is not dropping events. A climbing drop count means the sidecar is
wedged, not that the game server is unhealthy: the plugin sheds events rather than
stalling the game, which is what it is designed to do.
- `doctor` is still green after a game server update. That is what catches a deployed file
that an update put back to stock.
You have finished the installation path. From here,
[Administration](/docs/administration/configuration/) covers running the site day to day.

View File

@@ -8,7 +8,7 @@ import platform from '../../../data/platform.json';
Runic Gateway puts a private game server's live state on a public website without ever
exposing the game to the internet.
The shard dials **out** to a small sidecar over loopback; the sidecar is the only
The game server dials **out** to a small sidecar over loopback; the sidecar is the only
network-facing component, and only the website's backend is allowed to talk to it. The
website degrades gracefully when the game is down, and sensitive events never reach the
public event stream.
@@ -49,8 +49,8 @@ Worth stating before anything else, because it is a real trap: a Runic Gateway i
two independent deployments.
1. **The site** is a Docker deployment — the website, a database, and a game module.
2. **The shard side** is the installer binary, run on the game server's host. It sets up
the plugin overlay and the sidecar, and it never contacts the website.
2. **The game server side** is the installer binary, run on the game server's host. It sets
up the bridge plugin and the sidecar, and it never contacts the website.
They meet at the values the installer prints, pasted into the game module's connection
screen, and at the game's wire protocol, which both sides check before they will pair.
@@ -69,12 +69,15 @@ you should expect to see before you move on:
2. [Install the site](/docs/getting-started/install-the-site/) — Docker Compose, pull-only
3. [First run](/docs/getting-started/first-run/) — the first admin, and maintenance → live
4. [Install a game module](/docs/getting-started/install-a-game-module/) — what makes the game screens exist
5. [Connect a game server](/docs/getting-started/connect-a-game-server/) — the installer, on the shard host
5. [Connect your game server](/docs/getting-started/connect-a-game-server/) — the installer, on the game server's host
6. [Verify the whole stack](/docs/getting-started/verify-the-whole-stack/) — proving it works, rather than assuming
Then **Administration** covers running it: configuration, branding, content, users,
authentication, Teams, scheduled events, moderation, notifications, modules, the shard
connection, upgrades, and what to do when something is wrong.
authentication, Teams, scheduled events, moderation, notifications, modules, upgrades, and
what to do when something is wrong.
Everything that belongs to one game has its own group at the end of the sidebar, after
**Reference**: [Ultima Online](/docs/ultima-online/) today.
## Where the truth lives

View File

@@ -50,7 +50,7 @@ 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. That module has since been built for real, as
[`Module-Rust`](https://gitea.whitlocktech.com/RunicGateway/Module-Rust), so the dry run now
[the Rust module](https://gitea.whitlocktech.com/RunicGateway/Module-Rust), so the dry run now
reads as the plan beside a finished result.
## The template is built, not just quoted
@@ -58,7 +58,7 @@ reads as the plan beside a finished result.
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
Chapters 3 and 4 cite the Ultima Online sidecar and bridge plugin 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.
@@ -90,7 +90,7 @@ 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
the first game module'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

View File

@@ -5,6 +5,7 @@ description: Every key in module.json, what the loader does with each, and why a
import { Aside } from '@astrojs/starlight/components';
import platform from '../../../../data/platform.json';
import GameExample from '../../../../components/GameExample.astro';
`module.json` sits at the root of your bundle. The loader reads it synchronously, before
anything else about your module runs.
@@ -15,12 +16,15 @@ module that boots and mysteriously does half its job.
## A complete manifest
A real one, the Ultima Online module's, with the values that change every release elided:
<GameExample game="uo">
```json
{
"id": "uo",
"name": "Ultima Online",
"version": "1.0.0",
"coreApi": "^1.0.0",
"version": "…",
"coreApi": "…",
"server": "server/index.js",
"client": { "entry": "client/dist/entry.js" },
"schema": "server/db/schema.sql",
@@ -31,9 +35,10 @@ module that boots and mysteriously does half its job.
"player": ["/shard"]
},
"extensions": ["admin.users.detail"],
"capabilities": ["shard", "atlas", "market"]
"capabilities": ["shard", "atlas", "market", "…"]
}
```
</GameExample>
## The keys
@@ -64,9 +69,10 @@ register exactly.
`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.
That is why the manifest above declares `admin` prefixes that are not its admin screens'
URLs: the first is where its API routes live, the second is where the SPA puts its pages. Two
different mechanisms, and [the module system](/docs/modules/the-module-system/) explains why
the split is deliberate.
</Aside>
## `capabilities` is for feature detection
@@ -92,7 +98,7 @@ Use a caret range against the oldest core you actually support and test against.
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
`coreApi` versions the **website module contract**. `PROTOCOL_VERSION` versions **a game's
wire** and says nothing about a website module. See [Protocol
versions](/docs/architecture/protocol-versions/).
</Aside>

View File

@@ -4,9 +4,10 @@ description: What a module is, why the platform is built this way, and the one r
---
import { Aside } from '@astrojs/starlight/components';
import GameExample from '../../../../components/GameExample.astro';
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 —
site one game's site — that game's live status, its map, its leaderboards, its own 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
@@ -69,21 +70,26 @@ 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`:
**A module owns one path segment wherever it appears.** A module with the id `<id>` has its
public pages under `/<id>/…`, its admin pages under `/admin/<id>/…`, and its player pages
under `/player/<id>/…`.
**API routes are the exception, and keep their exact paths.** A surface that existed before
it moved into a module keeps the API path it always had. This is why the Android app and
the Discord bot needed no API changes at the cutover.
<GameExample game="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.
| The shard admin API — the exception | `/api/v1/admin/shard/*`, not `/api/v1/admin/uo/shard/*` |
</GameExample>
<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
The installer kept printing the first game's pre-module admin path well after the screen had
moved under the module's segment. 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.

View File

@@ -4,6 +4,7 @@ description: Where the normative specifications live — the documents that win
---
import { Aside } from '@astrojs/starlight/components';
import GameExample from '../../../../components/GameExample.astro';
import { canonicalDocs } from '../../../../data/reference.mjs';
Everything on this site is a **summary**. These are the documents it summarises, and where
@@ -19,10 +20,12 @@ If you find a disagreement, it is worth reporting — it means a check is missin
## The documents
The platform's own, which every deployment shares:
<table>
<thead><tr><th>Document</th><th>Answers</th></tr></thead>
<tbody>
{Object.entries(canonicalDocs).map(([docPath, why]) => (
{Object.entries(canonicalDocs.core).map(([docPath, why]) => (
<tr key={docPath}>
<td>
<a href={`https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/${docPath}`}>
@@ -35,6 +38,44 @@ If you find a disagreement, it is worth reporting — it means a check is missin
</tbody>
</table>
And each game's, which only matter if you run that game:
<GameExample game="uo">
<table>
<thead><tr><th>Document</th><th>Answers</th></tr></thead>
<tbody>
{Object.entries(canonicalDocs.uo).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>
</GameExample>
<GameExample game="rust">
<table>
<thead><tr><th>Document</th><th>Answers</th></tr></thead>
<tbody>
{Object.entries(canonicalDocs.rust).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>
</GameExample>
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.
@@ -45,14 +86,14 @@ moved turns this page red rather than leaving a dead link.
- **"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 `v8.md` for the current
protocol — which is also where the asset plane is specified.
- **"What can a game server send?"** → that game's protocol document, in its table above.
- **"What may a scheduled event do to the world?"** → `website/EVENTS.md` for the model,
`MODULE_API.md` for the verbs a module may declare, and `link/ADMIN_CONTROLS.md` for what
the site may ask a game to do at all.
- **"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`.
- **"Who may see this?"** → core's roles in `BACKEND_DESIGN.md`; a game's own audience
rules are in that game's module documents.
- **"How do I set a game server up?"** → `installer/INSTALL.md`, and that game's install
guide.
## Where they live

View File

@@ -35,7 +35,7 @@ The first boot is also when your admin account is written, so `ADMIN_USERNAME` a
## 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 game module's sidecar 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
@@ -53,7 +53,7 @@ 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
**Most settings are not here.** Branding, navigation, theming and the game connection are
**admin-managed and live in the database**, deliberately — so changing them does not mean
redeploying a container.

View File

@@ -60,8 +60,9 @@ 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.
Each game's sidecar exposes its own small REST and WebSocket surface, reached **only** by the
website's backend. It carries its protocol version in a header 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/).

View File

@@ -4,13 +4,14 @@ description: The four commands the installer offers, what each does to a host, a
---
import { Aside } from '@astrojs/starlight/components';
import GameExample from '../../../../components/GameExample.astro';
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.
The installer is one binary per operating system that deploys the **game server side
only**: the bridge plugin, and the sidecar with its service. 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 walkthrough is [Connect your game server](/docs/getting-started/connect-a-game-server/),
which hands off to each game's steps. This page is the command surface.
## The commands
@@ -25,47 +26,65 @@ server](/docs/getting-started/connect-a-game-server/). This page is the command
`doctor`, `update` and `uninstall` are the day-two commands.
## Telling it which game
One binary serves both games. A flag says which one a run is about, and each game has flags
of its own:
<GameExample game="uo">
```bash
runicgateway-installer install --game servuo --servuo /path/to/ServUO
```
`--game servuo` is the default, so a ServUO run can leave it out.
</GameExample>
<GameExample game="rust">
```bash
runicgateway-installer install --game rust --rust /srv/rust --server-id main
```
One sidecar serves one Rust server, so `--server-id` is required on install. The Rust flags
are refused on a run that is not `--game rust`, rather than silently ignored.
</GameExample>
## 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 …
```
**`--verify` writes nothing.** On `install` and `update` it reports every change the run
would make and stops, which is the right thing to run first against a game server that has
players on it.
**`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.
```bash
RUNICGATEWAY_STATE_DIR=/tmp/rehearsal runicgateway-installer install …
```
## 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
1. Resolves a **bundle** — an exact, protocol-checked pair of sidecar and bridge plugin
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
2. Deploys the bridge plugin onto the game server, 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.
3. Installs the sidecar and registers its service.
4. Records what it deployed, with per-file hashes, so `doctor` can tell later whether
anything changed.
5. Prints the values to paste into the game module's admin screen on the site.
<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>
Each game adds its own steps: the Ultima Online install, for one, offers an optional patch
tier, covered in [its group](/docs/ultima-online/connect-a-servuo-shard/).
## 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.
share a host, and no game server the platform supports runs on 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.

View File

@@ -60,7 +60,7 @@ 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/).
rather than sent oversized. See [Rosters are sets, not signatures](/docs/ultima-online/event-catalog/#rosters-are-sets-not-signatures).
## Canonical documents

View File

@@ -61,6 +61,14 @@ lazily instead. The two buttons there — *Fetch waiting pictures* and *Refresh
have* — exist for the two moments waiting is the wrong answer: you have just linked a shard,
or you have just patched a client.
## After a client update
When you patch the UO client on the shard host, press one more button. Creature portraits,
item pictures and the name table are read from that client, and the site deliberately never
re-reads them on its own — a restart does not, and neither does the installer's `update`.
They keep serving the old artwork until somebody presses *Update* here. It is one round trip
when nothing has changed.
## When the page says something is wrong
Every one of these is a reported state with a reason, not an error. The site keeps serving
@@ -69,8 +77,8 @@ whatever is already imported in all of them.
| What you see | What it means |
|---|---|
| **The shard is busy with another client-file request** | Not a fault. The shard serves one of these at a time, and an import — or the item-picture pass refilling itself — is holding it. It frees itself. |
| **The shard is not answering for client files** | The ordinary bridge problem: see [The shard connection](/docs/administration/the-shard-connection/). |
| **…set `AssetsEnabled` on the shard** | The asset plane is switched off in [`Bridge.cfg`](/docs/reference/bridge-cfg/). It is a separate switch on purpose — turning it on is consenting to the website reading this host's client files. |
| **The shard is not answering for client files** | The ordinary bridge problem: see [The shard connection](/docs/ultima-online/the-shard-connection/). |
| **…set `AssetsEnabled` on the shard** | The asset plane is switched off in [`Bridge.cfg`](/docs/ultima-online/bridge-cfg/). It is a separate switch on purpose — turning it on is consenting to the website reading this host's client files. |
| **The shard host cannot render images** | A Linux host with no `libgdiplus`. Names are unaffected, because they have no pixels in them. |
| **Waiting for you: *n* pictures … no longer offered** | The shard stopped offering artwork this site holds. A deletion is never silent here; it waits for you to approve or dismiss it. |
@@ -81,7 +89,7 @@ cannot read a single sprite.
`sudo apt-get install libgdiplus`, or `dnf install libgdiplus`. `runicgateway doctor` checks
for it, and Windows shard hosts need nothing. See
[Requirements](/docs/getting-started/requirements/).
[what the shard host needs](/docs/ultima-online/connect-a-servuo-shard/#what-the-shard-host-needs).
</Aside>
## What it will not do

View File

@@ -0,0 +1,168 @@
---
title: Connect a ServUO shard
description: What the shard host needs, what the installer deploys on it, what it asks, and the four values it prints for the website.
---
import platform from '../../../../data/platform.json';
import { Aside, Steps } from '@astrojs/starlight/components';
This is the second of the two installs, and it happens on the machine that runs your ServUO
shard. One binary deploys the plugin, installs the sidecar, registers its service, and
prints four values for you to paste into the website.
It never contacts your website, and it never starts or stops your shard.
## What the shard host needs
| Requirement | Detail |
|---|---|
| **A working ServUO install** | It must currently boot and compile scripts cleanly. The installer deploys onto a healthy shard; it does not repair a broken one. |
| **ServUO {platform.games.uo.bundle.servuoMin}** *(patch tier only)* | The base install works on any reasonably current ServUO. The optional patch tier is written and tested against stock {platform.games.uo.bundle.servuoMin}; on any other version it is unsupported, and skipping it still leaves you with a working bridge. |
| **`libgdiplus`** *(Linux shard hosts only)* | Only needed for **artwork**. ServUO runs under Mono on Linux, and the library it decodes sprites with is a thin layer over this one — in the decode path, not merely the encode. Without it creature portraits and item pictures stay empty, and names and the spawn atlas are unaffected because neither touches a pixel. `sudo apt-get install libgdiplus`; `doctor` checks for it. **Windows hosts need nothing.** |
| **The shard stopped** | `ServUO.exe` locks `Scripts.dll` and rewrites `Saves/` on exit. The installer refuses to deploy under a running shard. |
| **The sidecar on the same host as the shard** | The shard connects to `127.0.0.1:7788`. Splitting them is not supported — that loopback socket *is* the trust boundary for inbound commands. |
The general requirements for any game server host — administrator rights and outbound
HTTPS — are on [Requirements](/docs/getting-started/requirements/#the-game-server-host).
<Aside type="note" title="You do not need to install a game client for this">
You already have one. A ServUO shard cannot boot without a UO client — it resolves one at
startup to read the world's own data — so the artwork, the animations and the name table
the site shows are already sitting on that host. The shard reads them there and hands the
results over the bridge; nothing is converted on a desktop and nothing is uploaded. See
[Client files](/docs/ultima-online/client-files/).
</Aside>
<Aside type="caution" title="Back up before you install">
The overlay overwrites `Scripts/Scripts.csproj`, a stock file, and the optional patch tier
edits stock sources. A copy of `Scripts/` and `Config/` costs nothing and is the difference
between an experiment and a gamble. The installer keeps its own backups too: every file it
overwrites is recorded in `install.json`.
</Aside>
## What gets deployed
| # | Component | Where it goes |
|---|---|---|
| 1 | **The plugin overlay** — C# source ServUO compiles at boot | into your ServUO tree |
| 2 | **The uo-link sidecar** — a small Rust service | a system directory, plus a service |
| 3 | **A record of the run** | `install.json`, with per-file hashes and backups |
```
ServUO shard ──loopback TCP 127.0.0.1:7788──► uo-link sidecar ──HTTP + WebSocket──► website
```
The shard **dials out**. It never listens for the website and is never reachable from the
internet; only the sidecar is exposed, and only to your site.
## Install
<Steps>
1. **Download the binary for your OS, and `SHA256SUMS`**, from the
[installer releases page](https://gitea.whitlocktech.com/RunicGateway/installer/releases)
({platform.releases.installer}).
Releases are **unsigned** — there is no code-signing certificate, so that checksum file
is the whole trust anchor. Check it:
```bash
sha256sum -c SHA256SUMS --ignore-missing
chmod +x runicgateway-installer-linux-x86_64
```
On Windows, `(Get-FileHash .\runicgateway-installer-windows-x86_64.exe -Algorithm SHA256).Hash`
and compare. Windows will also show a SmartScreen prompt on first run, for the same
reason.
2. **Stop the shard.** `ServUO.exe` locks `Scripts.dll` and rewrites `Saves/` on exit, so
the installer refuses to deploy under a running server.
3. **Run it, elevated.**
```bash
sudo ./runicgateway-installer-linux-x86_64 install
```
Add `--verify` first if you want to see every change it would make and write nothing.
It asks four things: your ServUO root, whether to apply the optional patch tier, the
hostname your website should use to reach this machine, and your site's URL (used only
to print a link at the end).
4. **Read the summary.** It reports the overlay sync file by file, the sidecar binary and
its verified hash, the config and database paths, and the service state. Then it says
what you must do next — restart ServUO yourself, because it will not do that for you.
</Steps>
<Aside type="note" title="It installs a bundle, not “latest of each”">
The three components version independently but must agree on one wire protocol, so what it
resolves is a **bundle**: an exact, protocol-checked pair of sidecar and overlay versions
({platform.games.uo.bundle.tag} today — sidecar {platform.games.uo.bundle.sidecar}, overlay {platform.games.uo.bundle.overlay}).
`--bundle <tag>` pins an exact past combination, so a reinstall in six months reproduces
today's install rather than tomorrow's.
</Aside>
## The patch tier is optional
Most of the plugin is *added* files, which is why the base install is a safe copy. Two
features need edits to stock ServUO sources, and those are opt-in, off unless you say yes,
and refused where the target lines are not stock. Skipping the tier costs you vendor-sale
events and in-game moderation audit forwarding; everything else works.
The tier is written and tested against stock ServUO {platform.games.uo.bundle.servuoMin}. On any
other version it is unsupported and untested, and the prompt makes you answer past a
warning.
## Paste the four values into the site
A successful run ends by printing the one step it cannot do for you:
```
Base URL http://shard.example.com:8080
WebSocket URL ws://shard.example.com:8080/ws
Protocol version 4
Auth token 4f9c… (also in sidecar.toml)
```
Every value comes from asking the installed sidecar itself, so it cannot drift from what
the service actually runs.
On the site, sign in as an administrator and open **Shard (uo-link)** in the admin
sidebar — `/admin/uo/link`. Tick *Enable the shard integration*, paste **Base URL**,
**WebSocket URL**, **Auth token** and **Protocol**, and save. The ingest client restarts
immediately.
<Aside type="caution" title="Installer v0.1.0 prints an older path for that screen">
v0.1.0 prints `…/admin/shard`. Since the shard screens became part of the `uo` module — and
a module owns one path segment wherever it appears — the screen moved to
**`/admin/uo/link`**.
The old path does not fail visibly: the site has no route for it, so it sends you to the
dashboard, and that looks like the link worked. The four values you were just told to paste
then have nowhere to go. Use the sidebar, or the path above.
Fixed in **v0.1.1**, which prints the real path. Only matters if you are running the older
binary.
</Aside>
The token is encrypted at rest and **never returned to any client** — losing it means
reading it back from `sidecar.toml` on the shard host, not from the website.
## If the website is on a different machine
The sidecar binds `127.0.0.1:8080`, reachable only from the shard host. If the site runs
elsewhere, widen the bind and then narrow the access:
1. Set `[web] bind` in `sidecar.toml` to `0.0.0.0:8080` and restart the service.
2. **Firewall that port to your website's address only.** The auth token is always on, but
it travels as a plain bearer token — the sidecar speaks HTTP, not HTTPS.
3. If the two hosts are not on a trusted network, put the sidecar behind a TLS reverse
proxy or a VPN link and give the website the `https://` / `wss://` URLs.
Leave `[shard] bind` on `127.0.0.1:7788`. That socket accepts *inbound commands to the
game*, and being loopback-only is what makes that safe.
Next: [Verify the bridge](/docs/ultima-online/verify-the-bridge/) — because a successful
file copy is not a working bridge.

View File

@@ -53,6 +53,18 @@ Not all alike, and the difference is deliberate:
- **High-frequency kinds** only update state. Accumulating history for something that fires
constantly buys nothing and costs a table that grows forever.
## 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 when ServUO 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. A roster too large for one
line is split rather than sent oversized; `GuildRosterMembersPerLine` in
[`Bridge.cfg`](/docs/ultima-online/bridge-cfg/) sets the cap.
## The visibility ladder
Five rungs, in order, least privileged first:
@@ -95,7 +107,7 @@ underneath it. Configuration changes, by contrast, *do* take effect live.
## Administering it
[The shard connection](/docs/administration/the-shard-connection/) covers the admin screens,
[The shard connection](/docs/ultima-online/the-shard-connection/) covers the admin screens,
and the visibility ladder is administrator-configurable per feature and per field.
## Canonical documents

View File

@@ -0,0 +1,61 @@
---
title: The Ultima Online module
description: What the uo module adds to a site, the three pieces that connect it to a ServUO shard, and where each page in this group fits.
---
import platform from '../../../../data/platform.json';
The `uo` module turns a Runic Gateway site into an *Ultima Online* site for a ServUO shard.
Everything in this group is specific to it; the rest of the documentation is core, and
describes the platform in core's own words.
## The three pieces
| Piece | What it is | Release |
|---|---|---|
| **The module** | [`Module-uo`](https://gitea.whitlocktech.com/RunicGateway/Module-uo) — every Ultima Online screen on the site | {platform.releases['Module-uo']} |
| **The sidecar** | [`link`](https://gitea.whitlocktech.com/RunicGateway/link), the **uo-link sidecar**, in Rust — the only network-facing part of the bridge | {platform.games.uo.bundle.sidecar} |
| **The bridge plugin** | [`servuo-plugins`](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins) — a C# overlay ServUO compiles at boot, that feeds the sidecar | {platform.games.uo.bundle.overlay} |
The sidecar and the plugin overlay are installed together on the shard host as a bundle —
{platform.games.uo.bundle.tag} today, speaking protocol {platform.games.uo.protocol}. The
[installer](/docs/reference/installer-cli/) does that; see
[Connect a ServUO shard](/docs/ultima-online/connect-a-servuo-shard/).
## What the module adds
Install it from **Admin → Modules** like any module — the install manifest is the
`module-uo-<version>.json` asset on
[its releases page](https://gitea.whitlocktech.com/RunicGateway/Module-uo/releases) — and
watch the log at the restart:
```
[uo] registered routes: public:/shard,/atlas admin:/shard,/uo-link player:/shard
[modules] schema ensured for module "uo"
[modules] module "uo" started
```
Its capabilities are {platform.games.uo.module.capabilities.join(', ')} — the shard console, the
map atlas, the player-vendor marketplace, city governors, guilds, houses and IDOCs, champion
boards, and the cliloc strings that make item names readable. On this module, a shard's
guilds are the site's [Teams](/docs/administration/teams/).
Its pages live under `/uo/…`, `/admin/uo/…` and `/player/uo/…`, and its admin screen is
**Shard (uo-link)** at `/admin/uo/link`.
Until a shard is connected, its screens exist and report the shard as offline. That is the
same thing the public site does when the shard goes down, and it is designed to be
unremarkable.
## In this group
| Page | Read it when |
|---|---|
| [Connect a ServUO shard](/docs/ultima-online/connect-a-servuo-shard/) | Setting up the shard host, and pasting the values into the site |
| [Verify the bridge](/docs/ultima-online/verify-the-bridge/) | Proving the shard, the sidecar and the site are really talking |
| [The shard connection](/docs/ultima-online/the-shard-connection/) | Running the connection day to day, and what reaches the public |
| [Client files](/docs/ultima-online/client-files/) | Creature portraits, item pictures and item names |
| [Troubleshooting](/docs/ultima-online/troubleshooting/) | Something on the shard side is wrong |
| [Bridge.cfg](/docs/ultima-online/bridge-cfg/) | Changing what the plugin sends, and how often |
| [sidecar.toml](/docs/ultima-online/sidecar-toml/) | Changing the sidecar's binds, token or store |
| [Shard event catalog](/docs/ultima-online/event-catalog/) | Knowing what the shard can tell the site, and who may see it |

View File

@@ -12,7 +12,7 @@ With the `uo` module installed, **Shard (uo-link)** appears in the admin sidebar
the controls that ride on it.
Setting it up for the first time is
[Connect a game server](/docs/getting-started/connect-a-game-server/).
[Connect a ServUO shard](/docs/ultima-online/connect-a-servuo-shard/).
<Screenshot id="admin-shard" />
@@ -86,7 +86,7 @@ withdraw it without waiting for it to expire.
The other half of what the bridge carries has its own screen: creature portraits, item
pictures and the game's own name table, read from the UO client on the shard host. It is
**Client files**, at `/admin/uo/files`, and it is where an operator goes after patching that
client — nothing imports on a restart. See [Client files](/docs/administration/client-files/).
client — nothing imports on a restart. See [Client files](/docs/ultima-online/client-files/).
## What reaches the public

View File

@@ -0,0 +1,60 @@
---
title: Troubleshooting
description: The failures a ServUO shard's bridge actually hits, what each one looks like, and the fix.
---
import { Aside } from '@astrojs/starlight/components';
The problems on this page belong to the shard side. For the site itself — a container that
will not start, sign-in, modules, email — see the core
[Troubleshooting](/docs/administration/troubleshooting/) page.
## The shard screens are empty or say offline
Work outwards from the game, and stop at the first check that fails.
1. **In game:** `[bridge status` — `connected=False` means the shard cannot reach the
sidecar.
2. **On the shard host:** `curl -s http://127.0.0.1:8080/health` — `plugin_connected: true`
is the value that matters.
3. **On the shard host:** `runicgateway doctor` — checks the install record, every overlay
file hash, the service, and that the sidecar and overlay agree on a protocol.
4. **On the site:** the [shard connection screen](/docs/ultima-online/the-shard-connection/)
— its four indicators say which link is broken.
Two log lines with specific meanings: **`409`** is a protocol mismatch (set the Protocol
field to what `/health` reports), and **`401`** is the auth token (read the live one back
with `uo-link-sidecar --print-config`; do not retype it from a screenshot).
[Verify the bridge](/docs/ultima-online/verify-the-bridge/) has the same four checks in
more detail, and a table of what each failure means.
<Aside type="note" title="“Nothing changed and it stopped working” usually means a ServUO update">
An update to the server tree can revert `Scripts.csproj`, at which point the plugin sits in
the tree and never compiles — and ServUO ignores the script build's exit code, so the boot
looks clean. `doctor` catches it by comparing file hashes against the install record.
</Aside>
## The bestiary has no pictures, or items show numbers
Those come out of the UO client on the shard host, and **nothing imports them on a
restart** — a button on **Admin → Client files** is the only thing that does. Check that
page first: it reports why rather than failing.
| What it says | What to do |
|---|---|
| Counts are zero and no import is recorded | Press *Update*. On a shard that was linked before this existed, nobody ever has. |
| *…set `AssetsEnabled` on the shard* | The asset plane is off in `Bridge.cfg`. It is a separate switch on purpose. |
| *The shard host cannot render images* | A Linux host with no `libgdiplus`. Install it and press *Update* again. Names are unaffected either way. |
| *The shard is busy with another client-file request* | Not a fault. Something ordinary holds the slot; it frees itself. |
| Pictures were fine and went blank | Check the uploads volume before anything else — the database still remembers pictures that are no longer on disk, and *Re-import everything* is the button for exactly that. |
Items reading as numbers rather than names is the same page, different section: it means the
cliloc table has not been imported. See [Client files](/docs/ultima-online/client-files/).
## Teams are missing
On a shard, Teams are its guilds, and they arrive over the bridge. Check the sync panel on
**Admin → Teams** first: *last success: never* with `no uo-link configured` means the shard
connection, not the Team machinery. Fix the connection and the next sweep fills the list.
See [Teams](/docs/administration/teams/) for the Team machinery itself.

View File

@@ -0,0 +1,110 @@
---
title: Verify the bridge
description: Four checks on a ServUO shard, one per link in the chain, that distinguish "files copied" from "the bridge works".
---
import { Aside, Steps } from '@astrojs/starlight/components';
A successful install is not a working bridge, and the failure is quiet in a specific way:
**ServUO shells out to `dotnet build`, prints the output, ignores the exit code, and
reloads the existing `Scripts.dll`.** A broken script build looks exactly like a clean boot.
So verify each link in the chain, in order. Each check tells you which one to fix.
<Steps>
1. **The plugin compiled — watch the boot output.**
Start your shard the way you always do. You want the build to succeed *and* the bridge
to announce itself:
```
Core: Compiling scripts...
Build succeeded.
[Bridge] enabled=True endpoint=127.0.0.1:7788 queueCap=10000 …
```
If you scrolled past it, force the question:
```bash
dotnet build Scripts/Scripts.csproj -c Release -p:Platform=x64 # must be 0 errors
```
2. **The shard is connected — ask it in game.**
As an administrator:
```
[bridge status
```
It reports `connected=True depth=0 sent=… dropped=0`. `connected=False` means the shard
cannot reach the sidecar. `dropped` climbing means the sidecar is wedged and the shard
is shedding events rather than stalling — which is what it is designed to do, and why a
broken bridge never freezes your game.
`[bridge reload` re-reads `Bridge.cfg` without a restart; `[bridge sweepnow` forces one
pass of every stream.
3. **The sidecar is healthy — ask it over HTTP.**
`/health` needs no auth, so it is safe to curl on the shard host:
```bash
curl -s http://127.0.0.1:8080/health
```
```json
{"status":"ok","protocol":4,"plugin_connected":true,"database":"ok","uptime":"2m"}
```
**`plugin_connected: true` is the one that matters.** It is the only value in this whole
sequence that distinguishes "files copied" from "the bridge works".
4. **The website is ingesting — look at the shard screen.**
On the site, open **Shard (uo-link)** (`/admin/uo/link`). The header should read
**Connected**, with *Shard link: up* and *WS ingest: online*, and the live feed at the
bottom should start showing events within seconds rather than sitting on
*Waiting for shard events…*.
Then check the public side: the shard status page should stop reporting the game as
offline.
</Steps>
## When one of them fails
| What you see | What it means |
|---|---|
| Shard boots clean, nothing reaches the site | The classic silent failure — a stale `Scripts.dll`. Run the `dotnet build` line above and read the errors. |
| `[bridge` is not a command | The plugin did not compile, or the bridge is disabled in `Bridge.cfg`. |
| `connected=False` | The sidecar is not listening on `127.0.0.1:7788`. Check the service, and that `[shard] bind` matches `Host`/`Port` in `Bridge.cfg`. |
| `/health` is fine locally, the site says offline | The website cannot reach port 8080 — bind address or firewall. The site is *designed* to render normally with the shard down, so this fails quietly. |
| The site logs `409` from the sidecar | Protocol mismatch. Set the Protocol field to what `/health` reports rather than guessing; the sidecar rejects rather than mis-parsing. |
| `401` from the sidecar | Wrong or missing token. Read the live one back with `uo-link-sidecar --print-config`; do not retype it from a screenshot. |
<Aside type="note" title="`runicgateway doctor` answers most of this in one command">
Run on the shard host, it checks the install record, the ServUO tree, every overlay file
hash, the patch tier, the sidecar, its service, `/health`, that the sidecar and overlay
agree on a protocol, and — on Linux — that the host can decode an image at all. Its output is the first thing anyone helping you will ask for. It
exits non-zero when a check failed, so a monitoring system can run it too.
</Aside>
<Aside type="note" title="A bridge can be green and still show no artwork">
Creature portraits and item pictures are a separate switch and a separate import, so a
perfectly healthy bridge shows a bestiary of text until somebody presses *Update* on
**Admin → Client files**. That is the expected first-run state, not a fault — see [Client
files](/docs/ultima-online/client-files/).
</Aside>
## What "working" looks like a week later
- The public shard page shows live status, and the admin dashboard shows events arriving.
- `dropped` in `[bridge status` stays at zero. A climbing number means the sidecar is
wedged, not that the shard is unhealthy.
- `doctor` is still green after a shard update — that is what catches an overlay file
reverted by hand or by a ServUO upgrade.
You have finished the installation path. From here,
[Administration](/docs/administration/configuration/) covers running the site day to day.

View File

@@ -84,7 +84,7 @@ export const sidecarConfig = {
/** `installer` — `src/cli.rs`'s `Command`. */
export const installerCommands = {
Install: 'Set up the shard side: sync the overlay, install the sidecar, register its service',
Install: "Set up a game server's side: deploy the bridge plugin, install the sidecar, register its service",
Doctor: 'Diagnose an existing install',
Update: 'Move to a newer bundle',
Uninstall: 'Remove what install put there',
@@ -238,25 +238,42 @@ export const bridgeCfg = {
*/
export const visibilityLadder = ['anonymous', 'logged_in', 'player', 'staff', 'admin'];
/** Canonical documents, by the question each answers. Checked to still exist in `docs`. */
/**
* Canonical documents, by the question each answers. Checked to still exist in `docs`.
*
* Grouped by whose documents they are (PLAN.md §16, D63): `core` describes the platform in
* core's words, and each game's group may use that game's. The keys of the outer object are
* `core` plus keys of `platform.json`'s `games`, which is what lets the page label a game's
* group with the game's name and `checkTerms.mjs` hold each group to its own vocabulary.
*/
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/EVENTS.md': 'The Event System — leases, the ledger, and the module seam',
'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/v8.md': 'The current protocol, and its cross-repository obligations',
'link/SHARD_PREREQS.md': 'What a shard host needs before any of this works',
'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': 'The Rust module as first designed on paper, to test that the contract generalises — since built',
'modules/uo/API.md': "module-uo's own API, including its audience rules",
'android/PLAN.md': 'The Android app',
core: {
'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/EVENTS.md': 'The Event System — leases, the ledger, and the module seam',
'website/THEMING_AND_NAV.md': 'Admin-configurable theme, assets and navigation',
'website/TRUSTED_DEVICES_MFA.md': 'Trusted devices and the second factor',
'installer/INSTALL.md': 'The operator guide for setting a game server up',
'installer/PLAN.md': "The installer's design of record",
'modules/rust-dryrun.md': 'The second game module as first designed on paper, to test that the contract generalises — since built',
'android/PLAN.md': 'The Android app',
},
uo: {
'link/PLAN.md': 'The sidecar design of record, the data catalog and the wire protocol',
'link/INTEGRATION.md': 'Integrating with the sidecar',
'link/v8.md': 'The current protocol, and its cross-repository obligations',
'link/SHARD_PREREQS.md': 'What a shard host needs before any of this works',
'link/ADMIN_CONTROLS.md': 'What the site may command the game to do',
'website/SHARD_VISIBILITY.md': 'The audience ladder, for administrators',
'modules/uo/API.md': "The module's own API, including its audience rules",
},
rust: {
'rust-link/INSTALL.md': 'Installing the Rust bridge — the installer, the Pterodactyl egg, or by hand',
'rust-link/INTEGRATION.md': 'Integrating with the sidecar',
'rust-link/PROTOCOL.md': 'The wire protocol between the bridge plugin and the sidecar',
'modules/rust/PLAN.md': 'The Rust module design of record',
},
};

View File

@@ -156,3 +156,28 @@
--sl-color-green: var(--mode-live);
--sl-color-green-high: var(--mode-live);
}
/* ---- A game's worked example on a core page (D68) ------------------------
GameExample.astro. The label is what makes a game's words honest on a core
page, so it reads as a label: the same hairline and type as the rest of the
docs, set above the block it names. Starlight's own colour tokens, so the
light theme follows without a block of its own. */
.sl-markdown-content .game-example {
margin-block: 1.25rem;
border: 1px solid var(--sl-color-hairline-light);
border-radius: var(--radius-input);
padding: 0.75rem 1rem 1rem;
}
.sl-markdown-content .game-example__label {
margin: 0 0 0.5rem;
font-size: var(--sl-text-xs);
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--sl-color-gray-3);
}
.sl-markdown-content .game-example__body > :first-child {
margin-top: 0;
}

View File

@@ -38,6 +38,8 @@ import path from 'node:path';
import { after, before, describe, it } from 'node:test';
import { fileURLToPath } from 'node:url';
import { docsRedirects } from '../src/config/redirects.mjs';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const built = fs.existsSync(path.join(root, 'dist', 'server', 'entry.mjs'));
@@ -190,4 +192,19 @@ describe('the headers the server sends', { skip: built ? false : 'no build in di
assert.equal(res.status, 403, 'the forwarded-header fix weakened the CSRF check');
});
it('sends every moved docs page to its new address with a 301 (PLAN.md §16, D60)', async () => {
const redirects = Object.entries(docsRedirects);
assert.ok(redirects.length > 0, 'src/config/redirects.mjs declares no redirects');
for (const [from, to] of redirects) {
// With and without the trailing slash: a bookmark can carry either.
for (const route of [from, `${from}/`]) {
const res = await fetch(base + route, { redirect: 'manual' });
assert.equal(res.status, 301, `${route} did not redirect`);
assert.equal(new URL(res.headers.get('location'), base).pathname, to, `${route} redirected to the wrong place`);
}
const target = await fetch(base + to);
assert.equal(target.status, 200, `${to}, the target of ${from}, does not exist`);
}
});
});