The website half of the protocol-5 bump. Engagement Phase 10.
Schema — twelve columns and two indexes.
shard_houses gains next_stage, estimated_collapse, decay_period_sec and
dynamic_decay. estimated_collapse is nullable and stays null far more often than
not, deliberately: under dynamic decay ServUO draws each stage at random on entry,
so collapse is knowable only at IDOC. A null means "not knowable", never "not yet
read".
shard_vendors gains owner_acct plus seven fee columns and an index on dismissal_at.
owner_acct is the structural one — the table has carried owner_name since protocol
3, but a character name joins to nothing, and only the game account reaches
shard_account_links. Until now a vendor row named an owner the site could not
resolve to a person. dismissal_at + owner_acct are what let Phase 11's
uo.vendor.expiring find "vendors about to be dismissed" and turn each into a
person, without scanning every shop.
Ingest.
Both new field groups arrive NESTED and are flattened into columns on the way in,
then re-nested on the way out — the same trick shardMarket already uses for
`location`. That is not stylistic: the visibility projection matches literal JSON
keys, so the stored read model and the live wire frame have to spell a group
identically or one admin rule covers only one of the two paths. It also means a
field added inside a group later inherits the group's gate instead of defaulting to
visible; there is a test that adds an imaginary future fee field and asserts exactly
that.
Two write-back asymmetries, both load-bearing:
* ownerName is written ONLY when the frame carries one. house.update also writes
that column, from a different sweep, and a pre-v5 overlay's house.decay carries
no ownerName at all — coalescing to null would let every decay transition erase
a name the registry had already resolved.
* The schedule and fee columns are written UNCONDITIONALLY, including as nulls. A
schedule is a claim about the future and goes stale on its own: roll a shard
back to a pre-v5 overlay, or let a house leave IDOC, and the right stored value
is nothing. A dismissal date nobody is maintaining is worse than none.
dismissalAt is taken from the shard rather than recomputed. The shard resolved it
against ServUO's two vendor systems, whose charge, funds and pay interval all
differ; re-deriving it here would be a second implementation of PlayerVendor's own
rule.
Visibility — three classifications, each chosen rather than inherited.
* house.decay's `schedule` defaults to `anonymous`. The countdown IS the public
IDOC page's content and a house at IDOC is already announced in game. Listed
anyway so a shard that considers a precise collapse time an unfair advantage can
raise it — and one nested rule takes the whole schedule with it.
* vendor.listing's `fees` defaults to `admin`, the only default in the market
feature that does not reproduce prior behaviour, because there is no prior
behaviour to reproduce. Shop name, owner and location are already visible to any
player through the in-game Vendor Search gump, which is the argument for
publishing them. Held gold, daily charge and dismissal date are visible to the
OWNER only, on that vendor's own gump. Publishing them anonymously would be a
new disclosure and a targeting aid — which shops are about to be abandoned, and
how much coin is in each.
* account.login.result is admin-only BY OMISSION. KIND_FEATURE is the map of kinds
an admin may widen, and there is no rung below admin that an IP plus an auth
verdict belongs on. The omission is the decision, and a test says so by name.
owner_acct needs no rule: rule 1 locks it by suffix. And the new columns are in no
REST read model's column list — they exist for Phase 11's server-side trigger and
reach no client at all.
The pin, and the protocol-4 bug seen from the other side.
Both declaration sites go to 5 (the model constant and schema.sql's CREATE default),
plus the one-shot migration, guarded `protocol < 5` so an install that missed an
earlier step is carried the whole way.
The schema test used to assert `DEFAULT 4` at each site. That is exactly how
protocol 4 shipped with the emitters moved and one site left behind: every site
agreed with itself and the test passed. It now reads DEFAULT_PROTOCOL from the
model, so the assertion is "the declarations AGREE", and the one-shot migration
test is written once against the current version instead of being hand-copied per
bump.
470 tests pass, 16 new. Verified end to end on the live rig against a real ServUO
and the release sidecar.
Docs: RunicGateway/docs link/v5.md.
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 extraction is complete; this repo is the UO half of the site
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 |
✅ done |
| 4 — delivery: the admin Modules screen + the Docker path | website |
✅ done |
Phase 3 moved the UO half of website/ here in six slices (MODULE_SYSTEM.md §2.7.1): the bundle
skeleton, the whole server half, core's client extension slots, the whole client half, the de-UO of
core's own copy, and this one — the artifacts that make the result installable and checkable. Each
slice was one PR here that added and one in website that deleted, this one merging first, so
website's edge branch served each feature from core right up to the moment core dropped it.
Neither half sliced by feature in the end, and for the same reason on both sides: a mount prefix is claimed whole and a shared leaf moves with its last consumer, so the closure of either half is the whole half.
What core serves and what this repo serves is now a fact you can read, not a claim: 72 URLs, in
routes.manifest.json, derived by loading this module into a real core and
diffing. Not one of core's own URLs moved — that is the promise MODULE_SYSTEM.md §1.2 makes to the
shipped Android app and the Discord bot, and it is checked on every PR.
Working on it
npm ci --prefix server && npm test --prefix server && npm run check:imports --prefix server
npm run check:swagger --prefix server # is swagger-fragment.json still current?
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 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), no bare specifier may survive into the
built chunk (§3.6), and the OpenAPI fragment core merges must describe the routes registered today
(§2.8). The matching build failure — a shared dependency being bundled — comes from a guard inside
vite.config.js.
Changed a route, or its #swagger annotations? npm run swagger --prefix server regenerates
swagger-fragment.json; commit it. Core cannot generate it — core is a prebuilt image and this
module mounts through a call no static parser can follow — so the file this repo commits is the one
an operator's /api/docs shows.
Changed a mount prefix, or added a route? routes.manifest.json is regenerated by the
frozen-manifest CI job, which clones core at the ref pinned in ci/core-ref.json,
loads this module into it and takes the difference. To do it locally, check this repo out into that
core as modules/uo (copy it — a symlink is silently skipped by the loader), run core's
npm run routes:manifest with and without it, and hand both files to
server/scripts/frozenManifest.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.
module.json id, version, coreApi range, mounts, extensions
swagger-fragment.json generated · the OpenAPI core merges into /api/docs.json
routes.manifest.json generated · the 72 URLs this module serves
ci/core-ref.json the core commit the two above were proved against
server/index.js the entry point — register(ctx, api), synchronous, no database
server/router/ routers + controllers, one directory per tier
server/model/ one directory per table family; nothing crosses the boundary
server/utils/ sidecar client, visibility, ingest, town crier, cliloc, atlas
server/config/ the push stream catalog
server/db/schema.sql idempotent fragment, replayed by core's ensureSchema()
server/db/purge.sql destructive; only ever run by an explicit purge
server/scripts/ the three checks: imports, the fragment, the frozen manifest
server/test/ node --test, with a fake ctx standing in for core
client/src/entry.jsx the chunk's entry — registers routes, nav, slots, 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/dist/ PREBUILT ESM chunk, built by CI — never by an operator
The three generated files are committed on purpose. Two of them are what core reads instead of looking at this source — it never has it — and the third records which core they were proved against. A generated file nobody reviews is a generated file nobody notices going wrong, so each lands in a diff.
Release artifact: module-uo-<version>.tar.gz, plus module-uo-<version>.json carrying its
sha256. See below.
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.
Releases
Every merge to main that carries a releasable commit publishes a bundle. The next version is
computed from conventional-commit subjects since the newest v* tag, as in link and installer:
feat!: or BREAKING CHANGE is a major, feat: a minor, fix: or perf: a patch, and a main
that gained none of those cuts no release. The number that ships is the tag, and CI writes it
into the module.json inside the bundle.
module.json's version survives as a floor: name a version there above the newest tag and that
version is what releases, which is how you overrule the subjects — when a coreApi bump forces a
minor, say. What no longer happens is a main full of feat: producing nothing because a separate
PR to move one number had not been merged yet.
For a change with nothing releasable behind it — a widened coreApi, a new mount, a capability —
run the Release workflow by hand (Actions → Release → Run workflow). Leave version blank to
bump the newest tag by bump (default patch), or type an exact version to publish that.
Each release carries:
| Asset | What it is |
|---|---|
module-uo-<version>.tar.gz |
the directory core expects at modules/uo/ — already assembled, with the chunk built and ws installed |
module-uo-<version>.json |
id, version, coreApi, the artifact's URL, size and sha256 |
SHA256SUMS |
the same hash, in the shape every other repo here publishes |
Releases are unsigned; the sha256 is the trust anchor, and the website verifies it before
unpacking. That is the model installer's bundles already use, and a second trust model would be a
second thing to get right.
The tarball is assembled from an include list, never an exclude list — an exclude list ships
whatever it forgot. Tests, scripts, client/src and the dev dependencies are not in it.
Environment variables
Four, all optional, all read by this module rather than by core — which is why they are documented
here and not in core's .env.example. In Docker they go in the Compose .env, since that is what
reaches the container.
| Var | Default | What |
|---|---|---|
UOLINK_BASE_URL |
— | Default sidecar base URL for a site with nothing saved yet. The admin panel's stored value wins. |
UOLINK_WS_URL |
— | Same, for the WebSocket URL. |
UOLINK_PROTOCOL |
3 |
Wire protocol this build speaks. Again only a fallback — set it lower only if you deliberately run an older sidecar. |
TOWNCRIER_DURATION_SEC |
3600 |
How long a published news post's in-game town-crier message stays up (≤ 86400). |
The sidecar's auth token is deliberately not here. It is entered in Admin → Shard, encrypted at
rest with core's SECRET_ENC_KEY, and write-only in the API — never returned to any client.
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.