feat(facts): phase 13 - per-game facts, Rust and RunicNPC checked, main green again
All checks were successful
PR checks / checks (pull_request) Successful in 8m25s

platform.json drops the single protocol/bundle/capability list for a
`games` object (uo, rust) plus a `runicnpc` block. checkFacts.mjs loops
the protocol, plugin-protocol, capability and bundle checks over both
games, reads the schema-2 bundles at v2/<game>/current.json, checks each
module's coreApi, and reads RunicNPC's plugin.toml. 41 facts, up from 19;
clears the two that were red on main (Module API 1.17.0, installer
v0.8.0).

D66: the hero, footer and structured data show the Module API only; each
game's protocol and bundle go on that game's pages, and the docs landing
table splits into shared versions plus one column per game.

The re-read section 1 requires found, and this fixes:
- three hard-coded "1.6.0" Module API versions in the module docs
- module-lifecycle saying modules load alphabetically (the loader now
  refuses a second module, W19), and the install pages never mentioning
  the one-module rule
- "there is one module" in five places, now two modules both built
  inside this project; the Integration Kit keeps its draft banner
- the homepage diagram's ServUO/uo-link sub-labels (D63 wording now)

notBuilt drops `second-module` (resolved by Module-Rust's release) and
rewords `multi-module`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-10-22 08:09:22 -05:00
parent 2971b84d51
commit 9938e71e07
29 changed files with 384 additions and 154 deletions

58
PLAN.md
View File

@@ -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-five**:
somewhere other than the thing it decided. The count of record is **sixty-six**:
| # | Where | What it settled |
|---|---|---|
@@ -255,6 +255,7 @@ somewhere other than the thing it decided. The count of record is **sixty-five**
| D58 | §6, "How phase 12 delivered it" | `node_modules` ships in three layers because Cloudflare refuses a request body over 100 MB, and the workflow counts layers before it pushes |
| 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 |
---
@@ -1609,11 +1610,12 @@ a mechanism rather than diligence:
| Fact | Authority |
|---|---|
| Protocol version | `link` `main:sidecar/src/main.rs` → `PROTOCOL_VERSION` |
| Overlay protocol | `servuo-plugins` `main:overlay.toml` → `protocol` |
| Protocol version, per game | the game's sidecar repo (`link`, `Rust-Link`) `main:sidecar/src/main.rs` → `PROTOCOL_VERSION` |
| Plugin protocol, per game | the game's plugin repo (`servuo-plugins`, `Rust-Plugins`) `main:overlay.toml` → `protocol` |
| Module API | `website` `main:server/src/modules/version.js` → `MODULE_API_VERSION` |
| Module capability list | `Module-uo` `main:module.json` → `capabilities` (added in phase 3) |
| Bundle + component pins | `installer` branch `bundles`, **root** `current.json` |
| Module capabilities + `coreApi`, per game | `Module-uo` / `Module-Rust` `main:module.json` (capabilities added in phase 3, both games and `coreApi` in phase 13) |
| Bundle + component pins, per game | `installer` branch `bundles`, **`v2/servuo/current.json`** and **`v2/rust/current.json`** (phase 13; the root `current.json` is the frozen schema-1 manifest) |
| RunicNPC API, floors, required plugins | `runicnpc-rust` `main:plugin.toml`, and the Rust bundle's `npc` block (phase 13) |
| Release versions | Gitea releases API per repo |
Same mechanism and the same intent as the Integration Kit's `checkCoreApi.js`: when the platform
@@ -1974,6 +1976,52 @@ module collects, Steam ids first, before anything is claimed there. `checkTerms.
**Phase 18 — screenshots** (D65), plus the emulator pass for `/app/`'s Rust screens. This is the only
phase that needs the rigs.
### How phase 13 built it
One decision, **D66**, taken 2026-10-22 when the restructure reached the homepage. The count of
record is now **sixty-six**.
**D66 — the hero and the footer show the Module API only.** Both used to read "Protocol 8 · Bundle
2026.09.15", which was a description of the platform while there was one game. There are now two
protocols and two bundles, and neither belongs on the front door. The hero and footer show **Module
API**, the one version every deployment shares. The structured data's `softwareVersion` reads the
same value. Each game's protocol and bundle are on that game's pages, and the docs landing page
splits its table in two: shared versions, then one column per game. The alternative was per-game
chips on the hero ("Ultima Online: protocol 8, bundle … · Rust: protocol 14, bundle …"). It was
declined as busier and as putting game facts on the front door.
**`platform.json` is per game.** `protocol`, `bundle` and `moduleUoCapabilities` are gone. In their
place is a `games` object (`uo` and `rust`, each with its protocol, its sidecar and plugin repos, its
module's capabilities and `coreApi`, and its bundle pins), plus a `runicnpc` block for the plugin's
API version, framework floors and required plugins. `releases` stays flat, so every repository's
release is read the same way. **41 facts** are now checked, up from 19. Checks 1, 2, 4 and 5 loop
over the games, and the bundle reads `v2/<game>/current.json` and refuses anything but schema 2.
**What the re-read found** (§1 obliges a re-read of every page that quotes a changed value):
- **Three hard-coded Module API versions in prose**, all saying 1.6.0, on *The module API* and *The
module manifest*. §12 has forbidden that since phase 1. Nothing caught it because `checkFacts`
guards `platform.json` and not the prose. All three now read `platform.json`. The manifest page's
worked example of a caret range now quotes the two modules' real `coreApi` ranges, which are
checked facts as of this phase.
- ***Module lifecycle* said modules load alphabetically.** That stopped being the whole truth on
2026-09-23, when the installer began refusing a second module, and stopped being true at all on
2026-10-22, when the loader began refusing one (W19). The page now says what the loader does. *Installing
modules* and *Managing modules* had never mentioned the one-module rule at all, and now do.
- **"There is one module" was false in five places.** Those were the `/architecture/` seam drawing and its
"what this does not yet prove", `/community/`, the `/features/` lede, the Game intelligence
summary, and the homepage's list of absences. Each now says two modules exist, both built inside
this project, and that the Integration Kit keeps its draft banner until someone outside does it.
- **The homepage diagram's sub-labels** said "ServUO today" and "uo-link". They became false the day
Rust shipped, so they changed here rather than waiting for phase 17. They now carry D63's wording.
The caption names both games.
**One planned item moved to phase 17.** §16 put the Rust half of `capabilities.mjs`'s *page* coverage
assertion in this phase. But a coverage assertion needs a list of Rust capabilities on a page to
cover, and phase 17 is the phase that writes that list. Phase 13 does the half that has something to
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.
---
[api]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md

View File

@@ -92,33 +92,42 @@ function extract(text, re, what, authority) {
return m[1];
}
// Every game the site quotes facts for (PLAN.md §16). Checks 1, 2, 4 and 5 run once per
// game, and a row's label carries the game's key so a failure names which one moved.
const GAMES = Object.entries(platform.games);
// ---------------------------------------------------------------------------
// 1. The wire protocol, from the sidecar itself
// 1. Each game's wire protocol, from its sidecar
// ---------------------------------------------------------------------------
async function checkProtocol() {
const authority = 'link main:sidecar/src/main.rs';
const src = await raw('link', 'sidecar/src/main.rs', 'main');
const value = extract(
src,
/pub const PROTOCOL_VERSION:\s*u32\s*=\s*(\d+)\s*;/,
'PROTOCOL_VERSION',
authority
);
record('protocol (sidecar)', platform.protocol, Number(value), authority);
for (const [key, game] of GAMES) {
const authority = `${game.sidecarRepo} main:sidecar/src/main.rs`;
const src = await raw(game.sidecarRepo, 'sidecar/src/main.rs', 'main');
const value = extract(
src,
/pub const PROTOCOL_VERSION:\s*u32\s*=\s*(\d+)\s*;/,
'PROTOCOL_VERSION',
authority
);
record(`${key} protocol (sidecar)`, game.protocol, Number(value), authority);
}
}
// ---------------------------------------------------------------------------
// 2. The overlay's declared protocol — the third declaration site
// 2. Each game plugin's declared protocol — the third declaration site
//
// CLAUDE.md: a protocol bump has to land in overlay.toml in the same PR as the emitters,
// or the installer refuses to pair the sidecar with the overlay. If these two ever
// disagree, the site must not print either number.
// or the installer refuses to pair the sidecar with the plugin. If these two ever
// disagree, the site must not print either number. Both games keep the file under the
// same name at the root of their plugin repository.
// ---------------------------------------------------------------------------
async function checkOverlayProtocol() {
const authority = 'servuo-plugins main:overlay.toml';
const toml = await raw('servuo-plugins', 'overlay.toml', 'main');
const value = extract(toml, /^\s*protocol\s*=\s*(\d+)\s*$/m, 'protocol', authority);
record('protocol (overlay)', platform.protocol, Number(value), authority);
for (const [key, game] of GAMES) {
const authority = `${game.pluginRepo} main:overlay.toml`;
const toml = await raw(game.pluginRepo, 'overlay.toml', 'main');
const value = extract(toml, /^\s*protocol\s*=\s*(\d+)\s*$/m, 'protocol', authority);
record(`${key} protocol (plugin)`, game.protocol, Number(value), authority);
}
}
// ---------------------------------------------------------------------------
@@ -137,7 +146,7 @@ async function checkModuleApi() {
}
// ---------------------------------------------------------------------------
// 4. The capabilities the installed module actually declares
// 4. The capabilities each game's module actually declares
//
// §12 names "module-uo's capability list" as one of the facts platform.json holds, and it
// was the one fact nothing re-read. That mattered from phase 3 onwards, because the
@@ -150,36 +159,109 @@ async function checkModuleApi() {
// array is not a changed capability set. A slug appearing or disappearing is.
// ---------------------------------------------------------------------------
async function checkModuleCapabilities() {
const authority = 'Module-uo main:module.json';
const manifest = JSON.parse(await raw('Module-uo', 'module.json', 'main'));
const declared = [...(manifest.capabilities || [])].sort();
const expected = [...platform.moduleUoCapabilities].sort();
for (const [key, game] of GAMES) {
const authority = `${game.module.repo} main:module.json`;
const manifest = JSON.parse(await raw(game.module.repo, 'module.json', 'main'));
const declared = [...(manifest.capabilities || [])].sort();
const expected = [...game.module.capabilities].sort();
record('moduleUoCapabilities', expected.join(' '), declared.join(' '), authority);
record(`${key} module capabilities`, expected.join(' '), declared.join(' '), authority);
// The range the module declares against core. The manifest docs use it as the worked
// example of a caret range resolving across minor bumps, so it is a quoted fact.
record(`${key} module coreApi`, game.module.coreApi, manifest.coreApi, authority);
}
}
// ---------------------------------------------------------------------------
// 5. The current bundle
// 5. Each game's current bundle
//
// The manifests live at the ROOT of the `bundles` branch — `current.json`,
// `bundle-<tag>.json` — not under `bundles/`. Fetching the directory 404s.
// One manifest per game on the installer's `bundles` branch, at
// `v2/<game>/current.json` (schema 2). The ROOT `current.json` is the schema-1 manifest
// from before the installer learned a second game; it is frozen and no longer read here,
// because a check against a file that stopped moving passes forever.
//
// Each bundle is protocol-checked by CI when it is published, so its protocol is a third
// independent read of the same number rather than a duplicate of checks 1 and 2.
// ---------------------------------------------------------------------------
async function checkBundle() {
const authority = 'installer bundles:current.json';
const current = JSON.parse(await raw('installer', 'current.json', 'bundles'));
async function readBundle(game) {
const file = `v2/${game.bundleGame}/current.json`;
const authority = `installer bundles:${file}`;
const current = JSON.parse(await raw('installer', file, 'bundles'));
if (current.schema !== 2) {
throw new Error(`${authority} is schema ${current.schema}, and this check reads schema 2.`);
}
return { current, authority };
}
record('bundle.tag', platform.bundle.tag, current.bundle, authority);
record('bundle.sidecar', platform.bundle.sidecar, current.link?.tag, authority);
record('bundle.overlay', platform.bundle.overlay, current.overlay?.tag, authority);
async function checkBundle() {
const { uo, rust } = platform.games;
{
const { current, authority } = await readBundle(uo);
record('uo bundle.tag', uo.bundle.tag, current.bundle, authority);
record('uo bundle.sidecar', uo.bundle.sidecar, current.sidecar?.tag, authority);
record('uo bundle.overlay', uo.bundle.overlay, current.payload?.tag, authority);
record('uo bundle.servuoMin', uo.bundle.servuoMin, current.payload?.compat?.min_version, authority);
record('uo protocol (bundle)', uo.protocol, current.protocol, authority);
}
{
const { current, authority } = await readBundle(rust);
const compat = current.payload?.compat ?? {};
record('rust bundle.tag', rust.bundle.tag, current.bundle, authority);
record('rust bundle.sidecar', rust.bundle.sidecar, current.sidecar?.tag, authority);
record('rust bundle.plugin', rust.bundle.plugin, current.payload?.tag, authority);
record('rust bundle.oxideMin', rust.bundle.oxideMin, compat.frameworks?.oxide?.min_version, authority);
record('rust bundle.carbonMin', rust.bundle.carbonMin, compat.frameworks?.carbon?.min_version, authority);
record(
'rust bundle.requiresPlugins',
[...rust.bundle.requiresPlugins].sort().join(' '),
[...(compat.requires_plugins ?? [])].sort().join(' '),
authority
);
record('rust bundle.runicnpc', rust.bundle.runicnpc, current.npc?.tag, authority);
record('rust protocol (bundle)', rust.protocol, current.protocol, authority);
// The bundle carries RunicNPC's API too; check 5b reads it from the plugin's own repo.
record('runicnpc api (bundle)', platform.runicnpc.api, current.npc?.api, authority);
}
}
// ---------------------------------------------------------------------------
// 5b. RunicNPC's own declarations
//
// RunicNPC stands alone as well as shipping in the Rust bundle (D61), so its facts are
// read from its own `plugin.toml` too — the file its release workflow folds into the
// manifest the installer and the egg read. The API version is the number a plugin author
// calling `RunicNpc_*` relies on, and the site quotes it beside the API reference (D62).
// ---------------------------------------------------------------------------
async function checkRunicNpc() {
const authority = 'runicnpc-rust main:plugin.toml';
const toml = await raw('runicnpc-rust', 'plugin.toml', 'main');
const npc = platform.runicnpc;
record('runicnpc api', npc.api, Number(extract(toml, /^\s*api\s*=\s*(\d+)\s*$/m, 'api', authority)), authority);
record(
'bundle.servuoMin',
platform.bundle.servuoMin,
current.overlay?.servuo?.min_version,
'runicnpc oxideMin',
npc.oxideMin,
extract(toml, /^\s*min_oxide_version\s*=\s*"([^"]+)"/m, 'min_oxide_version', authority),
authority
);
record(
'runicnpc carbonMin',
npc.carbonMin,
extract(toml, /^\s*min_carbon_version\s*=\s*"([^"]+)"/m, 'min_carbon_version', authority),
authority
);
const required = extract(toml, /^\s*requires_plugins\s*=\s*\[([^\]]*)\]/m, 'requires_plugins', authority)
.split(',')
.map((name) => name.trim().replace(/^"|"$/g, ''))
.filter(Boolean);
record(
'runicnpc requiresPlugins',
[...npc.requiresPlugins].sort().join(' '),
required.sort().join(' '),
authority
);
// The bundle is protocol-checked by CI when it is published, so this is a third
// independent read of the same number rather than a duplicate of check 1.
record('protocol (bundle)', platform.protocol, current.protocol, authority);
}
// ---------------------------------------------------------------------------
@@ -349,6 +431,7 @@ async function main() {
checkModuleApi,
checkModuleCapabilities,
checkBundle,
checkRunicNpc,
checkReleases,
checkWebsiteHasNoReleases,
checkAndroidApk,

View File

@@ -69,7 +69,7 @@ const UOLINK_TOKEN = process.env.UOLINK_TOKEN || '';
// `platform.json` means the number is the one `checkFacts.mjs` verified against `link`'s
// `main` — so the rig cannot quietly drift two protocols behind the platform again, which is
// exactly what the literal did.
const UOLINK_PROTOCOL = Number(process.env.UOLINK_PROTOCOL || platform.protocol);
const UOLINK_PROTOCOL = Number(process.env.UOLINK_PROTOCOL || platform.games.uo.protocol);
const DRY = process.argv.includes('--dry-run');

View File

@@ -62,8 +62,9 @@ const isExternal = (href: string) => href.startsWith('http');
</span>
</p>
<p class="site-footer__meta">
<span class="chip chip--version">Protocol {platform.protocol}</span>
<span class="chip chip--version">Bundle {platform.bundle.tag}</span>
{/* D66, as on the hero: the shared version only. Each game's protocol and bundle
are on that game's pages. */}
<span class="chip chip--version">Module API {platform.moduleApi}</span>
</p>
</div>
</div>

View File

@@ -53,7 +53,9 @@ const application = {
// client. Not "Windows" — the installer runs there, the platform does not require it.
operatingSystem: 'Linux, Windows, Android',
license: 'https://www.gnu.org/licenses/gpl-3.0.html',
softwareVersion: platform.bundle.tag,
// D66: the Module API is the one version every deployment shares. Each game has its own
// bundle, so no single bundle tag describes the platform any more.
softwareVersion: platform.moduleApi,
publisher: { '@id': url('/#organization') },
// Self-hosted and free, and `offers` is the only way the vocabulary can say so. Omitting
// it reads as "price unknown"; stating zero is simply true.

View File

@@ -8,9 +8,11 @@ import platform from '../../data/platform.json';
* The other two draw runtime shapes. This one draws a code boundary, and it is here because
* it is the claim the whole project rests on: that a community platform can be built once
* and pointed at any game. An evaluator has every reason to read that as marketing, so the
* page draws the seam and then says plainly what does and does not prove it — one module
* exists, the second is a paper exercise, and the exit criterion for calling the contract
* proven is written down (§2, and the entries `/modules/` renders from `notBuilt.mjs`).
* page draws the seam and then says plainly what does and does not prove it — two modules
* exist, both built inside this project, and the exit criterion for calling the contract
* proven by an outsider is written down (§2, §16, and the entries `/modules/` renders from
* `notBuilt.mjs`). Until phase 13 this said one module and a paper exercise; Module-Rust's
* release made that false.
*
* The Module API version is read from `platform.json` like every other number on this site
* (§12). It is the one place a version genuinely belongs in this diagram: the seam is
@@ -73,7 +75,7 @@ import platform from '../../data/platform.json';
<!-- The module: everything that knows a game exists. -->
<rect class="host" x="8" y="306" width="364" height="186" rx="14" />
<text class="host-title" x="28" y="340">Game module</text>
<text class="host-sub" x="28" y="360">one per deployment; UO today</text>
<text class="host-sub" x="28" y="360">one per deployment; UO or Rust today</text>
<rect class="node" x="28" y="378" width="156" height="46" rx="10" />
<text class="node-title" x="46" y="406">Routes</text>
@@ -130,11 +132,12 @@ import platform from '../../data/platform.json';
<section>
<h3>What this does not yet prove</h3>
<p>
One module exists and it is Ultima Online. A second, for a different game, is a
written dry-run that was deliberately never implemented — it exists to test whether
the contract generalises on paper. Until somebody builds the second one, the seam is
a well-argued design rather than a demonstrated one, and this site says so wherever
it comes up.
Two modules exist: Ultima Online, and Rust, which this project built second, from
the Integration Kit. Where the Rust module needed something the contract did not
yet offer, the gap was closed in core, for every module, rather than in a fork — so
the seam now has two games on its far side instead of one. What two modules built
inside this project cannot prove is that somebody outside it can do the same, and
that is why the Integration Kit still says draft.
</p>
</section>
</div>

View File

@@ -17,7 +17,7 @@ import { capabilityGroups, assertCapabilityCoverage } from '../../data/capabilit
* what exists while quietly omitting the well-known things that do not is the exact failure
* §1 is written to prevent.
*/
assertCapabilityCoverage(platform.moduleUoCapabilities);
assertCapabilityCoverage(platform.games.uo.module.capabilities);
---
<section class="page section caps">
@@ -52,7 +52,7 @@ assertCapabilityCoverage(platform.moduleUoCapabilities);
<p class="caps__foot prose">
Some things people reasonably expect are <strong>deliberately not built</strong> — a Matrix
integration, more than one game module active at once, a second game module. They are
integration, more than one game module active at once, a finished Integration Kit. They are
listed rather than left out, on <a href="/features/">features</a> and{' '}
<a href="/integrations/">integrations</a>.
</p>

View File

@@ -1,12 +1,10 @@
---
import platform from '../../data/platform.json';
/**
* The data path (PLAN.md §13 phase 3), drawn as inline SVG per §11's motif rule — hand-drawn
* geometry, used where it explains something, and no raster anywhere.
*
* ---------------------------------------------------------------------------------------
* THE LABELS ARE GENERIC, WITH UO AS THE CAPTION
* THE LABELS ARE GENERIC; THE CAPTION NAMES THE GAMES
* ---------------------------------------------------------------------------------------
* The org lead settled this before the diagram was drawn. The nodes say "your game server"
* and "sidecar", not "ServUO shard" and "uo-link", because §10's rule is that a reader
@@ -14,10 +12,12 @@ import platform from '../../data/platform.json';
* repositories in order to connect a game server — and because the tagline promises a
* platform, not a UO product.
*
* It does NOT hide what actually ships. The sub-labels and the caption name ServUO and
* uo-link outright, because §1 says the technical truth wins and today there is exactly one
* implementation of this shape. An operator running a shard has to see themselves in the
* picture on the first screen.
* It does NOT hide what actually ships: the caption names the games that ship on this shape,
* because §1 says the technical truth wins and an operator has to see their game in the
* picture on the first screen. Until phase 13 the sub-labels named ServUO and uo-link, which
* was true while there was exactly one implementation. With two, naming one would mislead,
* and D63 (PLAN.md §16) keeps each game's vocabulary on that game's own pages — so the
* caption names the games and the diagram names nothing else.
*
* ---------------------------------------------------------------------------------------
* WHY THE SVG IS aria-hidden
@@ -74,11 +74,11 @@ import platform from '../../data/platform.json';
<rect class="node" x="20" y="16" width="340" height="76" rx="12" />
<text class="node-title" x="42" y="50">Your game server</text>
<text class="node-sub" x="42" y="72">ServUO today · opens no inbound port</text>
<text class="node-sub" x="42" y="72">opens no inbound port</text>
<rect class="node" x="20" y="148" width="340" height="76" rx="12" />
<text class="node-title" x="42" y="182">Sidecar</text>
<text class="node-sub" x="42" y="204">uo-link · the only network-facing part</text>
<text class="node-sub" x="42" y="204">the only network-facing part</text>
<rect class="node node--self" x="20" y="280" width="340" height="76" rx="12" />
<text class="node-title" x="42" y="314">Runic Gateway</text>
@@ -90,8 +90,9 @@ import platform from '../../data/platform.json';
</svg>
<p class="datapath__caption">
Today that game server is a ServUO shard and that sidecar is uo-link. The shape is the
contract; the implementations are what plug into it.
Two games ship on this shape today, Ultima Online and Rust, each with its own plugin
and its own sidecar. The shape is the contract; the implementations are what plug
into it.
</p>
</div>
@@ -109,8 +110,8 @@ import platform from '../../data/platform.json';
<h3>The sidecar</h3>
<p>
A small service beside the game, and the only piece of the bridge anything else can
reach. It speaks a versioned wire protocol — protocol {platform.protocol} today — so
a mismatched pair is refused rather than misread, and it answers only your website's
reach. It speaks a versioned wire protocol — each game pins its own — so a
mismatched pair is refused rather than misread, and it answers only your website's
backend, over an authenticated WebSocket and REST.
</p>
</li>

View File

@@ -55,9 +55,9 @@ import platform from '../../data/platform.json';
</div>
<div class="chips">
<span class="chip chip--version">Protocol {platform.protocol}</span>
{/* D66: only the version every deployment shares. A protocol and a bundle belong
to a game, so they live on that game's pages rather than on the front door. */}
<span class="chip chip--version">Module API {platform.moduleApi}</span>
<span class="chip chip--version">Bundle {platform.bundle.tag}</span>
</div>
</div>

View File

@@ -41,6 +41,10 @@ Two consequences of how boots work:
Paste the new release's install-manifest URL and press Install. The bundle is verified
against its `sha256`, unpacked over the old one, and takes effect at the restart.
**A site runs one module.** Installing a module with a different id beside the one you have
is refused with `409`, and nothing is downloaded. To change game, remove the installed module
first.
An upgrade **deliberately leaves the state alone**: upgrading an enabled module must not
silently switch it off, and re-installing a disabled one must not silently switch it on.

View File

@@ -26,7 +26,7 @@ on:
| **Base URL (REST)** | `http://<shard host>:8080` — point-in-time queries |
| **WebSocket URL (feed)** | `ws://<shard host>:8080/ws` — the live event feed |
| **Auth token** | The sidecar's token |
| **Protocol** | {platform.protocol} today |
| **Protocol** | {platform.games.uo.protocol} today |
Saving restarts the ingest client, so a change takes effect immediately.

View File

@@ -10,7 +10,11 @@ The loopback wire protocol between the game plugin and the sidecar is a **versio
compatibility contract**, not a build dependency. Nothing compiles the three sides together,
so the number is what stops a mismatch from being discovered as corrupted data.
The current protocol is **{platform.protocol}**.
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.
## Three declaration sites
@@ -18,8 +22,8 @@ The same number is written down in three places, and they must move together.
| Where | What declares it |
|---|---|
| `link/sidecar/src/main.rs` | `PROTOCOL_VERSION`, currently {platform.protocol} — what the sidecar speaks |
| `servuo-plugins/overlay.toml` | `protocol`, currently {platform.protocol} — what the plugin overlay speaks |
| `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 |
<Aside type="caution" title="Bump the overlay in the same PR as the emitters">

View File

@@ -71,7 +71,7 @@ internet; only the sidecar is exposed, and only to your site.
<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.bundle.tag} today — sidecar {platform.bundle.sidecar}, overlay {platform.bundle.overlay}).
({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>
@@ -83,7 +83,7 @@ features need edits to stock ServUO sources, and those are opt-in, off unless yo
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.bundle.servuoMin}. On any
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.

View File

@@ -60,7 +60,7 @@ Watch the log at the restart and you will see exactly what it mounted:
[modules] module "uo" started
```
Its capabilities are {platform.moduleUoCapabilities.join(', ')} — the shard console, the
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.

View File

@@ -35,7 +35,7 @@ the [`uo` module](/docs/getting-started/install-a-game-module/) and the installe
| 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.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.bundle.servuoMin}; on any other version it is unsupported, and skipping it still leaves you with a working bridge. |
| **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. |

View File

@@ -15,19 +15,32 @@ public event stream.
## What the platform is on today
Two versions are shared by every deployment, whichever game it runs:
<table>
<tbody>
<tr><td>Wire protocol</td><td>{platform.protocol}</td></tr>
<tr><td>Module API</td><td>{platform.moduleApi}</td></tr>
<tr><td>Current bundle</td><td>{platform.bundle.tag}</td></tr>
<tr><td>uo-link sidecar</td><td>{platform.releases.link}</td></tr>
<tr><td>Plugin overlay</td><td>{platform.bundle.overlay}</td></tr>
<tr><td>Installer</td><td>{platform.releases.installer}</td></tr>
<tr><td>ServUO (minimum)</td><td>{platform.bundle.servuoMin}</td></tr>
</tbody>
</table>
Every value in that table is read from `src/data/platform.json` and re-checked against its
Everything else belongs to a game. Each game has its own wire protocol and its own bundle —
the exact, protocol-checked pair of sidecar and game plugin the installer deploys:
<table>
<thead>
<tr><th></th><th>{platform.games.uo.name}</th><th>{platform.games.rust.name}</th></tr>
</thead>
<tbody>
<tr><td>Game module</td><td>{platform.releases['Module-uo']}</td><td>{platform.releases['Module-Rust']}</td></tr>
<tr><td>Wire protocol</td><td>{platform.games.uo.protocol}</td><td>{platform.games.rust.protocol}</td></tr>
<tr><td>Current bundle</td><td>{platform.games.uo.bundle.tag}</td><td>{platform.games.rust.bundle.tag}</td></tr>
<tr><td>Sidecar</td><td>{platform.games.uo.bundle.sidecar}</td><td>{platform.games.rust.bundle.sidecar}</td></tr>
<tr><td>Game plugin</td><td>{platform.games.uo.bundle.overlay}</td><td>{platform.games.rust.bundle.plugin}</td></tr>
</tbody>
</table>
Every value in those tables is read from `src/data/platform.json` and re-checked against its
source of truth on each build. None of it is typed into prose — including here.
## An install is two installs
@@ -39,8 +52,8 @@ two independent deployments.
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.
They meet at four values pasted into the module's shard screen, and at protocol
{platform.protocol}, which both sides check before they will pair.
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.
You can stop after the first one. A site with no game server attached is a complete
community website — news, wiki, pages, Teams, forums, accounts, moderation and the event

View File

@@ -29,6 +29,13 @@ Two artifacts:
The manifest URL is the thing an operator pastes. The bundle is downloaded, **verified
against the `sha256`**, and unpacked into `modules/<id>/` on the mounted volume.
<Aside type="caution" title="A site runs one module">
An install whose `id` differs from a module already on the volume is refused with `409`
before anything is downloaded. Upgrading the installed module is the only install a
populated site accepts; to change game, remove the module first. The loader enforces the
same rule at boot — see [Module lifecycle](/docs/modules/module-lifecycle/#the-scan).
</Aside>
Nothing is compiled at any point in that sequence.
## The two surfaces

View File

@@ -18,10 +18,17 @@ time**. The database is not consulted: what is on the volume determines what mou
missing modules directory is not an error** — "no modules installed" is the normal state of
bare core, and the loader must not make the mount mandatory to boot.
Modules load **alphabetically by `id`**, deterministically. There is no dependency
resolution between modules, and alphabetical order is the honest way of saying so: any
other order would imply a precedence nobody is computing. Do not build a module that needs
to load before or after another one.
**A site runs one module.** The scan is alphabetical by directory name, and the first
module directory it finds holds the site, whether or not that module then loads. Every
other module directory is recorded `startup_failed` at the manifest stage **before any of
its code is required**: no routes, navigation, tables or registrations. Its reason, in the
log and on **Admin → Modules**, names both modules and says to remove one. The installer
refuses the same thing earlier — an install whose id differs from the module already on the
volume answers `409` before anything is downloaded — so the loader's rule is what catches a
directory copied onto the volume by hand.
There is no dependency resolution between modules, because there is only ever one running.
Do not build a module that expects another to load beside it.
## Validation, in order

View File

@@ -49,7 +49,9 @@ your game server ──dials out──▶ your sidecar ──HTTP + WS──
Before any of them, the kit points at the [Rust dry
run](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/modules/rust-dryrun.md)
— a complete module designed on paper for a second game, and the shortest honest picture of
the whole job.
the 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
reads as the plan beside a finished result.
## The template is built, not just quoted

View File

@@ -4,6 +4,7 @@ description: The two arguments core hands your module — what you can reach, wh
---
import { Aside } from '@astrojs/starlight/components';
import platform from '../../../../data/platform.json';
Your server entry point exports one function:
@@ -14,7 +15,7 @@ module.exports = function register(ctx, api) { /* … */ }
`ctx` is what core lends you. `api` is what you register with it. Everything crossing the
module boundary goes through one of the two.
The contract version is **`MODULE_API_VERSION`**, currently **1.6.0**, and your manifest's
The contract version is **`MODULE_API_VERSION`**, currently **{platform.moduleApi}**, and your manifest's
[`coreApi` range](/docs/modules/the-module-manifest/#coreapi-and-what-a-range-means) is
checked against it before your code is required.

View File

@@ -4,6 +4,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';
`module.json` sits at the root of your bundle. The loader reads it synchronously, before
anything else about your module runs.
@@ -78,12 +79,14 @@ repository is branching on them.
## `coreApi` and what a range means
Core exports a single semver string, currently **1.6.0**. Your range is checked at boot,
before your code is required.
Core exports a single semver string, currently **{platform.moduleApi}**. Your range is
checked at boot, before your code is required.
A **minor** bump adds members without removing any or changing a signature, so `^1.3.0`
keeps resolving against 1.6.0 — which is exactly why `module-uo` still declares `^1.3.0`
and runs fine.
A **minor** bump adds members without removing any or changing a signature, so an older
caret range keeps resolving against a newer core. The {platform.games.uo.name} module
declares <code>{platform.games.uo.module.coreApi}</code> and runs on {platform.moduleApi};
the {platform.games.rust.name} module declares
<code>{platform.games.rust.module.coreApi}</code>.
Use a caret range against the oldest core you actually support and test against. Pinning
exactly buys nothing and strands you on the next additive release.

View File

@@ -23,7 +23,7 @@
* capability slugs the module actually declares in its `module.json`, and
* `assertCapabilityCoverage()` fails the build if the two lists drift apart.
*
* That closes a real gap. `platform.json` holds `moduleUoCapabilities` and
* That closes a real gap. `platform.json` holds `games.uo.module.capabilities` and
* `scripts/checkFacts.mjs` re-reads it from the module's manifest on every build — so the
* day `module-uo` gains a capability, the JSON goes red and someone updates it. Before this
* function, updating the JSON was the end of it and the page kept the old list. Now the
@@ -188,8 +188,9 @@ const gameIntelligence = {
title: 'Game intelligence',
moduleSupplied: true,
summary:
'Supplied by the installed game module, not by the core site. Today that module is ' +
'module-uo, and this is what it publishes from a live shard.',
'Supplied by the installed game module, not by the core site. Two modules exist, for ' +
'Ultima Online and for Rust; this is what the Ultima Online module publishes from a ' +
'live game server, and the Rust module brings its own set in its place.',
items: [
{
label: 'Live server status',
@@ -490,7 +491,7 @@ export function assertCapabilityCoverage(declared) {
}
throw new Error(
`src/data/capabilities.mjs disagrees with platform.json's moduleUoCapabilities:\n` +
`src/data/capabilities.mjs disagrees with platform.json's games.uo.module.capabilities:\n` +
lines.map((line) => ` - ${line}`).join('\n') +
`\n\nUpdate the "Game intelligence" items, or the JSON if the module itself changed.\n`
);

View File

@@ -78,26 +78,18 @@ export const notBuilt = [
scope: ['features', 'integrations', 'modules'],
title: 'More than one game module at a time',
body:
'One active module per deployment. The database columns that would scope data to a ' +
'module exist and are populated, so the door is not nailed shut, but nothing ' +
'exercises them and no interface offers it. A community running two games runs two ' +
'deployments.',
'One module per deployment, and the site enforces it: it loads one module at boot ' +
'and refuses every other, with a message saying so, rather than letting two games ' +
'share one site. The database columns that would scope data to a module exist and ' +
'are populated, so the door is not nailed shut, but no interface offers it. A ' +
'community running two games runs two deployments.',
resolvedBy:
'Someone needing it. The schema was shaped to keep it possible, which is a different ' +
'thing from planning it.',
},
{
id: 'second-module',
scope: ['integrations', 'modules'],
title: 'A second game module',
body:
'There is exactly one, and it is Ultima Online. A paper dry-run for a Rust module ' +
'exists and is deliberately unimplemented — it was written to test whether the ' +
'module contract generalises, not to ship. Until a second one exists, "any game" is ' +
'an argument about a shape rather than a demonstration.',
resolvedBy: 'The first module built for a game that is not Ultima Online.',
link: { href: `${GITEA}/docs/src/branch/main/modules/rust-dryrun.md`, label: 'The dry-run' },
},
// `second-module` was here until phase 13 (PLAN.md §16): "a second game module", resolved
// by exactly the event its own `resolvedBy` named — the Rust module's release. Removed
// rather than reworded, because an absence that is no longer absent is not an entry.
{
id: 'integration-kit-draft',
scope: ['integrations', 'modules'],

View File

@@ -12,24 +12,28 @@
"wrong protocol number in the first place."
],
"verifiedOn": "2026-09-15",
"verifiedOn": "2026-10-22",
"protocol": 8,
"moduleApi": "1.10.0",
"bundle": {
"tag": "2026.09.15",
"sidecar": "v2.3.0",
"overlay": "v1.3.0",
"servuoMin": "57.4"
},
"$comment_moduleApi": [
"The one version every deployment shares, whichever game it runs. D66: it is the only",
"version the homepage hero and the footer show — a protocol and a bundle belong to a game,",
"so they appear on that game's own pages."
],
"moduleApi": "1.17.0",
"$comment_releases": [
"Every repository whose release the site quotes, flat, so checkFacts.mjs reads them all",
"the same way. Which game a repository belongs to is said once, under `games` below."
],
"releases": {
"installer": "v0.8.0",
"Android-app": "v0.5.0",
"link": "v2.3.0",
"installer": "v0.2.0",
"Module-uo": "v1.3.0",
"Android-app": "v0.5.0"
"Rust-Link": "v0.3.0",
"Rust-Plugins": "v0.3.0",
"Module-Rust": "v0.5.0",
"runicnpc-rust": "v1.0.0"
},
"$comment_website": [
@@ -40,16 +44,70 @@
],
"websiteHasReleases": false,
"moduleUoCapabilities": [
"shard",
"atlas",
"market",
"governors",
"guilds",
"houses",
"champs",
"cliloc"
"$comment_games": [
"PLAN.md §16. One entry per game the platform supports. Each game has its own wire",
"protocol (declared by its sidecar AND its game-side plugin, and re-read from both) and",
"its own bundle on the installer's `bundles` branch, at `v2/<bundleGame>/current.json`.",
"The root `current.json` is the schema-1 manifest and is no longer read: it stopped",
"moving when the installer went to one bundle per game.",
"",
"`module.capabilities` is re-read from the module's own module.json, unsorted order being",
"the module's business. `name` is the game's own name — D63 lets the site at large say it."
],
"games": {
"uo": {
"name": "Ultima Online",
"bundleGame": "servuo",
"protocol": 8,
"module": {
"repo": "Module-uo",
"coreApi": "^1.10.0",
"capabilities": ["shard", "atlas", "market", "governors", "guilds", "houses", "champs", "cliloc"]
},
"sidecarRepo": "link",
"pluginRepo": "servuo-plugins",
"bundle": {
"tag": "2026.09.15",
"sidecar": "v2.3.0",
"overlay": "v1.3.0",
"servuoMin": "57.4"
}
},
"rust": {
"name": "Rust",
"bundleGame": "rust",
"protocol": 14,
"module": {
"repo": "Module-Rust",
"coreApi": "^1.17.0",
"capabilities": ["rust", "servers", "killfeed", "leaderboard", "presence", "wipes", "identity", "map"]
},
"sidecarRepo": "Rust-Link",
"pluginRepo": "Rust-Plugins",
"bundle": {
"tag": "2026.10.22",
"sidecar": "v0.3.0",
"plugin": "v0.3.0",
"oxideMin": "2.0.7585",
"carbonMin": "2.0.259",
"requiresPlugins": ["Kits", "ZoneManager"],
"runicnpc": "v1.0.0"
}
}
},
"$comment_runicnpc": [
"RunicNPC — the Rust NPC plugin (PLAN.md §16, D61). It ships in the Rust bundle and also",
"stands alone, so it has facts of its own: `api` is the version a calling plugin relies",
"on, declared in runicnpc-rust's plugin.toml and re-read from there and from the bundle.",
"Its framework floors are its own and differ from the bridge's."
],
"runicnpc": {
"api": 6,
"oxideMin": "2.0.7726",
"carbonMin": "2.0.259",
"requiresPlugins": ["Kits"]
},
"androidApplicationId": "com.runicgateway.app",

View File

@@ -256,7 +256,7 @@ export const canonicalDocs = {
'link/ADMIN_CONTROLS.md': 'What the site may command the game to do',
'installer/INSTALL.md': 'The operator guide for setting a shard up',
'installer/PLAN.md': "The installer's design of record",
'modules/rust-dryrun.md': 'A second module designed on paper, to test that the contract generalises',
'modules/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',
};

View File

@@ -83,8 +83,8 @@ const docs = `${platform.gitea.base}/${platform.gitea.org}/docs/src/branch/main`
</a>
<span
>What the game and the sidecar say to each other, and what the sidecar publishes.
Protocol {platform.protocol} today, and versioned so a mismatched pair is refused
rather than misread.</span
Each game pins its own protocol version, so a mismatched pair is refused rather
than misread.</span
>
</li>
<li>

View File

@@ -135,10 +135,9 @@ const gitea = `${platform.gitea.base}/${platform.gitea.org}`;
<li>
<h3>Build a module for another game</h3>
<p>
There is one module and it is Ultima Online, so the claim that this platform is
game-agnostic is currently an argument rather than a demonstration. The
<a href="/modules/">Integration Kit</a> exists to be followed by somebody outside
this project — and it stays marked draft until it has been.
There are two modules, Ultima Online and Rust, and both were built inside this
project. The <a href="/modules/">Integration Kit</a> exists to be followed by
somebody outside it — and it stays marked draft until it has been.
</p>
</li>
<li>

View File

@@ -53,7 +53,7 @@ import {
* matches bytes, and a stock build hides every one of these links, so a mistake here is
* invisible until the day somebody configures a demo.
*/
assertCapabilityCoverage(platform.moduleUoCapabilities);
assertCapabilityCoverage(platform.games.uo.module.capabilities);
assertDetailCoverage();
/**
@@ -91,9 +91,10 @@ const description =
>.
</p>
<p>
Today there is one module and it covers Ultima Online, so the second group below is
what a UO deployment gets. On a deployment with no module, that group is simply absent
and the other four are unchanged.
Two modules exist today, for Ultima Online and for Rust, and a deployment runs one of
them. The second group below is what the Ultima Online module adds; the Rust module
brings its own set in its place. On a deployment with no module, that group is simply
absent and the other four are unchanged.
</p>
</PageHeader>

View File

@@ -201,7 +201,7 @@ const integrations = [
server, and an installed module merges its own routes into it — so whatever you build
against is documented by the thing that is actually running, at
{' '}Module API {platform.moduleApi}. The bridge to a game server is a documented wire
protocol on the same principle, currently protocol {platform.protocol}.
protocol on the same principle, versioned per game.
</p>
<div class="integ__actions">
<a class="btn btn--primary" href="/modules/">How modules work</a>