Core seeded `game_account_signup` and `uo_link_protocol_3_migrated`, two keys that name a game concept. That made core's schema declare a module's settings, which is the structural half of what Phase 3 removes (MODULE_SYSTEM.md §2.7.1, slice 4). Both INSERTs move here. The keys are deliberately unchanged: they are live rows on every existing install and renaming one silently resets an operator's choice to the default. The marker is not just a tidy-up. It and the `UPDATE uo_link_config SET protocol = 3` it makes one-shot were adjacent in core's schema.sql until slice 1 moved the UPDATE here and left the INSERT behind — and the two files do not run together: core's schema is replayed in full before any module fragment. So the marker existed before the UPDATE ever read it, the NOT EXISTS guard was false on every boot of an upgraded install, and the migration could never fire. An install carrying a protocol-2 row would have stayed pinned at 2 against a v3 sidecar, 409ing every REST call — the exact failure the migration prevents. Latent rather than live: it bites only an install that first boots a post-slice-1 build while already holding a uo_link_config row, and `edge` has not cut over. `schemaFragment.test.js` asserts the order, plus the fragment rules core validates at load time (leading-verb allowlist, IF NOT EXISTS, grandfathered table prefixes) — restated here for the same reason manifest.test.js restates the manifest rules. Its statement splitter is a character walk, because a comment in this file contains quotes. Co-Authored-By: Claude <noreply@anthropic.com>
module-uo — the Ultima Online module for Runic Gateway
The Runic Gateway website is becoming game-agnostic: core keeps accounts, sessions, the wiki, posts, branding, theming and the admin panel, and everything that knows what a shard is moves out into an installable module. This repo is that module — the first one, and the reference for every module that follows.
RunicGateway/website (core — game-agnostic)
│ loads modules at boot, synchronously, from the filesystem
▼
┌───────────────────────────────────────────┐
│ module-uo (>>> HERE <<<) │
│ shard status · spawn atlas · marketplace │
│ governors · cliloc · town crier · uo-link│
└───────────────────────────────────────────┘
│ server half: routers, models, schema fragment
│ client half: prebuilt ESM chunk, SPA routes + nav
▼
the shard bridge (RunicGateway/link → RunicGateway/servuo-plugins)
The module's id is uo — that is what appears in module.json, in the installed_modules
table, in the modules/<id>/ path on disk and in the URL segment (/uo/*, /admin/uo/*,
/player/uo/*). Module-uo is the repository; module-uo is the module and its release artifact.
Status: the bundle skeleton exists; the extraction has started
The design of record is
website/MODULE_SYSTEM.md
and the normative contract is
website/MODULE_API.md
in the docs repo — read them before opening a PR here. Where the two differ, the contract wins.
| Phase | Where it happens | State |
|---|---|---|
0 — CI trigger fix, cut website edge, bootstrap this repo |
website, here |
✅ done |
1 — module API contract (docs/website/MODULE_API.md) + the atlas spike |
docs, website |
✅ done |
2 — core scaffolding: loader, installed_modules, registries, client registry |
website |
✅ done |
| 3 — extract the UO half of the site into this repo | website, here |
🟡 in progress |
| 4 — delivery: the admin Modules screen + the Docker path | website |
⬜ |
Phase 3 moves the UO half of website/ here in ten slices (MODULE_SYSTEM.md §2.7.1), server-first
and then client. Each slice is one PR here that adds, and one PR in website that deletes — this one
merging first, so website's edge branch serves the feature from core right up to the moment core
drops it.
Slice 0 is the bundle skeleton, and it registers nothing on purpose. What it proves is the
delivery path itself: core discovers the module, validates module.json, calls register(), serves
the client chunk, injects it, and reports the module started — and the chunk resolves React, the
renderer and the router from core's window.__rg rather than bundling its own. Every slice after
this one adds registrations to server/index.js and client/src/entry.jsx.
Working on it
npm ci --prefix server && npm test --prefix server && npm run check:imports --prefix server
npm ci --prefix client && npm test --prefix client && npm run build --prefix client
npm run check:externals --prefix client # asks the BUILT chunk, so it runs after the build
The two check:* scripts are the contract's acceptance criteria rather than this module's own tests:
no import may escape the module root (MODULE_API.md §5.1), and no bare specifier may survive into
the built chunk (§3.6). The matching failure — a shared dependency being bundled — fails the build
itself, from a guard inside vite.config.js.
Running it against a real core means checking this repo out as website/modules/uo, building the
client half, and booting core. The four-step browser smoke in MODULE_API.md §7.7 is the only thing
that proves the client half works: its real failure modes are timing and module resolution, and
neither has a shape a DOM-less test runner can see.
What it contains
One repo, one bundle: the server half and the client half live side by side and version together, so a route and the screen that calls it can never be mismatched. A ✅ is in the tree today.
module.json ✅ id, version, coreApi range, mounts, extensions
server/index.js ✅ the entry point — register(ctx, api), synchronous, no database
server/scripts/ ✅ checkImports.js — the §5.1 boundary check
server/test/ ✅ node --test, with a fake ctx standing in for core
server/ routers, controllers, models, utils
server/db/schema.sql idempotent fragment, replayed by core's ensureSchema()
server/db/purge.sql destructive; only ever run by an explicit purge
client/src/entry.jsx ✅ the chunk's entry — registers routes, nav, feature provider
client/src/shim/ ✅ react, react-dom, react-router-dom, jsx-runtime, from window.__rg
client/vite.config.js ✅ the library build, the aliases, the not-bundled guard
client/src/ route components, nav registrations, feature provider
client/dist/ ✅ PREBUILT ESM chunk, built by CI — never by an operator
Release artifact: module-uo-<version>.tar.gz, plus a manifest carrying its sha256.
How it reaches an operator
An operator never builds anything. Installing a module is the WordPress-plugin experience: an
admin-panel action, or a directory mounted into the Docker container — never a build step, because
production runs a prebuilt, pull-only image. That constraint is why the client half ships as a
prebuilt ESM chunk that resolves React from a window.__rg global rather than an import map (an
import map must be inline, and the site's CSP is script-src 'self').
The installer is not the delivery path. It deploys the shard side — the plugin overlay and the uo-link sidecar — and never contacts the website. Module delivery is website-side only.
Compatibility
module.json declares a coreApi semver range, checked at boot against core's MODULE_API_VERSION.
A mismatch fails loudly — the module is marked startup_failed and the site comes up without it,
rather than mis-loading. This is a separate number from PROTOCOL_VERSION, which versions the shard
wire protocol and says nothing about a website module.
A module that fails to load must never take the site down.
Related repos
| Repo | What |
|---|---|
this — RunicGateway/Module-uo |
The UO module: the game-specific half of the website. |
| RunicGateway/website | Core — the site, admin panel and API that loads this module. |
| RunicGateway/link | The uo-link sidecar — the network-facing half of the game bridge this module talks to. |
| RunicGateway/servuo-plugins | The C# ServUO plugin that feeds the sidecar. |
| RunicGateway/installer | Deploys the shard side. Not the module delivery path. |
| RunicGateway/docs | All project documentation, including the module system design and this module's docs under modules/uo/. |
Contributing
See CONTRIBUTING.md. Contributions are welcome, AI assistance must be disclosed, and security problems go to SECURITY.md rather than a public issue.
License
GNU General Public License v3.0 or later — see LICENSE.md.