Files
Module-uo/README.md
wtclaude 8ec21086b5
All checks were successful
PR Checks / client-build (pull_request) Successful in 18s
PR Checks / server-tests (pull_request) Successful in 20s
PR Checks / frozen-manifest (pull_request) Successful in 56s
ci(release): derive the version from commit subjects, and add a manual backdoor
A bundle used to cost a second pull request whose entire content was a number.
The workflow published only when a merge to `main` left `module.json` at a
version with no release yet, so a merge that did not touch that line released
nothing. Measured rather than argued: v0.3.0 (2026-08-12) is the only release
this repo has ever cut, while the nine Teams phases and the cutover landed on
`main` in the week since.

Adopt the engine `link` and `installer` already run - feat!/BREAKING CHANGE ->
major, feat -> minor, fix|perf -> patch, nothing releasable -> no release. The
number that ships is the tag, and CI writes it into the `module.json` inside the
bundle, with an assertion that the rewrite happened: a bundle carrying the wrong
version would install under a number that is not the one it was released as.

`module.json`'s version is kept as a floor rather than deleted - a version above
the newest tag still releases at that version - so the declared model survives as
the special case it always was, and is still how a `coreApi` bump overrules the
subjects. `workflow_dispatch` covers what the rules cannot reach: leave `version`
blank to bump the newest tag by `bump`, or name an exact version. Where the log
and the input disagree the larger bump wins, because a button pressed on a log
full of `feat:` would otherwise publish its `patch` default over a minor's worth
of work.

Two things carried over from `link` at the same time: a tag pushed without a
release behind it is now recovered rather than making that state permanent, and
the changelog moved into the plan step (so the assemble step clears `$OUT`, not
`dist/`, which now holds it).

On this repo's `main` the engine computes v0.4.0. The workflow still never
writes to a branch.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-19 13:01:31 -05:00

211 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
**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](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).