# 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//` 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`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_SYSTEM.md) and the normative contract is [`website/MODULE_API.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/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` | ⬜ | 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`](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 ```bash 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`](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-.tar.gz`, plus `module-uo-.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](https://gitea.whitlocktech.com/RunicGateway/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 A merge to `main` that leaves `module.json` at a version with no release yet publishes one. The version is **declared**, not computed from commit subjects: `module.json`'s version is what core records in `installed_modules` and shows on the admin screen, and it sits beside the `coreApi` range a bump usually has to be weighed against — two sources for one number is how they drift. Bumping it is an ordinary reviewed PR. Each release carries: | Asset | What it is | |---|---| | `module-uo-.tar.gz` | the directory core expects at `modules/uo/` — already assembled, with the chunk built and `ws` installed | | `module-uo-.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](https://gitea.whitlocktech.com/RunicGateway/website) | Core — the site, admin panel and API that loads this module. | | [RunicGateway/link](https://gitea.whitlocktech.com/RunicGateway/link) | The **uo-link sidecar** — the network-facing half of the game bridge this module talks to. | | [RunicGateway/servuo-plugins](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins) | The **C# ServUO plugin** that feeds the sidecar. | | [RunicGateway/installer](https://gitea.whitlocktech.com/RunicGateway/installer) | Deploys the shard side. Not the module delivery path. | | [RunicGateway/docs](https://gitea.whitlocktech.com/RunicGateway/docs) | All project documentation, including the module system design and this module's docs under `modules/uo/`. | ## Contributing See [CONTRIBUTING.md](CONTRIBUTING.md). Contributions are welcome, AI assistance must be disclosed, and security problems go to [SECURITY.md](SECURITY.md) rather than a public issue. ## License **GNU General Public License v3.0 or later** — see [LICENSE.md](LICENSE.md).