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
Reviewed-on: #34
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
78
PLAN.md
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
271
scripts/checkTerms.mjs
Normal 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);
|
||||
}
|
||||
37
src/components/GameExample.astro
Normal file
37
src/components/GameExample.astro
Normal 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
22
src/config/redirects.mjs
Normal 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/',
|
||||
};
|
||||
@@ -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',
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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/).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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/).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
168
src/content/docs/docs/ultima-online/connect-a-servuo-shard.mdx
Normal file
168
src/content/docs/docs/ultima-online/connect-a-servuo-shard.mdx
Normal 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.
|
||||
@@ -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
|
||||
61
src/content/docs/docs/ultima-online/index.mdx
Normal file
61
src/content/docs/docs/ultima-online/index.mdx
Normal 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 |
|
||||
@@ -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
|
||||
|
||||
60
src/content/docs/docs/ultima-online/troubleshooting.mdx
Normal file
60
src/content/docs/docs/ultima-online/troubleshooting.mdx
Normal 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.
|
||||
110
src/content/docs/docs/ultima-online/verify-the-bridge.mdx
Normal file
110
src/content/docs/docs/ultima-online/verify-the-bridge.mdx
Normal 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.
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user