Files
docs/modules/uo
wtclaude 872352389d docs(website): close phase 3 — slice 5, the fragment obligation, and the rust dry run
Records the slice that closes the extraction, and adds the two documents phase 3
owed: docs/modules/uo/ and the module-rust dry run.

**The slice found an obligation neither repo had built.** MODULE_API.md §2.8 and
§6.1a settle the OpenAPI fragment in detail — a module ships one, core merges the
fragments of started modules into /api/docs.json. Neither half existed, so the 72
URLs module-uo serves were in no spec at all. §2.8 and §6.1a now record what was
built, including the four things settled while building it: the filename is fixed
rather than declared, a module namespaces what it DEFINES and references core's
shared schemas by core's name, the generator derives its prefixes from the
module's own register() call, and swagger-autogen's diagnostics have to be
captured because it reports a broken annotation and then prints Success.

**§5.3 gains the design decision the frozen manifest actually made:** it is a
SUBTRACTION, not a prefix filter. Generating the manifest without the module and
then with it answers "what does the module serve" AND "did core lose anything",
and the second is the one §1.2 promises to the shipped Android app. A module that
shadowed a core route cannot appear as an addition anywhere.

**BACKEND_DESIGN.md §4.0.1** is new: /api/docs.json is assembled per request, the
two generated artifacts are core's alone, and the route count was still 228.

**docs/modules/** is new, per §2.10 (module documentation aggregates here, not in
module repos): docs/modules/uo/README.md orients a reader on what module-uo
serves, owns and needs from an operator, and links out to the feature docs that
already existed rather than restating them.

**docs/modules/rust-dryrun.md** is phase 3's fourth acceptance criterion. A
written, deliberately unimplemented module for Rust — chosen because it wipes
monthly, runs several servers rather than one shard, identifies by Steam, and
ships RCON so there is no sidecar to write. The contract generalises: same
manifest, same seven registration calls, same schema rules, and six of the UI
kit's seven members wanted by a game with nothing in common with the one the kit
was curated from.

It found one real gap — **a module cannot register an identity provider**, and
"Sign in with Steam" is what a Rust community expects. Recorded as the first
candidate for a future MODULE_API_VERSION bump rather than bolted on: an identity
provider participates in session creation, and §2.7's link-only SSO policy has to
survive it.

Also: website-README.md refreshed from the repo (it was several changes stale),
and three settled decisions added (18-20).

Pairs with Module-uo#6 and website#141.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 23:05:56 -05:00
..

module-uo — the Ultima Online module

Everything specific to Ultima Online that the website serves. Code: RunicGateway/Module-uo. Module id uo, installed at modules/uo/ on the website's modules volume.

This page is orientation: what the module is, what it serves, what it owns, and what an operator has to know. It is not a second copy of the contract — ../../website/MODULE_API.md is normative for anything about how a module and core fit together, and the module's own repo is authoritative for its file layout. Where this page and either of those disagree, they win.

Module documentation aggregates here rather than in module repos (MODULE_SYSTEM.md §2.10), so the feature docs that describe what these routes mean are the ones that already existed and did not move:

Doc What it covers
SHARD_VISIBILITY.md Who sees which shard data — the admin-configurable audience framework
SPAWN_ATLAS.md The bestiary / spawn atlas, parsed from the shard's own ServUO tree
MARKETPLACE.md The player-vendor index
CLILOCS.md UO's id → name table
UOFIDDLER.md Operator runbook for extracting the cliloc table and creature art
../../link/PLAN.md, ../../link/INTEGRATION.md The wire protocol this module speaks to the sidecar

What it serves

72 URLs, frozen in the module's own routes.manifest.json and documented in its swagger-fragment.json, which core merges into /api/docs.json while the module is running.

Mount Tier What
/api/v1/public/shard public Status, activity feed, economy, presence, houses/IDOC, champs, guilds, governors, points boards, the player-vendor market, the ruleset, and the SSE event stream
/api/v1/public/atlas public The bestiary: creatures, regions, landmarks, champion altars, and what is loaded
/api/v1/admin/shard admin Shard ops (kick/ban/broadcast/pages), the visibility config, atlas and cliloc imports, market admin, account links
/api/v1/admin/uo-link admin The sidecar connection config, live status, and the town crier
/api/v1/player/shard player A player's own linked accounts: rosters, character sheets, vendors, sales
/api/v1/admin/users/:id/shard/* admin Six routes filling core's admin.users.detail extension slot — a module's routes hanging off a core resource, since core owns the user

Every URL is byte-identical to the one core served before the extraction. That is the whole point of moving the code and not the paths: the shipped Android app calls POST /api/v1/admin/shard/kick, the Discord bot reads /api/v1/public/shard/*, and neither knows a module answers now.

In the SPA: twelve public pages under /uo/*, the admin views under /admin/uo/*, the player views under /player/uo/*, three extension-slot fills, and the nav rows for all of them, interleaved into core's nav so an operator can reorder, relabel or hide them like any other row.

What it owns

  • 27 database tables — 26 shard_* plus uo_link_config. Created by an idempotent schema.sql fragment core replays on every boot, after its own schema. The shard_/uo_link_ prefixes are grandfathered (MODULE_API.md §6.5): the rule for a new module is <id>_, and these predate it.
  • Seven push notification streams and the announce leg towncrier, likewise grandfathered — they are stored in notification_subs and announce_job_legs.leg and read by the shipped Android app, so renaming them would be a data migration plus a client break.
  • Two settings rows: game_account_signup and the shard's protocol pin.
  • The public-safety filter. Which shard event kinds may reach the public SSE stream is decided here, not in core — the kinds, the streams and the filter are one file that moves together.

For an operator

Installing. A release is module-uo-<version>.tar.gz plus a manifest carrying its sha256. Unpack it as modules/uo/ on the website's modules volume (or use the admin Modules screen when phase 4 lands) and restart. You never build anything — the client chunk is prebuilt and the one runtime dependency ships inside the tarball.

Connecting it to a shard. The module needs the uo-link sidecar running next to the ServUO shard. Deploy that with the installer../../installer/INSTALL.md is the operator guide — and paste the four values it prints into Admin → Shard. The token is encrypted at rest and write-only in the API.

Nothing requires the shard to exist. With no sidecar configured the site renders normally and shows the shard offline. That is the same bargain the module system makes one level up: a module that fails to load never takes the site down.

Environment variables — four, all optional, all read by the module and documented in its README: UOLINK_BASE_URL, UOLINK_WS_URL, UOLINK_PROTOCOL, TOWNCRIER_DURATION_SEC. They live in the Compose .env, because that is what reaches the container.

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. This is a separate number from PROTOCOL_VERSION, which versions the shard wire and says nothing about a website module.

The module's CI clones core at a pinned ref (MODULE_API.md §5.3) to generate its frozen manifest. Bumping that pin is a deliberate commit that says which core the module was last proved against — not a tracking reference that turns core's unrelated changes into red Xes here.