Nineteen `#swagger` descriptions carried a `\'` inside a single-quoted string. That is correct JavaScript and wrong here: swagger-autogen does not evaluate the annotation as JS, so the backslash survives into the spec and Swagger UI renders "the shard\'s published ruleset" to a reader. Replaced with a typographic apostrophe, which the same files already use elsewhere. Found by opening /api/docs in a browser against a real core with this module installed — the fragment was valid JSON, the paths were right, every test passed, and it was still wrong on screen. Nothing that reads the artifact can see this; only reading the rendered page can. Also documents the four environment variables this module reads (UOLINK_BASE_URL / _WS_URL / _PROTOCOL, TOWNCRIER_DURATION_SEC). Core's .env.example is dropping them in the paired website PR: they were never core's, and a half-copy in two repos goes stale silently. Co-Authored-By: Claude <noreply@anthropic.com>
202 lines
12 KiB
Markdown
202 lines
12 KiB
Markdown
# 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`](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-<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](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-<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](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).
|