wtclaude 10fde87724
All checks were successful
PR Checks / server-tests (pull_request) Successful in 38s
PR Checks / client-build (pull_request) Successful in 24s
PR Checks / frozen-manifest (pull_request) Successful in 53s
feat(events): what an author borrows, and two one-shots (Phase 12b)
Five targeted leases over two planes, the item grant, the world save, and the
atlas work the spawner dropdown needed.

FIVE LEASES, ONE FACTORY

`uo.spawner.maxcount`, `.mindelay`, `.maxdelay`, `.running` and
`uo.seasonal.status`. The four callables differ only in which key they name, so
they are built rather than repeated: five copies would be five chances for one of
them to forget the drift check, which is the one thing §F says a lease must not
be allowed to skip.

It is `MaxCount`, not the `Amount` EVENTS_PLAN.md named -- there is no such
property on ServUO 57.4. `MinDelay`/`MaxDelay` are TimeSpans, so the wire carries
SECONDS: the spawn files' own `DelayInSec` flag proves both units are in use on a
real tree, and a unit that cannot express five seconds cannot express this
shard's own data.

The seasonal lease is a THREE-value enum over EIGHT events. §G called
`GetEntry(type).Status` "a nine-value enum" and had it backwards: `EventStatus`
has three values and it is `EventType` that has nine entries. Eight rather than
nine because `TreasuresOfTokuno` is excluded -- `IsActive()` reads its own
`DropEra` rather than `Status`, so leasing it would apply cleanly, read back,
restore cleanly and do nothing at all.

Two behaviours worth the review. `inForce()` reads the frame's `holds` rather
than a row's `held` flag, because a catalog walk can enumerate the keys but never
the holds on a targeted one. And a target that VANISHED mid-run is a SUCCESSFUL
restore: there is nothing to give back, and reporting it failed would leave a
ledger row unresolved for ever over an object that is gone -- 12a's `gone` in the
lease plane's vocabulary.

THE GRANT NAMES A RUN, NEVER A RECIPIENT LIST

Core has the participants in `event_run_participants`, but a module cannot read
core's tables -- so the alternative was a new core surface handing them over. Not
needed: the shard has held the run's ledger since it opened, keyed by the same
serials core stores as `member_key`.

And the grant is RETRYABLE. §G called it un-retryable because a lost
acknowledgement and a grant that never applied were the same event, which is
exactly the argument that made `uo.broadcast` answer `retry: false` in Phase 9.
Protocol 6's idempotency key closes it. `uo.rewards` counts ITEMS rather than
grants: 500 gold to forty people and a candle to forty people are not the same
imposition.

THE ATLAS KEEPS UniqueId AGAIN, AND THE SPAWNER SOURCE SEARCHES

The parser has read `<UniqueId>` and thrown it away since the atlas shipped, on a
line citing a committed artifact -- there is no committed artifact, as
`spawnAtlasSource.js` says in its own header. It is the ONLY name for one
particular spawner that exists off the shard, so a property lease could not have
had a dropdown without it. `PARSER_VERSION` -> 4 so an unchanged tree is re-read.

`uo.options.spawners` is the first searchable source and the first that had to
be: 6,707 spawn points against `MAX_OPTIONS`' 2,000, so a flat list would drop
two thirds of the world and say nothing about which two thirds.

ONE DEFECT IN ALREADY-MERGED CODE, AND IT WOULD HAVE BROKEN EVERYTHING

The protocol pin never left 5. `uo_link_config.protocol` reaches the sidecar as
`X-UOLink-Version` on every REST call and an exact mismatch is a 409, so from
Phase 11a onward every sidecar call on a real deployment would have been refused
-- the whole event plane dead, loudly, for a reason nobody would look here for.
11a took the wire to 6 and 12a to 7; neither moved the pin, in either of the two
places this repo declares it. It survived both because both live walks set the
column by hand while standing the rig up, which is exactly what makes a migration
nobody runs invisible. All three sites go to 7.

The test that guards them is worth understanding before trusting it:
`schemaFragment.test.js` asserts the three declarations agree WITH EACH OTHER --
a real check they once failed -- but all three being equally stale passes it, and
nothing in this repo can anchor it to the wire. Recorded in the model's own
header so the next reader knows.

CHECKS

`npm test`: 620 pass, 0 fail (was 605). `check:imports` and `check:externals`
clean; the client builds and its 42 tests pass. `check:swagger` reports the
fragment stale -- it is ALREADY stale on `edge` (verified by stashing this
branch's changes and re-running) and this phase adds no route, so it is left
alone rather than regenerated inside an unrelated change.

Two bugs the new tests caught in this branch's own code before it left: `counted()`
returns `.count` and the grant read `.value`, so every grant went out with
`amount: undefined` and the non-stackable guard never fired; and `optionalInt`'s
`ok` was ignored, so a bad hue passed silently instead of refusing.

Refs: docs/link/v7.md §11-§14, docs/website/EVENTS_PLAN.md Phase 12b

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-07 08:08:08 -05:00

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, the engagement triggers and audiences
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

What this module registers with core, beyond its routes. Seven push streams, one announce leg (the in-game town crier), a Team provider (a UO guild is a Team), one slash command, and — since ENGAGEMENT.md Phase 11 — 24 engagement triggers and 3 audiences. A trigger is a payload contract: what a rule may fire on, what a template may interpolate, and the widest audience an operator may ever give it. Core learns none of the vocabulary; it holds ids, labels and ceilings. Declaring a trigger sends nobody anything — every rule ships disabled. The catalogue, the four rows deliberately absent and the reasons are in docs/modules/uo/API.md §5.

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.

Repo What
thisRunicGateway/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.

Description
No description provided
Readme 2.7 MiB
v1.3.0 Latest
2026-09-14 23:10:16 +00:00
Languages
JavaScript 100%