63 Commits

Author SHA1 Message Date
50f84b5ea3 Merge pull request 'fix(atlas): keep the UniqueId, and make a landmark value name one landmark' (#35) from fix/atlas-unique-id-and-landmark-values into main
Some checks failed
Release / release (push) Successful in 27s
SonarQube / analysis (push) Failing after -59s
Reviewed-on: #35
2026-09-10 02:14:49 +00:00
d6346996d3 fix(atlas): keep the UniqueId, and make a landmark value name one landmark
All checks were successful
PR Checks / client-build (pull_request) Successful in 23s
PR Checks / server-tests (pull_request) Successful in 28s
PR Checks / frozen-manifest (pull_request) Successful in -21s
Two defects the Phase 16b re-verify found in the released v1.2.1 bundle, both
of which make a shipped feature unusable and neither of which any test saw.

## The aggregator discarded the UniqueId

`shard_spawn_points.unique_id` was NULL on all 6,455 rows of a stock 57.4 tree.
`listSpawners` filters `unique_id IS NOT NULL`, so `uo.options.spawners` was an
empty dropdown -- and it is the ONLY option source for the Phase 12b
object-property leases, so no `Spawner.MaxCount` / `MinDelay` / `MaxDelay` lease
could be authored at all, with nothing on the form to say why.

Every part of the path was already right except one line. The spawn files carry
`<UniqueId>` (~6,374 of them), `parsePoints` returns it, the column exists and
the insert passes `p.uniqueId || null`. `buildAtlas` rebuilds each point from an
explicit field list and `uniqueId` was not on it -- the word appears nowhere in
that file. `PARSER_VERSION = 4`'s own note says "a spawn point keeps its
UniqueId, which is what a property lease targets", so the intent shipped as a
comment while the code dropped the field one function later.

`PARSER_VERSION` goes to 5 because the bump is the only thing that re-reads an
already-imported tree: `sameSources` compares the tree's hashes, which have not
changed -- only what is kept from them. Confirmed on the rig, where the boot
after the fix logged `spawn atlas refreshed` on an unchanged tree and the manual
import then correctly answered `unchanged`.

## A landmark option value named 23 places at once

A stock tree has 558 landmarks under 320 distinct `facet/name` pairs.
`Trammel/Entrance` is 23 different dungeons -- Blighted Grove, Covetous, Deceit,
Despise, Destard and so on -- and `landmarkPoint` resolved with `.find()`, so 22
of the 23 were unreachable. An author who picked "Entrance - Destard" got
Blighted Grove, and the run succeeded with no warning. The group was already the
disambiguator: it was shown in the dropdown and left out of the value.

The value is now `facet/group/name`, which is distinct across all 558.
`landmarkPoint` tries that form first and keeps the two-part read as a fallback,
because every event published before this fix stores `facet/name` and a
published version is immutable -- refusing to parse those would break runs
rather than correct them. The fallback keeps the old first-match behaviour
deliberately: it is imprecise in exactly the way it always was, and silently
relocating a live event's spawn point is worse than repeating a known
imprecision. A three-part value whose group is gone REFUSES rather than falling
back to the name, because it asked for one particular place.

## Verification

On the released-artefact rig (installer -> bundle 2026.09.10 -> stock 57.4 tree
-> protocol-7 sidecar -> core at main with this module):

  spawn points     6455 rows, 6364 with a unique_id   (was 0)
  uo.options.spawners   100 options, and `?q=orc` searches them   (was 0)
  uo.options.landmarks  558 options, 558 distinct values          (was 320)
  suite            625 pass, 0 fail

Each new test was confirmed to FAIL without its fix. The atlas one asserts the
field on the AGGREGATOR's output rather than the parser's, which is the whole
point of it -- and the test fixture had no `<UniqueId>` at all until now, which
is exactly why a green suite said nothing. The landmark one asserts an
INEQUALITY between two resolved points rather than a literal value string, so it
survives another change of format as long as two options still address two
places.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-09 20:59:49 -05:00
bbaf08f67c Merge pull request 'feat(events): the UO half of the Event System, and the core pin comes home (Phase 16b cutover, 3 of 6)' (#34) from chore/events-cutover-repin into main
Some checks failed
Release / release (push) Failing after -45s
SonarQube / analysis (push) Successful in 2m30s
Reviewed-on: #34
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-09-10 01:23:30 +00:00
ea63ad019c chore(ci): the core pin comes home to main (Phase 16b cutover)
All checks were successful
PR Checks / client-build (pull_request) Successful in 18s
PR Checks / server-tests (pull_request) Successful in 23s
PR Checks / frozen-manifest (pull_request) Successful in 49s
`ci/core-ref.json` pointed at a website `edge` sha for the length of the Event
System window (org lead, 2026-09-04), because `api.registerEventActions` exists
only from MODULE_API 1.10.0: under the old `main` pin the frozen-manifest job's
`register()` threw and this module did not load at all, so the job would have
been red by construction for eight phases while a real regression hid behind it.

The cutover put 1.10.0 on `main` (website#199, 655fbf3f), so the pin returns to
a `main` sha -- and this is the same move that turns the Integration kit green,
since `checkCoreApi` asserts equality against whatever core this pin names.

`routes.manifest.json` needed NO regeneration. The frozen-manifest job's own
steps were run against this exact ref -- core's manifest alone, the module
installed, core's manifest again, then `frozenManifest.js --check` -- and it
answered `routes.manifest.json is current, 73 routes, all documented`. So the
file's own "commit both together" instruction had nothing to pair with this
time. website's `main` and `edge` are the identical tree (930422ff), which is
why the measurement taken on the branch holds for the merge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-09 19:48:29 -05:00
c73d62e93a Merge pull request 'fix(events): the atlas import, and a teardown that was a no-op (Phase 16a)' (#33) from fix/events-p16a-walk into edge
Reviewed-on: #33
2026-09-09 13:47:56 +00:00
8def6e19f4 fix(events): the atlas import, and a teardown that was a no-op (Phase 16a)
All checks were successful
PR Checks / client-build (pull_request) Successful in 24s
PR Checks / frozen-manifest (pull_request) Successful in 58s
PR Checks / server-tests (pull_request) Successful in 8m27s
Two defects the acceptance walk found in shipped code, both invisible to the
suites that were green on either side of them.

**The spawn atlas cannot import on a stock ServUO tree.** `spawnAtlasSource.js`
dedupes decoration types with a case-SENSITIVE `Map`, but `shard_decor_types.type`
is a PRIMARY KEY under MariaDB's default `..._ai_ci` collation, which folds case.
Stock 57.4's own `Data/Decoration/` names four types under two spellings each
(CheckerBoard/Checkerboard, ChessBoard/Chessboard, MetalChest/Metalchest,
SpinningWheelEastAddon/SpinningwheelEastAddon), and in every pair exactly one is a
real class. The second row raised `1062 Duplicate entry` and took the WHOLE import
transaction down. The blast radius is not decoration: with no atlas, EVERY option
source answers empty and no Phase 12 world verb can be authored at all.

The shard end already knew — `BridgeWorld.cs` resolves a decor type with
`FindTypeByName(name, ignoreCase: true)` and its comment says the atlas and the
decoration files disagree about casing. Folding here is the two ends agreeing.

**Teardown of every world verb was a no-op that reported success.** `revertOwned`
forwarded core's `idempotencyKey` as the despawn's OWN key — and core's key is the
step's, the one `placeOwned` spawned under. `BridgeIdempotency` keys on the key
alone, so the despawn was taken for a repeat and answered with the SPAWN's stored
reply; `OnDespawn` never ran. Core read `ok` with no `refused` and marked every
row `reverted` while the shard still held every object.

Measured on the rig: ledger `world | reverted | 21`, shard `world.owned` 21 alive
with `pruned: 0`, and the identical despawn re-sent with a fresh key removed all
21. It affected all five world verbs, so an invasion's creatures, boss, oracle,
gate and decoration stayed in the world for ever while the console reported a
clean teardown.

`MODULE_API.md` says what that key is for and it is not this: it identifies a
dispatch core never learned the outcome of, so the module can ask about it. No key
is needed on a despawn — a repeat answers `gone`, which both ends already treat as
success — and dropping it also makes the documented empty-`resources` case work,
since no serials means "everything this run owns". The parameter is removed from
`despawnWorld`'s signature rather than left optional.

Both fixes are verified end to end against a real ServUO + sidecar + website rig:
the import now yields 309 decor types (was failing at 313 with 4 collisions),
6,455 spawn points, 800 creatures, 558 landmarks; and a full four-phase run's
teardown left the shard owning 0 objects.

Each new test was confirmed to FAIL without its fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-09 08:27:50 -05:00
c289586a3d Merge pull request 'feat(events): what an author borrows, and two one-shots (Phase 12b)' (#32) from feature/events-p12b-borrowed-and-oneshots into edge
Reviewed-on: #32
2026-09-07 16:23:32 +00:00
10fde87724 feat(events): what an author borrows, and two one-shots (Phase 12b)
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
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
0b142adb81 Merge pull request 'feat(events): the five world verbs an author sees (Phase 12a)' (#31) from feature/events-p12a-world-verbs into edge
Reviewed-on: #31
2026-09-07 06:58:04 +00:00
89be9d6a4e feat(events): the five world verbs an author sees (Phase 12a)
All checks were successful
PR Checks / client-build (pull_request) Successful in 28s
PR Checks / server-tests (pull_request) Successful in 30s
PR Checks / frozen-manifest (pull_request) Successful in 43s
`uo.creature.spawn`, `uo.boss.spawn`, `uo.npc.place`, `uo.gate.open` and
`uo.decor.place`, over protocol 7's one command family. Five actions because
five is what an author has; one `perform`/`revert`/`reconcile` because on the
wire they are one thing.

Five new budget dimensions -- `uo.creatures`, `uo.bosses`, `uo.npcs`,
`uo.decor`, `uo.gate.minutes` -- all declared by THIS MODULE (org lead,
2026-09-07). Core meters whatever dimensions a module declares and holds no UO
knowledge, which is the whole of what MODULE_API means by game-agnostic. A gate
is priced in minutes rather than in gates: one standing all day and twelve
standing five minutes each are not the same imposition on a world.

`reconcile()` ASKS the shard, and is the one place in this file that must not
use `reconcileByBootId`. A crier line lives in shard memory, so a changed
`bootId` IS proof it is gone; a spawned creature is in the world SAVE and
survives the restart the stamp would report it lost by. Anything `world.owned`
does not list is gone -- safe only because the shard's registry and the objects
it describes are written by the same save.

Teardown reports `gone` as success and `refused` as failed. A creature a player
killed is the point of having spawned it, and a run that ended `incomplete`
because its event worked would be a report nobody could read. `refused` means
the shard denies this run ever owned the serial, so nothing will delete it
through this path and the row must land unresolved with a reason.

The atlas gains a decoration index, parsed from the shard's own
`Data/Decoration/**/*.cfg` -- 120 files, read RECURSIVELY because the real tree
nests two deep and a flat read would index a fraction of it while looking like
it worked. 313 distinct types. The decor verb resolves through it rather than
passing a type name through, which keeps the verb to this shard's own decoration
vocabulary AND fetches the item id: `Static` alone accounts for 5031 placements
under 1992 different graphics, so a bare type name places the wrong thing.
`PARSER_VERSION` -> 3, so an already-imported tree is re-read.

Two things the build found in code that had already shipped:

`uo.options.creatures` answered with the atlas SLUG -- unique, stable, and not
something the shard can build, because a creature is constructed from a ServUO
class name and `orc-brute` is not one. The atlas's `name` is the raw type token
from the spawn files, so the fix was to stop discarding the half that works.
Safe to change because Phase 12a is the source's first consumer; the file said
so when it shipped.

`uo.npc.place` could not be performed from its own required params. Both ends
refuse an oracle with neither a greeting nor a line, but both fields were
optional -- so a cross-field rule sat where no authoring form could render it.
The greeting is now `required`, which says the same thing in the contract
itself. Caught by the existing dry-run sweep, which is a better argument for
that test than anything written about it when it shipped.

605 tests pass. `swagger-fragment.json` is stale on `edge` already and this
phase adds no route, so it is left alone.

Refs: docs/link/v7.md, docs/website/EVENTS_PLAN.md Phase 12a

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-07 01:52:15 -05:00
c11c130438 Merge pull request 'feat(events): one lease and the participation verbs (Phase 11b)' (#30) from feature/events-p11b-leases-participation into edge
Reviewed-on: #30
2026-09-05 04:10:14 +00:00
88bfe9310e feat(events): one lease and the participation verbs (Phase 11b)
All checks were successful
PR Checks / client-build (pull_request) Successful in 20s
PR Checks / server-tests (pull_request) Successful in 26s
PR Checks / frozen-manifest (pull_request) Successful in 40s
The UO half of protocol 6 part b. No route added, no schema change, no
MODULE_API bump.

`uo.playercaps.skillcap` is the one lease, and the catalog is short because
ServUO made it short: of the 158 non-Bridge `Config.Get` call sites in
`Scripts/`, roughly eight are read live. This one is read inside
`CharacterCreation.cs`'s per-character path, so it is both live and observable --
which is what "proven" has to mean, since the failure an allowlist exists to
prevent is a key that applies cleanly and changes nothing.

Its `apply()` sends a DURATION rather than the deadline: an absolute time
computed here and honoured there is measured against two clocks, and a shard
running ten minutes fast would restore a ten-minute lease the instant it took it.
Its `restore()` turns `lease.drifted` into `{ drifted: true, current }` rather
than an error, because core records drift as a distinct successful outcome and an
error would put the row on the retry ladder. Its `inForce()` asks whether the
shard still HOLDS the lease, never whether the value still matches -- see the
core PR.

`uo.participation.open` / `.collect` count who took part and file them on the
success envelope. `open` is the one resource in this module that must NOT
reconcile by boot stamp: every other resource here lives in shard memory, so a
changed bootId IS the proof it is gone, while the participation ledger is written
into the world save precisely so it survives that restart. It asks instead.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-04 19:31:57 -05:00
bf9a702cfa Merge pull request 'feat(events): send the idempotency key, declare champ.boss.killed (Phase 11a)' (#29) from feature/protocol-v6-idempotency into edge
Reviewed-on: #29
2026-09-04 23:07:02 +00:00
dc13515927 feat(events): send the idempotency key, and declare champ.boss.killed (Phase 11a)
All checks were successful
PR Checks / client-build (pull_request) Successful in 20s
PR Checks / server-tests (pull_request) Successful in 26s
PR Checks / frozen-manifest (pull_request) Successful in 39s
The website's half of protocol 6.

Every event-driven write now carries the step's idempotency key, and `uo.broadcast`
stops being un-retryable. Phase 9 shipped it answering `retry: false` to everything
including a 503 from a shard that was merely restarting, with a comment naming the
line that would change when the wire could refuse a repeat. This is that line: it
defers to `sidecarFailure`, the same helper its two siblings already used, so the
hand-rolled variant that forced every outcome terminal is gone rather than re-tuned.

One verb was less idempotent than its own id made it look. Both keyed verbs post
under a run-scoped id and a repeat replaces — but `news.add` with `announce: true`
makes the criers proclaim the title on every post, so a retry replaced the article
silently and proclaimed it again. The key stops the second proclamation.

`champ.boss.killed` is mapped to the `champs` feature (rule 2 would otherwise fail
it closed to admin), with `damagers` a nested `staff` field rule: the kill is public
because a champion falling is what the board is for, the ranked roll of who was
strong enough to fell it is not. `uo.champ.boss_killed` is declared as a trigger —
which is what makes it usable as an event PHASE CONDITION, since a condition is
written over a trigger firing — and it carries `damagerCount`, never a damager name,
because a trigger variable reaches mail an operator may address to every subscriber.

Its seeded rule is its own group, `champ-boss-killed-v1`: `triggers-v1` is stamped
once under a settings guard, so appending a 27th entry would have reached fresh
installs and nothing else. It also ships email+inapp and NOT push, and the comment
says why — no trigger in this module is also a registered stream, so no engagement
rule here can push. That is pre-existing in twenty rules and flagged rather than
fixed; this one declines to be the twenty-first.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-04 14:57:26 -05:00
cf60932c85 Merge pull request 'feat(events): UO wave 1 — the verbs that need no protocol change (Phase 9)' (#28) from feature/events-phase-9 into edge
Reviewed-on: #28
2026-09-04 12:56:13 +00:00
021f191f65 fix(events): three defects the live rig found, two of them data loss
All checks were successful
PR Checks / client-build (pull_request) Successful in 20s
PR Checks / frozen-manifest (pull_request) Successful in 41s
PR Checks / server-tests (pull_request) Successful in 8m33s
The whole-rig walk (ServUO + sidecar + website) against a real two-phase event.

- **A WS reconnect would have orphaned every live resource.** The backfill
  replays the last several `server.hello` frames in order — this rig saw three,
  each with a different `bootId` — so every replayed frame reads as a restart,
  and the intermediate ones compare a resource stamped with the CURRENT boot
  against a boot that ended hours ago. The row is then `orphaned`: a live crier
  line core will never take down again, lost to nothing worse than the website
  reconnecting. Gated on `!fromBackfill`, the rule the engagement fan-out and
  the SSE broadcast beside it already state. The website-was-down case is not
  missed — core asks every module at its own boot.
- **The shard explains its refusals and the run log dropped the explanation.**
  A 403 body reads `{"reason":"admin write plane disabled"}`; `legError` looks
  for `data.message`, finds nothing, and reports "sidecar responded 403". For a
  staff member clicking a button that is survivable. For an event that ran at
  four in the morning the run log is the only place anyone will learn why.
- **The "not retried" clause explained the wrong thing on a permanent status.**
  A 403 will not succeed on any attempt, so telling an operator it was not
  retried "because a repeat would announce twice" points them at a policy
  decision instead of at the switch they have to flip. The clause is now added
  only where a retry was genuinely given up, and 403/404 join the statuses the
  keyed verbs treat as terminal.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-04 07:36:03 -05:00
57419111e6 feat(events): UO wave 1 — the verbs that need no protocol change (Phase 9)
module-uo registers its first event actions: `uo.broadcast`,
`uo.towncrier.post` and `uo.news.post`, plus the `uo.broadcasts` budget
dimension and the three spawn-atlas option sources. The write plane they use
has existed since protocol 2.1; what is new is the declaration that lets the
event engine drive it unattended.

Three things the tree corrected about the plan:

- The plan's `on_failure: 'skip'` for `uo.broadcast` is already the default for
  `risk: 'notify'`, and `on_failure` is what happens AFTER the retries. The
  lever a module actually has is the failure envelope, so the action answers
  `retry: false` to everything — and every action declares `budgetMs: 15000`,
  because core's 10s default deadline fires before `uoLinkClient`'s 12s timeout
  and `classify()` answers `retry` for a timeout without asking the module.
  Without the budget the retry refusal is unreachable.
- `reconcile()` needs no protocol work. A shard restart wipes both the crier
  lines and an event's news article, so `perform()` stamps the shard `bootId`
  into the resource payload and `reconcile()` reports in force exactly the rows
  whose stamp still matches — correct for the module's own trigger and for
  core's boot sweep alike. `shardIngest` fires `ctx.events.reconcile()` on a
  changed `bootId`, after `recordStatus` so the comparison reads the new boot.
- Event articles post under `evt-<idempotencyKey>`, because `newsGump.js` uses
  the bare website post id and re-pushes that set on every reconnect.

`ci/core-ref.json` moves to a website `edge` sha for the length of this
workstream: `registerEventActions` exists only from MODULE_API 1.10.0, so under
the old `main` pin the module does not load at all. Verified locally — the
frozen-manifest rig passes against the new pin.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-04 07:23:03 -05:00
144242fe8f Merge pull request 'ci(core-ref): pin the core on main, now that the cutover has landed' (#27) from ci/core-ref-main into main
All checks were successful
Release / release (push) Successful in 11s
SonarQube / analysis (push) Successful in 2m13s
Reviewed-on: #27
2026-09-01 18:03:20 +00:00
c679944181 ci(core-ref): pin the core on main, now that the cutover has landed
All checks were successful
PR Checks / client-build (pull_request) Successful in 33s
PR Checks / frozen-manifest (pull_request) Successful in 47s
PR Checks / server-tests (pull_request) Successful in 8m24s
Module-uo#25 moved this pin onto website `edge` (52eac24) to unbreak
frozen-manifest during the engagement window, with its own note saying it
reverts to a `main` sha at the cutover. The cutover is step 4 of 7, merged as
#26, and website#180 landed the same code on `main` -- so the pin now names a
branch that no longer exists.

No regeneration, and the reason is checkable rather than asserted: website's
tree at 66bb3b9a (main, the cutover merge) and at 52eac24d (the edge head it
merged) are the SAME tree, e7a7240. `main` was zero commits ahead, so the merge
carried edge's tree unchanged. The frozen-manifest job clones a different commit
and reads identical bytes; routes.manifest.json cannot move.

What changes is what a reader learns from the file: which core this module was
last proved against, named by a ref they can still resolve.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-01 12:48:08 -05:00
1590b52bc8 Merge pull request 'feat(engagement): 26 shard triggers and the in-universe bodies — cutover 4 of 7 (edgemain)' (#26) from edge into main
All checks were successful
SonarQube / analysis (push) Successful in 1m26s
Release / release (push) Successful in -51s
Reviewed-on: #26
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-09-01 13:59:02 +00:00
3a81766526 Merge pull request 'ci(core-ref): pin core at MODULE_API 1.9.0, unbreaking frozen-manifest' (#25) from ci/bump-core-ref-1.9.0 into edge
All checks were successful
PR Checks / server-tests (pull_request) Successful in 24s
PR Checks / client-build (pull_request) Successful in 19s
PR Checks / frozen-manifest (pull_request) Successful in 39s
Reviewed-on: #25
2026-09-01 12:52:04 +00:00
3139cb4364 ci(core-ref): pin core at MODULE_API 1.9.0, unbreaking frozen-manifest
All checks were successful
PR Checks / client-build (pull_request) Successful in 17s
PR Checks / server-tests (pull_request) Successful in 21s
PR Checks / frozen-manifest (pull_request) Successful in -35s
The pin was `963d734` -- website `main` at the Teams cutover, MODULE_API **1.6.0**.
That core has no `ceilings.js`, no `registerEventTriggers` and no
`registerAudiences`, so this module has failed to load into it since Phase 11a
added the first of those calls, and `frozen-manifest` has been red on every
engagement PR since. The last green run was #39 (`6a276a7`, Phase 10's
protocol-5 ingest), which added no `register*` call and so still loaded.

The red X was never about the PR in front of it. This is the bump the org lead
scheduled for the moment website#178 landed; it should have ridden in
Module-uo#24 and did not.

New pin: `52eac24` -- website `edge` carrying MODULE_API 1.9.0
(`registerEngagementSeeds`) and the Phase 11b core fixes (website#179).

`routes.manifest.json` is unchanged and is NOT regenerated here: this phase's
work added triggers, bodies and rules, and not one route. The job's own check
confirms it -- 73 routes, all documented.

Reproduced locally the way the job does it: core at the new sha, manifest without
the module, module installed with `npm ci --omit=dev`, manifest with it, then
`frozenManifest.js --check`. The module registers cleanly (26 triggers, 3
audiences) and the check passes. `check:imports` and `check:bundle` clean.

Reverts to a `main` sha at the Phase 13 cutover.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-01 07:35:57 -05:00
17a96ed4c4 Merge pull request 'fix(engagement): four defects the Phase 11b live walk found, and the 26th trigger' (#24) from fix/engagement-live-walk-uo into edge
Reviewed-on: #24
2026-09-01 12:32:55 +00:00
849d4b10e8 fix(engagement): four defects the Phase 11b live walk found, and the 26th trigger
Some checks failed
PR Checks / server-tests (pull_request) Successful in 21s
PR Checks / frozen-manifest (pull_request) Failing after 36s
PR Checks / client-build (pull_request) Successful in 8m17s
Needs website#<core> (the cooldown key and the seed-rule ceiling).

1. Every owner-audienced trigger reached NOBODY. `resolveTarget` read
   `link.user_id`; the model's `toSafe` returns `userId`. So the whole flagship
   family -- houses, vendors, logins, unlinks, deaths, the governor's letter --
   resolved to null and looked exactly like the ordinary unlinked-account case,
   which the code treats as normal and deliberately does not log.

   The test fake returned `user_id` and therefore agreed with the bug, while
   `shardStreams.test.js`'s fake next door -- same model, the path this file says
   it copies -- returned `userId`. The fake is now built by running the real
   `toSafe` over a stubbed db row, so the shape is not a hand-written opinion.

2. `uo.house.refreshed`, the 26th trigger (the org lead's decision 11). The
   warning's rule carries `delay_seconds: 900` so a player who repairs the house
   inside the quarter-hour is never told it is in peril -- and nothing could
   cancel it: `cancel_on` named only the collapse. The wire had carried the
   transition all along; the mapper returned early on it.

   It fires on `Ageless` as well as `LikeNew`, and `Ageless` is the common case:
   a condemned house cannot be refreshed at all (`RefreshDecay()` refuses
   `DecayType.Condemned`), so the rescue is the owner logging in, and their
   newest house then reads `Ageless`. Ships a body and a seeded (disabled) rule
   of its own; the cancellation is read off the WARNING's rule and works whether
   or not the new one is enabled.

3. Every call-to-action in every in-universe body was a dead link, from two
   independent mistakes. The client router prefixes a module's routes with its
   ID (`/uo/houses`), not with module.json's `mounts` (`/shard/...`), so every
   declared `example` was a 404 -- and an example is what the template editor
   previews and test-sends with. And no `url` variable was ever populated by the
   mapper, so the buttons rendered with an empty href and dropped out of the text
   part entirely. Both now read `config/clientPaths.js`. Two tests close it.

4. A raw wire timestamp was signing off the Merchants' Guild's letter
   (`2026-09-02T04:06:43.8397548Z`, mid-sentence). Core has no interpolation
   filters by design, so the readable form is assembled in the mapper and arrives
   as its own variable; the machine value stays, because an operator writes
   `is at most` conditions against it.

Also fixes a latent flake: `hoursRemaining` floors a live clock, so a fixture at
a whole number asserted 19 or 20 depending on sub-millisecond timing.

527 module tests green (3 new). Proved end to end against real ServUO + the
release sidecar + a live SMTP catcher; see docs#<docs>.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-01 07:12:29 -05:00
52d9c3ddb8 Merge pull request 'feat(engagement): sixteen in-universe bodies, 25 seeded rules, the governor's letter (Phase 11b)' (#23) from feature/engagement-uo-templates into edge
Reviewed-on: #23
2026-09-01 06:35:44 +00:00
50a89b48e2 feat(engagement): sixteen in-universe bodies, 25 seeded rules, the governor's letter (Phase 11b)
Some checks failed
PR Checks / client-build (pull_request) Successful in 17s
PR Checks / server-tests (pull_request) Successful in 22s
PR Checks / frozen-manifest (pull_request) Failing after -34s
11a declared the triggers; this is the content behind them. Ships through
core's new api.registerEngagementSeeds (MODULE_API 1.9.0): 32 templates and 25
rules, every rule enabled = 0.

THE VOICE (decision 8). The game-powered families read from inside Britannia,
with a per-family in-fiction sender rather than one voice across all sixteen —
Lord Blackthorn's court writes about the crown's business (the seat, the ballot)
and nothing else, because a shard where Blackthorn writes to you personally about
a champion spawn is a shard where the letter about your governorship means
nothing. The Office of Deeds has houses, the Merchants' Guild vendors, a herald
guilds, the town crier champion spawns, a guildmaster skills and quests, the
Chronicler deaths, the keeper of the rolls leaderboards.

WHAT STAYS PLAIN (decision 9). Nine of the 25 point at core's notify.event /
inapp.event and author nothing, and the line is drawn where fiction costs
something real: a failed-login notice written as "a stranger sought entry to thy
account" is indistinguishable in register from the phishing mail it warns about,
and a moderator reading uo.cheat.detected at 2am wants a name, a rule and a
timestamp rather than a scroll. Both account-security triggers, server up/down,
and the five staff/admin-ceiling ones.

THE GOVERNOR'S LETTER (decision 10) — uo.governor.appointed, the 25th trigger.
§8.6 records that uo.points.rank_changed cannot address a person because top[]
names a mobile serial, and the same reasoning was silently assumed to cover the
governor. It does not: city.update's `governor` is written by BridgeJson.Actor(),
which emits serial, name, acct AND webId. The winner is addressable today with no
protocol change. It fires from the same frame, the same transition and the same
never-on-first-sight guard as uo.governor.elected, which stays exactly as
declared — the town's bulletin and the governor's letter are two triggers because
one trigger means one rule means one template, and they are not the same text.
An operator can run either alone.

PRESENTATIONAL FRAGMENTS, because a template has no conditionals by design and an
unset optional interpolates to the empty string. Phase 5a's `forWhom` precedent:
the ternary stays in the mapper and its result arrives as a declared optional.
Two shapes — a LABEL always has a value and carries a sentence's spine
(houseLabel falls back to a seal number); a TRAILING FRAGMENT may be empty and
leads with its own space, so `{{slainBy}}.` closes as "has fallen." either way.
Additive, so no version bump.

A render sweep over all 32 bodies, twice — once with every declared example and
once with required variables only — is what found these. Three defects it caught:
an optional `{{region}}` in a subject line ("A notice concerning thy house at ");
multi-optional ledger lines rendering "On hand:  gold. Charged each period:
gold." on a pre-v5 frame, now assembled in the mapper from the parts actually
present, the same argument place() already makes; and a leading trailing-fragment
opening a body with a stray space.

The labels stay `required: false` deliberately — a missing one must never REFUSE
an emit, since a dropped notification is worse than a cosmetic hole — so nothing
at runtime would notice a mapper that forgot one. engagementSeeds.test.js is what
notices.

524 module tests green; check:imports and check:bundle clean. check:swagger
reports STALE from CRLF alone and regenerates byte-identical — no route changed.

Refs docs ENGAGEMENT.md Phase 11b, decisions 8, 9, 10.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-01 01:02:15 -05:00
1a866112e4 Merge pull request 'feat(engagement): declare 24 shard triggers and 3 audiences (Phase 11a)' (#22) from feature/engagement-triggers into edge
Reviewed-on: #22
2026-09-01 05:06:26 +00:00
419dee3e49 feat(engagement): declare 24 shard triggers and 3 audiences (Phase 11a)
Some checks failed
PR Checks / client-build (pull_request) Successful in 22s
PR Checks / server-tests (pull_request) Successful in 28s
PR Checks / frozen-manifest (pull_request) Failing after 41s
module-uo's half of ENGAGEMENT.md Phase 11: every trigger DECLARATION, the
wire-kind mapping that fires them, and the three registered audiences. No rule
and no template is seeded here -- that is 11b -- so nothing this adds sends
anybody anything until an operator writes a rule.

server/config/shardTriggers.js declares the 24, grouped by the audience kind
each family exercises, and every variable carries the `example` the template
editor previews and test-sends with. Ceilings: 10 `owner`, 2 `members`, 7
`authenticated`, 2 `staff`, 3 `admin` (the value core adds in the same window).
`uo.cheat.detected` at `staff` is the declaration the lattice exists for.

server/utils/shardEngagement.js maps the wire to those ids, hung off
shardIngest.ingest beside the SSE broadcast and the push tickle, and reads like
shardPush.js on purpose -- owner resolution is why neither can be a pure mapper.
Three things live here because a rule cannot express them:

  * Transitions. champ.update and city.update are full-state upserts, so without
    a per-process tracker a sidecar reconnect reads as twenty spawns starting.
    A FIRST sighting is never a transition.
  * Thresholds. conditions.js compares a declared variable against a LITERAL, so
    "within 24 hours of dismissal" is not expressible; and vendor.listing is a
    sweep frame re-emitted on any price change, so per-frame would flood. The
    crossing is tracked here and `hoursRemaining` is declared so an operator can
    still narrow with `is at most`.
  * The members audience. "The members of THIS guild" differs every firing, so
    it travels on the envelope as recipientUserIds (Phase 6 decision 2).

**The fan-out runs BEFORE the state write, and that ordering is load-bearing.**
account.unlinked drops the shard_account_links row that names the one person who
needs to be told; house.remove drops the house whose stored ownerAcct is the only
place a collapsed house's owner appears; guild.leave/remove need the roster and
board mirrors to name who left. Resolving afterwards finds nobody, every time.

Four rows of 8.6 deliberately do not ship, each with its reason recorded in
docs (docs#194): uo.market.item_listed (a saved search, no per-user query store),
uo.guild.joined (core's team.member.joined already fires for it -- a UO guild IS
a Team and this module is the provider), uo.link.requested (no addressable
recipient by construction, ~5-minute TTL), and uo.points.rank_changed's personal
half (top[] names a serial, links are keyed by account).

coreApi -> ^1.8.0: the module now calls registerEventTriggers and declares
`ceiling: 'admin'`, so a 1.7.0 core would refuse the ceiling and a 1.6.0 one
would not have the method at all.

39 new tests; 509/509 pass. check:imports, check:bundle and check:swagger clean.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-31 20:28:17 -05:00
75f9b27687 Merge pull request 'feat(shard): ingest protocol 5 — decay schedule, vendor fees, login result' (#21) from feature/protocol-v5 into edge
Reviewed-on: #21
2026-09-01 00:26:28 +00:00
6a276a7ec3 feat(shard): ingest protocol 5 — decay schedule, vendor fees, login result
All checks were successful
PR Checks / client-build (pull_request) Successful in 20s
PR Checks / frozen-manifest (pull_request) Successful in 40s
PR Checks / server-tests (pull_request) Successful in 8m46s
The website half of the protocol-5 bump. Engagement Phase 10.

Schema — twelve columns and two indexes.

shard_houses gains next_stage, estimated_collapse, decay_period_sec and
dynamic_decay. estimated_collapse is nullable and stays null far more often than
not, deliberately: under dynamic decay ServUO draws each stage at random on entry,
so collapse is knowable only at IDOC. A null means "not knowable", never "not yet
read".

shard_vendors gains owner_acct plus seven fee columns and an index on dismissal_at.
owner_acct is the structural one — the table has carried owner_name since protocol
3, but a character name joins to nothing, and only the game account reaches
shard_account_links. Until now a vendor row named an owner the site could not
resolve to a person. dismissal_at + owner_acct are what let Phase 11's
uo.vendor.expiring find "vendors about to be dismissed" and turn each into a
person, without scanning every shop.

Ingest.

Both new field groups arrive NESTED and are flattened into columns on the way in,
then re-nested on the way out — the same trick shardMarket already uses for
`location`. That is not stylistic: the visibility projection matches literal JSON
keys, so the stored read model and the live wire frame have to spell a group
identically or one admin rule covers only one of the two paths. It also means a
field added inside a group later inherits the group's gate instead of defaulting to
visible; there is a test that adds an imaginary future fee field and asserts exactly
that.

Two write-back asymmetries, both load-bearing:

  * ownerName is written ONLY when the frame carries one. house.update also writes
    that column, from a different sweep, and a pre-v5 overlay's house.decay carries
    no ownerName at all — coalescing to null would let every decay transition erase
    a name the registry had already resolved.
  * The schedule and fee columns are written UNCONDITIONALLY, including as nulls. A
    schedule is a claim about the future and goes stale on its own: roll a shard
    back to a pre-v5 overlay, or let a house leave IDOC, and the right stored value
    is nothing. A dismissal date nobody is maintaining is worse than none.

dismissalAt is taken from the shard rather than recomputed. The shard resolved it
against ServUO's two vendor systems, whose charge, funds and pay interval all
differ; re-deriving it here would be a second implementation of PlayerVendor's own
rule.

Visibility — three classifications, each chosen rather than inherited.

  * house.decay's `schedule` defaults to `anonymous`. The countdown IS the public
    IDOC page's content and a house at IDOC is already announced in game. Listed
    anyway so a shard that considers a precise collapse time an unfair advantage can
    raise it — and one nested rule takes the whole schedule with it.
  * vendor.listing's `fees` defaults to `admin`, the only default in the market
    feature that does not reproduce prior behaviour, because there is no prior
    behaviour to reproduce. Shop name, owner and location are already visible to any
    player through the in-game Vendor Search gump, which is the argument for
    publishing them. Held gold, daily charge and dismissal date are visible to the
    OWNER only, on that vendor's own gump. Publishing them anonymously would be a
    new disclosure and a targeting aid — which shops are about to be abandoned, and
    how much coin is in each.
  * account.login.result is admin-only BY OMISSION. KIND_FEATURE is the map of kinds
    an admin may widen, and there is no rung below admin that an IP plus an auth
    verdict belongs on. The omission is the decision, and a test says so by name.

owner_acct needs no rule: rule 1 locks it by suffix. And the new columns are in no
REST read model's column list — they exist for Phase 11's server-side trigger and
reach no client at all.

The pin, and the protocol-4 bug seen from the other side.

Both declaration sites go to 5 (the model constant and schema.sql's CREATE default),
plus the one-shot migration, guarded `protocol < 5` so an install that missed an
earlier step is carried the whole way.

The schema test used to assert `DEFAULT 4` at each site. That is exactly how
protocol 4 shipped with the emitters moved and one site left behind: every site
agreed with itself and the test passed. It now reads DEFAULT_PROTOCOL from the
model, so the assertion is "the declarations AGREE", and the one-shot migration
test is written once against the current version instead of being hand-copied per
bump.

470 tests pass, 16 new. Verified end to end on the live rig against a real ServUO
and the release sidecar.

Docs: RunicGateway/docs link/v5.md.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-31 19:20:15 -05:00
1b6d92a5ba Merge pull request 'fix(uo-link): pin protocol 4, the version this build actually speaks' (#20) from fix/protocol-4-pin into main
All checks were successful
Release / release (push) Successful in 28s
SonarQube / analysis (push) Successful in 1m49s
Reviewed-on: #20
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-25 00:33:44 +00:00
7f7d4578ce fix(uo-link): pin protocol 4, the version this build actually speaks
All checks were successful
PR Checks / client-build (pull_request) Successful in 24s
PR Checks / server-tests (pull_request) Successful in 28s
PR Checks / frozen-manifest (pull_request) Successful in 40s
The protocol-4 cutover moved `link`'s PROTOCOL_VERSION, the overlay's
`overlay.toml` and this module's ingest — `guild.roster` and `guild.leave`
landed with the Teams cutover — but left both of this module's pin sites at 3.

A fresh install therefore came up speaking 3 to a protocol-4 sidecar, and a
sidecar answers a stale client with `409 protocol version mismatch` rather than
mis-parsing it. The failure is total and silent: every REST read fails, the WS
closes on ws.hello, and the operator sees an empty marketplace, an empty guild
board and no shard status, with the cause only in the server log. It cleared
only when an admin edited the number by hand in Admin → Shard.

Found while standing up a demo deployment for the marketing site's screenshots.

- `DEFAULT_PROTOCOL` → 4 (the constant used before an admin has saved anything)
- the `uo_link_config.protocol` column default → 4, at both declaration sites
- a protocol-4 one-shot mirroring the protocol-3 one, guarded by its own marker
  so an operator who deliberately pins an older sidecar stays pinned, and
  written `protocol < 4` so an install that never took the protocol-3 migration
  is carried the whole way rather than one step
- three regression tests: the column default, the marker ordering, and the
  `< 4` predicate

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-24 19:17:51 -05:00
6fca1cebf4 Merge pull request 'docs(readme): phase 4 shipped' (#19) from docs/phase-4-shipped into main
All checks were successful
Release / release (push) Successful in 9s
SonarQube / analysis (push) Successful in 2m15s
Reviewed-on: #19
2026-08-19 23:25:30 +00:00
9b0ae19855 docs(readme): phase 4 shipped
All checks were successful
PR Checks / client-build (pull_request) Successful in 26s
PR Checks / server-tests (pull_request) Successful in 30s
PR Checks / frozen-manifest (pull_request) Successful in 42s
The phase table still showed phase 4 - delivery: the admin Modules
screen plus the Docker path - as not started. The module system shipped
on 2026-08-12: the admin Modules screen exists, the MODULES environment
variable resolves manifests at container start, and this module has been
released four times through that path, most recently v1.0.1.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-19 18:00:16 -05:00
956e3fb0b4 Merge pull request 'fix(release): ship server/commands, and check that the bundle is complete' (#18) from fix/bundle-ships-commands into main
All checks were successful
Release / release (push) Successful in 37s
SonarQube / analysis (push) Successful in 2m2s
Reviewed-on: #18
2026-08-19 18:27:26 +00:00
3c179e3338 fix(release): ship server/commands, and check that the bundle is complete
All checks were successful
PR Checks / client-build (pull_request) Successful in 16s
PR Checks / frozen-manifest (pull_request) Successful in 40s
PR Checks / server-tests (pull_request) Successful in 8m38s
v1.0.0 installed and then died on every boot:

  module "uo" failed to load — {"stage":"register","reason":"Cannot find
  module './commands/guild.command'"}

`server/commands/` arrived with the Teams cutover (2d1d91e, `/guild`). The
release assembles the tarball from an include list, that list was hardcoded in
release.yml, and it was never told about the new directory — so the bundle
shipped without it and the module was dead on the operator's box.

Nothing caught it, and that is the more interesting half. Every PR check runs
against the whole repo — `frozen-manifest` even installs the module into core by
tarring the entire tree — but a release is a SUBSET of the repo, and the subset
exists nowhere except the release. The pre-publish check in release.yml only
stats the paths `module.json` declares, and a file reached by a require inside
`register()` is named in none of them, so it passed on a bundle that could not
load.

The include list stays an include list — release.yml's header makes that case
and it still holds. What changes is that it is declared ONCE, in ci/bundle.json,
with two readers instead of one:

  • release.yml assembles from it (via jq) rather than from its own copy.
  • server/scripts/checkBundle.js asks, in PR checks, whether it still covers
    everything `server/index.js` reaches — following requires transitively and
    through function bodies, which is where index.js deliberately puts them.

And the release gains a real loadability check: `checkBundle.js --bundle` walks
the ASSEMBLED tree and asserts every relative require resolves inside it. Asked
of the artifact rather than the source, so it also catches a half-failed copy or
a list naming a path that has moved.

Requiring the entry point would not have worked as a check: index.js requires
inside `register()` because require order is load-bearing (`core.init(ctx)` must
run before anything under `router/`), so requiring it evaluates one line and
reports success on a bundle missing every router it has.

Both modes were verified against the real defect — each fails with `commands`
removed and passes with it present.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WnDSWzpUjw8t8C2hghysNz
2026-08-19 13:25:57 -05:00
16cfbe194d Merge pull request 'ci(release): derive the version from commit subjects, and add a manual backdoor' (#17) from feature/release-per-merge into main
All checks were successful
Release / release (push) Successful in -30s
SonarQube / analysis (push) Successful in 1m47s
Reviewed-on: #17
2026-08-19 18:07:55 +00:00
8ec21086b5 ci(release): derive the version from commit subjects, and add a manual backdoor
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
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
637121bce3 Merge pull request 'feat(guilds): a UO guild is a Team (Teams cutover 5/6)' (#16) from edge into main
All checks were successful
SonarQube / analysis (push) Successful in 2m18s
Release / release (push) Successful in 7s
Reviewed-on: #16
2026-08-19 09:02:38 +00:00
fe176920c5 ci(core-ref): pin to the Teams cutover, not a phase 3 edge sha
All checks were successful
PR Checks / client-build (pull_request) Successful in 25s
PR Checks / frozen-manifest (pull_request) Successful in 49s
PR Checks / server-tests (pull_request) Successful in 8m40s
The pin said "edge @ Teams phase 3" and edge is about to stop existing. More to
the point, this module has since gained the Team provider, three declared slots,
the /guild command and the contribution names on those slots - none of which the
pinned core knew about, so the frozen-manifest job has been proving this module
against a core older than half of it.

routes.manifest.json does not move: the job compares core-with-module against
core-without-module, so core's own Teams routes cancel out and what is left is
this module's 73, unchanged. Verified locally against the cutover core before
moving the pin rather than after.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-19 04:00:01 -05:00
d98f0c1a3d Merge pull request 'feat(guilds): name the core contribution each declared slot wants' (#15) from feature/teams-slot-contributions into edge
Reviewed-on: #15
2026-08-19 06:19:52 +00:00
1a13f680f5 feat(guilds): name the core contribution each declared slot wants
Some checks failed
PR Checks / client-build (pull_request) Successful in 27s
PR Checks / frozen-manifest (pull_request) Failing after 47s
PR Checks / server-tests (pull_request) Failing after 13m23s
Core no longer fills a slot by name - it offers a contribution and the module
that owns the page says where each one goes (MODULE_API 1.6.0, amended). The
three slot names are unchanged and stay this module's own vocabulary; what is
new is the second argument saying which of core's three contributions belongs
in each place.

Nothing here worked differently before. The change is for every game that is
not this one: core used to fill the literal name uo.guild.detail, so a second
module declaring a place under its own id got an empty page and no error.

The registration fake gained the same validation core does, including the
contribution catalogue - written down rather than imported, since this suite
runs against the built chunk with no core in the process, which makes it a
claim about core that has to be re-read when core's list changes.

42 client tests, 437 server tests.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-19 01:15:49 -05:00
7d0378842b Merge pull request 'feat(guilds): /guild — the module's own chat command' (#14) from feature/teams-phase7-slash-commands into edge
Reviewed-on: #14
2026-08-19 00:16:11 +00:00
466842c6f2 fix(guilds): do not offer linking where linking cannot reach
Some checks failed
PR Checks / server-tests (pull_request) Successful in 28s
PR Checks / client-build (pull_request) Successful in 17s
PR Checks / frozen-manifest (pull_request) Failing after 35s
Found on the live rig, with the shard's guild feature gated to staff: the
refusal still read "link your account — this shard shows guild information to
linked players". Signing in reaches `logged_in` and linking a game account
reaches `player`; `staff` and `admin` are roles an operator grants, and no
amount of linking earns them. Inviting someone to do something that changes
nothing is worse than plainly saying no.

Also drops the host name from the list embed's title. `ctx.site` carries a base
URL and no brand name, so naming the deployment there could only ever mean
printing its hostname into a title on the shard's own Discord server.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 19:08:22 -05:00
2d1d91e372 feat(guilds): /guild, the module's own chat command
The first command through `api.registerSlashCommands` (MODULE_API 1.6.0,
TEAMS.md §7.1). The definition and the handler both live here; the bot pulls the
definition and runs no line of this module.

`/guild` and not `/team`, deliberately. Core does not own the word for a Team —
that is what deleted its Team pages in phase 3 — so it does not publish the noun
in a channel either. Core ships the dispatcher and zero commands.

The audience rungs are re-resolved in the handler rather than assumed: a shard
that gates guilds to staff does not become public because the question arrived
over Discord. The provider's own staleness guard is honoured too, so a stale
board answers "not connected" instead of reporting what it still holds, and
`resolveUserId` is exported rather than copied so "linked" means here what it
means on the roster.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 18:53:45 -05:00
990a50b491 Merge pull request 'feat(guilds): a third place on the guild page, and where that page lives' (#13) from feature/teams-phase6-notifications into edge
Reviewed-on: #13
2026-08-18 23:10:02 +00:00
c57310c505 feat(guilds): a third place on the guild page, and where that page lives
All checks were successful
PR Checks / server-tests (pull_request) Successful in 23s
PR Checks / client-build (pull_request) Successful in 17s
PR Checks / frozen-manifest (pull_request) Successful in 34s
Two lines only core cannot supply for itself.

`uo.guild.header` is a third declared slot, at the top of the page, for core's
per-Team notification control. A third rather than a corner of the feed because a
slot holds one component and the first fill wins: the control is an action ON this
page and the other two are content IN it, and separate slots are what let this
module say so.

`pageUrlTemplate` tells core where a guild page actually is. Teams are a contract
primitive with no core surface — core owns the tables and the access rules, this
module owns the word "guild" and therefore the page — which leaves core unable to
write a link to one. A notification email that cannot take you to the thread it is
about is most of the way to useless. Core substitutes `{externalId}` and does
nothing else with it; a template naming its own host is refused at registration.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 14:35:36 -05:00
7ce78e303c Merge pull request 'feat(guilds): declare a second place on the guild page, for core's forum' (#12) from feature/teams-phase4-forum-slot into edge
Reviewed-on: #12
2026-08-18 14:17:46 +00:00
46e3f5a127 ci(core-ref): bump the pin past registerTeamProvider
All checks were successful
PR Checks / client-build (pull_request) Successful in 15s
PR Checks / server-tests (pull_request) Successful in 19s
PR Checks / frozen-manifest (pull_request) Successful in 42s
`frozen-manifest` has been failing since Teams phase 2, on this PR and on #11
before it, for a reason that has nothing to do with either: the pinned core
(website#140, the module-system de-UO slice) predates `api.registerTeamProvider`,
which this module has called since phase 1 of its Teams work. The module therefore
fails to LOAD in the pinned checkout — "api.registerTeamProvider is not a function"
— and a module that does not load adds no routes, which the job correctly reports
as the module having removed everything it serves.

So the red was real and was pointing at the pin, exactly as the pin's own comment
says it should: core moves for reasons that have nothing to do with this module,
and a bump is a deliberate commit saying which core the module was last proved
against.

Bumped to `edge` at Teams phase 3 (website#152) — the first core that has both
`registerTeamProvider` and the roster projection this module now implements.
Reproduced the whole job locally against that core: core's own manifest is current
at the new pin, the module loads, and the difference is 73 routes, all documented.
`routes.manifest.json` is unchanged and needed no regeneration, which is the
expected result for a client-only change.

Not bumped to the phase 4 core, deliberately: that is website#153 and is not merged
yet. Nothing in this module needs it — the second slot is a client-side
declaration, invisible to the route manifest.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 07:31:58 -05:00
9d0a197008 feat(guilds): declare a second place on the guild page, for core's forum
Some checks failed
PR Checks / client-build (pull_request) Successful in 14s
PR Checks / frozen-manifest (pull_request) Failing after 34s
PR Checks / server-tests (pull_request) Successful in 8m40s
The mirror of the activity feed, one phase later. Core owns the Team forum —
membership, manual grants and the member/guest split are all core's rules, and a
module reimplementing any of them would be reimplementing a security boundary — but
core publishes no Team page, because it does not own the word "guild". So this
module declares the place and core puts the forum in it.

TWO declarations rather than one, and that is the interesting part. A slot holds one
component and the first fill wins, so folding the forum into `uo.guild.detail`
alongside the feed would hand core the decision about where each of its two
contributions sits on a page this module owns. Separate slots also keep them
independent: with the forum switched off, the feed renders exactly as before.

The registration test now asserts the set of declared slots and that EVERY one of
them is rendered by the page that owns it, rather than naming a single slot twice.
A slot nothing renders is a slot core fills into the void.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 07:24:37 -05:00
eb30e4ae37 Merge pull request 'feat(guilds): the roster projection, a guild detail page, and the slot core fills' (#11) from feat/teams-phase3-projection-slot into edge
Reviewed-on: #11
2026-08-18 02:12:18 +00:00
dda0e32dd3 feat(guilds): a guild detail page, and the slot core puts the feed in
Some checks failed
PR Checks / client-build (pull_request) Successful in 16s
PR Checks / frozen-manifest (pull_request) Failing after 33s
PR Checks / server-tests (pull_request) Successful in 8m46s
The module's half of the org lead's correction: Teams is the contract, guilds
are the presentation, and the presentation is this module's.

Adds `/uo/guilds/:id` — the detail view the board never had — with the roster
from this module's OWN board, which is the same data it answers core's Team
provider from. Reading core's projection of our own answer back would be a round
trip through a staler copy of it.

The page declares `uo.guild.detail` and core fills it with the Team activity
feed. That is the one part of this page core cannot hand over: only core can
resolve whether the viewer is inside the Team, and the public/members split on
that feed is a security boundary. The guild is named in OUR terms — core maps
its own Team from the module id and the external id — so this module never holds
core's row id or slug.

`TeamOverviewStrip` is deleted with the core Team page it filled.
`team.member.row` is not declared here either: the useful thing to put in a
roster row is a link to the character behind it, and nothing core could supply
identifies one.

`GET /public/shard/guilds/:id` backs the page, gated and projected through the
same `guilds` feature as the board — so an operator who raises that audience
raises this too, and the locked acct/webId fields never survive below admin. A
roster is where those appear in bulk, which makes this the endpoint where
getting the projection wrong would matter most.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-17 20:58:30 -05:00
d4aa5ade12 feat(teams): project rosters by audience rung, and add to the Team page
The module's half of TEAMS.md phase 3.

`projectRoster` is the optional fourth provider method and the only one core
calls on a request path. Core holds the roster and owns its public shape; the
question that is this module's is who is allowed to look, because the audience
rungs and their configuration live here.

The answer is all-or-nothing, which is the honest translation rather than a
shortcut: a rung is a property of the FEATURE, and there is no configuration in
which some members of a guild are public and others are not.

The refusal semantics INVERT here, and the tests say so. For the other three
methods a refusal means "change nothing" and an empty array would be
destructive. Core fails CLOSED on this one, so the dangerous answer is the
opposite — returning every key because the config could not be read would
publish a roster an operator gated to staff. Every path that cannot reach a
confident answer refuses, including the catch.

The anonymous case is answered directly rather than by handing `viewerLevel` a
synthetic request. Given one with no `req.user` it falls through to
`auth.getUserFromRequest`, which expects real cookies and throws on a fake — and
that throw would have become a refusal, so every anonymous visitor would have
been served an empty roster on a shard whose guilds are public. Caught by the
tests, not by reading.

`team.overview` gets a live population reading beside core's stored one. Core's
number comes from the last roster sync and is coarse by construction; this is
the `presence.online` feed this module already holds. It is explicitly not a
per-Team presence figure — the shard publishes a global aggregate and no
per-guild breakdown exists on the wire, so claiming one would be inventing a
number — and it renders nothing at all when it has nothing true to say.

`team.member.row` is left unfilled. The useful thing to put there is a link to
the character behind a row, and the props core can supply do not identify one:
the member key and the site account id are withheld from every public roster.
An empty cell beats a guess.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-17 20:16:22 -05:00
0d618599cf Merge pull request 'feat(teams): ingest guild rank, and report every leader rather than one' (#10) from feat/protocol4-guild-rank into edge
Reviewed-on: #10
2026-08-17 22:48:54 +00:00
76b2321f25 Merge pull request 'feat(teams): answer core's Team provider from the guild board' (#9) from feat/teams-phase2-provider into edge
Reviewed-on: #9
2026-08-17 22:48:20 +00:00
99d1ca25a7 feat(teams): ingest guild rank, and report every leader rather than one
Some checks failed
PR Checks / client-build (pull_request) Successful in 15s
PR Checks / server-tests (pull_request) Successful in 20s
PR Checks / frozen-manifest (pull_request) Failing after 34s
The module half of the Protocol 4 rank amendment (servuo-plugins, same wire
version -- Protocol 4 is unreleased on `edge`, so it is amended rather than
bumped).

`shard_guild_members` gains `rank`, `rank_cliloc` and `rank_name`. The provider
then answers the question it previously could not: `getTeamLeaders()` returns
EVERY member at rank 4, not just the board's single `leader_serial`. That
limitation was the whole reason the wire grew a per-member rank -- TEAMS.md §2.5
treats multiple leaders as the normal case and core has always supported them.

The board's `leader_serial` is folded in as a floor rather than replaced. It
comes from a different frame, so on a shard whose roster has not been re-emitted
since the amendment it is the only leadership signal there is, and moving to
ranks must not lose it.

## NULL rank is a real state, and it is load-bearing

The shard withholds the rank for a staff account, because ServUO's
`PlayerMobile.GuildRank` reports Leader for anyone at GameMaster or above
whatever their actual rank. Every layer here preserves that:

  - the ingest stores NULL rather than defaulting to 0, which would be a
    demotion this code invented;
  - `leader` requires an integer rank >= 4, so absence is never leadership;
  - the leaders query compares on `rank`, and NULL is excluded by the comparison.

Reading a missing rank as either 0 or "leader" would republish the exact lie the
shard went out of its way not to send.

## Rank labels

Three sources, in order: a custom rank's literal string, then the operator's
cliloc table, then the five standard names. The last exists because the cliloc
table is populated only if someone ran the client-file extraction, and a roster
on a shard that has not should still read "Warlord" rather than nothing. A
failing lookup falls back rather than failing the roster -- a label is decoration,
and losing it must not lose the data.

`rank` is backticked everywhere it is written, like `int` on shard_online: it is
reserved in MySQL 8 and merely a keyword in MariaDB, so it parses bare here and
must not be relied on to.

The schema fragment carries ALTERs as well as the CREATE. No production install
has this table -- it is new in an unreleased protocol -- but `edge` deployments do,
from the roster work that landed before the amendment, and CREATE TABLE IF NOT
EXISTS adds a table and never a column. Same gap the sidecar's own store hit when
`guilds.members` was added.

## Verification

The unit tests stub the db layer, so the round trip was proved separately: the
VERBATIM roster frame captured from the live ServUO run was fed through the real
ingest into MariaDB and then read back through the provider.

  stored:   0x1F5 rank=4  0x1F6 rank=3  0x1F7 rank=2  0x1F8 rank=1
            0x1F9 rank=NULL (the GameMaster)  0x2E0 rank=4
  provider: leaders = [0x1F5, 0x2E0]   <- two, which the board alone cannot express
            labels  = Leader / Warlord / Emissary / Member, with no cliloc table
            0x1F9   = not a leader, no label

9/9 checks. Suite 413 -> 421 tests, all passing.

Refs docs/link/v4.md §2.3, docs/website/TEAMS.md §2.5

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-17 17:42:32 -05:00
c6929c6bae fix(teams): take query from the core facade, not a core.db that does not exist
Some checks failed
PR Checks / client-build (pull_request) Successful in 23s
PR Checks / server-tests (pull_request) Successful in 29s
PR Checks / frozen-manifest (pull_request) Failing after 40s
The Team provider's db layer built its query helper as `core.db.query(...)`. The
facade has no `db` member -- every other *.db.js in this module destructures
`query` from it directly -- so every call threw `Cannot read properties of
undefined (reading 'query')`.

The failure mode is the bad part. That throw is caught by the provider's own
error handling and turned into `{ ok: false, reason: 'roster unreadable: …' }`,
which is a perfectly valid refusal -- so core would have accepted it, held the
projection it had, and reported staleness. A provider that answers correctly and
never returns data, forever, with nothing in any log louder than a warning.

Invisible to the unit tests because they stub every db function, so the helper
was never called. Found by running a real roster frame through the ingest and
then asking the provider what it saw, against the real database.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-17 17:41:22 -05:00
51e58104bf Merge pull request 'feat(teams): answer core's Team provider from the guild board' (#9) from feat/teams-phase2-provider into edge
Reviewed-on: #9
2026-08-17 22:19:01 +00:00
268449f2a6 feat(teams): answer core's Team provider from the guild board
Some checks failed
PR Checks / client-build (pull_request) Successful in 16s
PR Checks / server-tests (pull_request) Successful in 21s
PR Checks / frozen-manifest (pull_request) Failing after 44s
A UO guild is a Team. This registers module-uo as the authoritative source of
them (MODULE_API 1.6.0, docs/website/TEAMS.md §2.3) and answers the three
questions core asks, from the board and the roster Protocol 4 put there.

`externalId` is the persistent ServUO `Guild.Id`, which survives a rename -- so
core sees "an id whose name changed" and applies its rename rule rather than an
unrelated new guild appearing beside the old one. That mapping is this module's
to make: only the game knows what identity survives what.

The most important code here is the refusal guard, and it is deliberately
conservative. Core's contract is that module unavailability becomes staleness and
never emptiness, and this module is the only thing that can honour it -- an empty
array from here reads as an authoritative "there are none", and core archives
Teams and departs members from an authoritative answer. Three states refuse: no
uo-link configured, the integration disabled, and the socket not connected.

**The third is the one worth arguing about.** The board is durable and survives an
outage, so serving it while disconnected looks harmless. It is not: core cannot
tell a board five minutes stale from one five days stale, and a complete answer
licenses destruction. There is a test named for that.

A fourth refusal has no equivalent anywhere else: a guild whose roster has not
arrived. Protocol 4's roster comes on its own frames, separately from the
`guild.update` that creates the board row, so there is a real window where a
155-member guild has zero roster rows. The board's own `members` count is the only
thing that distinguishes "the roster is late" from "this guild is empty", and it
is checked -- with the count in the refusal message, because it is the evidence.
The other side is tested too: when the board says zero, an empty roster is the
truth and withholding it would freeze a disbanding guild's membership forever.

Two limitations, both honest and both in the code as comments:

  - **`rankLabel` is null.** The wire's roster member is the standard actor object
    (`serial`, `name`, `player`, `acct?`, `webId?`) and carries no guild rank.
    Inventing a label from the leader flag would be core displaying something this
    module made up.

  - **One leader, not several.** TEAMS.md §2.5 expects multiple leaders from
    `GuildRank.Rank >= 4` and core supports them, but Protocol 4 does not put rank
    on the wire, so the only leadership visible here is the board's single
    `leader_serial`. Raising it to the full set is a protocol change, not
    something this module can fix.

`online` comes from `shard_online` rather than the roster, which carries no
per-member presence and only a board-level count -- the same source the public
"who's online" surface already uses. `userId` prefers the roster's own `web_id`
(what the shard asserted at roster time) and falls back to the `shard_account_links`
join for a member whose row predates their link; resolving it here rather than in
core is the contract, since core reading `shard_account_links` would be core
naming a module's table.

`coreApi` stays `^1.3.0` -- 1.6.0 satisfies it, which is what makes the bump minor.

18 provider tests plus two on the entry point: that all three methods are
registered, and that registration performs no query. The second matters because
register() runs while core's app.js is still being required with the pool pointed
at a dead port, which both routeManifest.js and swagger.js depend on.

`fakeApi` gained `registerTeamProvider` with the same `once` rule core applies --
one provider per deployment, so a second registration has to fail here too rather
than passing a shape core rejects at load.

411 -> 413 tests, all passing.

Refs docs/website/TEAMS.md §2.3, Part 12 phase 2

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-17 15:31:58 -05:00
e93361aa48 Merge pull request 'feat(shard): ingest guild rosters and departures (protocol 4)' (#8) from feat/teams-phase1-guild-roster into edge
Reviewed-on: #8
2026-08-17 19:29:05 +00:00
2fa4d87a40 feat(shard): ingest guild rosters and departures (protocol 4)
All checks were successful
PR Checks / client-build (pull_request) Successful in 15s
PR Checks / server-tests (pull_request) Successful in 19s
PR Checks / frozen-manifest (pull_request) Successful in 38s
Protocol 2 gave the guild board a member *count* and nothing else, so the Guilds
page could say a guild had 155 members but never who they were, and
findGuildForActor deliberately answered only for leaders because membership for
rank-and-file was not in the feed at all. Protocol 4 puts it there.

`shard_guild_members` holds one row per member per guild, keyed on
(guild_id, serial). `guild.roster` replaces a guild's rows; `guild.leave` removes
one. A guild.remove now clears the membership too, so a disbanded guild does not
leave orphaned rows behind.

The chunking needs explaining. A roster over the shard's per-frame cap arrives as
several frames carrying seq/more/total. The sidecar reassembles them for its own
GET /guilds board, but the live WebSocket feed and the /history backfill both
carry the individual frames — so this ingest sees them unreassembled.

It copes without buffering, because a table expresses what the sidecar's single
JSON column could not: the frame carrying seq 0 clears the guild first, and every
frame then upserts its own rows. Upsert rather than insert because the /history
backfill replays stored frames on every reconnect, and a redelivery has to be a
no-op rather than a duplicate-key error. The cost is a sub-second window during a
multi-frame update where the table holds part of a roster; buffering to close it
would duplicate the sidecar's reassembly for a projection that is already only as
fresh as a 60s sweep.

On visibility: both kinds are mapped to the existing `guilds` feature. Without
that mapping rule 2 fails an unmapped kind closed to admin-only, which would have
quietly kept rosters off the public page forever. Mapping them is safe because a
roster is the first frame carrying locked fields inside an ARRAY of actors rather
than one nested actor, and the projection walker already recurses into arrays and
matches acct/webId by suffix — so a member's account name is stripped below admin
by exactly the rule that already strips guild.leader.acct. There is a test for
that specifically, because the difference is a public page listing character names
versus one publishing 150 account names.

`acct`/`web_id` are still stored, since that is what lets a linked member be
matched to a site user; they are just never projected below admin.

guild.leave is appended to the event log, as the departure counterpart to
guild.join and for the same reason — it is what a "so-and-so left" feed reads.
guild.roster stays out: it is board state like guild.update, and it is the one fat
frame on the wire, so logging it would put a full membership snapshot into
shard_events on every membership change.

The PUBLIC_KINDS guard test caught the addition, which is what it is for; its
expected set now carries a v4 group alongside the v3 one.

Refs: docs/website/TEAMS.md Part 12 Phase 1

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-17 12:57:26 -05:00
97e2fddfcd Merge pull request 'chore(ci): scan this repo with SonarQube (phase 4, slice 0)' (#7) from chore/sonarqube into main
All checks were successful
Release / release (push) Successful in 8s
SonarQube / analysis (push) Successful in 1m54s
Reviewed-on: #7
2026-08-12 07:37:39 +00:00
62c8ee68b4 chore(ci): scan this repo with SonarQube (phase 4, slice 0)
All checks were successful
PR Checks / client-build (pull_request) Successful in 14s
PR Checks / frozen-manifest (pull_request) Successful in 34s
PR Checks / server-tests (pull_request) Successful in 8m50s
Until now this was the one part of the platform that had never been scanned.
The 75 files here arrived in the Phase 3 extraction and left their Sonar
history behind in core's project, so a whole module's worth of shipped code
has no dashboard at all.

Adds sonar-project.properties (project key Module-uo) and a sonarqube.yml
mirroring website's: push to main, never a PR gate, nothing waiting on the
quality gate.

Two things differ from core's config, both because this repo is shaped
differently:

  - There is no src/ to point sonar.sources at — the server half keeps
    boot.js/core.js/index.js at server/ root beside its subdirectories — so
    the whole tree is included and the non-source parts are excluded. That
    direction is deliberate: a new top-level server directory is scanned by
    default rather than silently unscanned.
  - server/scripts and client/scripts are IN. checkImports.js and
    checkExternals.js are the enforcement of MODULE_API.md 5.1 and 3.6, they
    carry their own test suites, and both have already shipped defects a
    reviewer missed. Build code that decides whether a release is allowed out
    is not throwaway code.

The workflow builds the client chunk before running either suite, for the
reason pr-checks.yml already calls load-bearing: build.test.js and
registration.test.js read dist/entry.js and SKIP without it, so the other
order reports coverage for a suite that quietly asked less than it looks like
it did.

Both suites run from the repo root rather than with --prefix, so the LCOV SF:
paths come out repo-root-relative and resolve against sonar.sources. That is
why the server suite's --require is spelled out here instead of reusing
`npm test --prefix server`, whose path is relative to server/.

Verified locally: 385 server test cases across 57 covered files and 40 client
cases, both LCOV and Generic Test Execution XML well-formed with
repo-root-relative paths.

Needs one-time setup in the Gitea UI before it can run — secret SONAR_TOKEN
and variable SONAR_HOST_URL, same as the other repos.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-12 02:36:11 -05:00
67 changed files with 12996 additions and 134 deletions

View File

@@ -16,6 +16,17 @@
# tree works right up until core moves a file, and the whole boundary is
# worth exactly as much as this check is (§5.1).
#
# • `server: check:bundle` — the release ships everything the entry point can
# reach. Every other job here runs against the whole repo, but a release is a
# SUBSET of it (release.yml assembles from the include list in
# `ci/bundle.json`), and nothing compared the two. On 2026-08-19 they
# disagreed: `server/commands/` arrived with the Teams cutover, the include
# list did not learn about it, and v1.0.0 installed and then died at the
# register stage on the operator's box with "Cannot find module
# './commands/guild.command'". Green here, broken there — because the subset
# only exists in the release. This asks, on the PR that adds the directory,
# whether the list still covers what index.js reaches.
#
# • `client: check:externals` — the BUILT chunk has no bare imports left. That
# failure is invisible in source: `import { useState } from 'react'` is
# correct in every file, and whether it becomes core's React or a bare
@@ -61,12 +72,19 @@
#
# Runner: the shared self-hosted `ubuntu-latest` runner. These jobs need only
# Node — no Docker socket, no database.
#
# Scope note: `edge` is gated as well as `main`. Multi-phase work lands there
# first, so gating only the `main` hop would run these checks for the first time
# at the cutover — the one moment a red build is most expensive to discover. This
# is the same call `RunicGateway/installer` made for the same reason, and it was
# taken here after a nine-PR Android workstream landed on an ungated `edge` with
# no CI at all. Adding a branch to the `branches:` list is the whole change.
name: PR Checks
on:
pull_request:
branches: [main]
branches: [main, edge]
# A newer push to the same PR cancels the in-flight run.
concurrency:
@@ -106,6 +124,9 @@ jobs:
- name: Check the module boundary (MODULE_API.md §5.1)
run: npm run check:imports --prefix server
- name: Check the release ships what the module requires
run: npm run check:bundle --prefix server
- name: Check the OpenAPI fragment is current (MODULE_API.md §2.8)
run: npm run check:swagger --prefix server

View File

@@ -11,25 +11,52 @@
# admin install downloads the tarball, verifies it against the `sha256` in the
# manifest, and unpacks it onto the volume. Nothing runs `npm` on the way.
#
# ── The version is DECLARED, not derived ────────────────────────────────────
# ── The version is DERIVED, and the declaration is a floor ──────────────────
#
# Unlike RunicGateway/link and RunicGateway/installer, whose release engines read
# conventional-commit subjects to compute the next version, this repo already has
# one authoritative version — `module.json`'s, which is the version core records
# in `installed_modules` and shows on the admin screen, and which sits beside the
# `coreApi` range a bump usually has to be considered against. Two sources for one
# number is how they drift, so: **a release happens when a merge to `main` leaves
# `module.json` at a version that has no release yet.** Bumping the version is an
# ordinary reviewed PR; publishing is this file's business.
# This file used to release only when a merge to `main` left `module.json` at a
# version with no release yet — the version DECLARED, never computed, on the
# argument that two sources for one number is how they drift. That was true and
# it was still the wrong trade: it makes every bundle cost a second reviewed PR
# whose entire content is a number, and between 2026-08-12 and 2026-08-19 it cost
# this repo *every* bundle — v0.3.0 was the only release while nine phases of
# Teams work landed, because nothing in them touched that line.
#
# It follows that this workflow never writes to a branch — it tags and publishes,
# nothing else — so `main` needs no push exception. That is the installer's model,
# adopted here for the reason it was adopted there: `main` is protected, and a
# release engine that has to push to it is a release engine that stops working the
# day someone tightens the rule.
# So the engine `link` and `installer` already run is adopted here (MODULE_SYSTEM
# §2.7.1, decision 19 as amended):
#
# feat!: / BREAKING CHANGE -> major feat: -> minor fix|perf: -> patch
# nothing releasable -> no release is cut
# (first ever run, no tag) -> releases what module.json declares
#
# **The declared version is kept as a floor, not deleted.** If `module.json` names
# a version above the newest tag, that version releases — which is the old model
# exactly, surviving as the special case it always was. Raising it by hand is
# still how you say "this one is a minor, whatever the subjects imply", and it is
# still the natural place to move when a `coreApi` bump forces the question. What
# no longer happens is a merge full of `feat:` producing nothing.
#
# The number that ships is therefore the TAG, and CI writes it into the
# `module.json` inside the bundle at assembly time. The committed `module.json` is
# a floor and a starting point, not a record of the last release — `link` reached
# the same arrangement with `Cargo.toml`, for the same reason: a release engine
# that has to commit a bump back to `main` stops working the day someone protects
# the branch, and this one is protected.
#
# ── The backdoor ────────────────────────────────────────────────────────────
#
# `workflow_dispatch` publishes on demand, for the case the rules above cannot
# reach: `module.json` changed in a way worth shipping — a widened `coreApi`, a
# new mount, a capability — with no releasable code behind it. Leave `version`
# blank to bump the newest tag by `bump` (default `patch`), or name an exact
# version to publish that. A dispatch releases even when nothing in the log is
# releasable; that is the entire point of pressing the button.
#
# Re-running on a version that is already released is a no-op, so a rerun after an
# unrelated failure is safe.
# unrelated failure is safe. A tag that exists with no release behind it is NOT a
# no-op — see the recovery branch in the plan step.
#
# This workflow still never writes to a branch. It tags and publishes, so `main`
# needs no push exception.
#
# Prerequisites (Settings → Actions → Secrets on RunicGateway/Module-uo):
# REGISTRY_TOKEN — Gitea access token with `write:repository`, to push the tag
@@ -40,6 +67,16 @@ name: Release
on:
push:
branches: [main]
workflow_dispatch:
inputs:
version:
description: 'Exact version to publish (e.g. 0.4.1). Blank = bump the newest tag by the level below.'
required: false
default: ''
bump:
description: 'Bump level when version is blank: patch | minor | major'
required: false
default: 'patch'
concurrency:
group: release-module-uo
@@ -54,6 +91,8 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
# Full history: the plan step reads every tag and every subject since the
# newest one, and a shallow clone has neither.
- uses: actions/checkout@v4
with:
fetch-depth: 0
@@ -62,32 +101,169 @@ jobs:
with:
node-version: 20
- name: Decide whether this commit releases
- name: Plan the release (version + changelog)
id: plan
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
EVENT: ${{ github.event_name }}
IN_VERSION: ${{ github.event.inputs.version }}
IN_BUMP: ${{ github.event.inputs.bump }}
run: |
set -euo pipefail
VERSION="$(node -p "require('./module.json').version")"
echo "module.json version: ${VERSION}"
mkdir -p dist
git fetch --tags --force >/dev/null 2>&1 || true
# Does a release already exist for this version? A 404 means no, a 200
# means yes, and anything else — a network failure, a bad token — is not
# evidence of absence. Guessing "no" would publish over a good release,
# so refuse instead. (The installer learned this one the expensive way.)
HTTP="$(curl -s -o /dev/null -w '%{http_code}' \
-H "Authorization: token $(printf '%s' "${REGISTRY_TOKEN:-}" | tr -d '\r\n')" \
"https://${GITEA_HOST}/api/v1/repos/${REPO}/releases/tags/v${VERSION}" || echo 000)"
DECLARED="$(node -p "require('./module.json').version")"
LAST_TAG="$(git describe --tags --match 'v*' --abbrev=0 2>/dev/null || true)"
CURRENT="${LAST_TAG#v}"
RANGE="${LAST_TAG:+${LAST_TAG}..}HEAD"
echo "module.json declares ${DECLARED}; newest tag is ${LAST_TAG:-<none>}"
case "$HTTP" in
404) RELEASE=true ;;
200) RELEASE=false; echo "v${VERSION} is already released — nothing to do." ;;
*) echo "::error::Could not determine whether v${VERSION} is released (HTTP ${HTTP}). Refusing to guess."; exit 1 ;;
esac
SUBJECTS="$(git log --no-merges --format='%s' $RANGE || true)"
BODIES="$(git log --no-merges --format='%B' $RANGE || true)"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
echo "release=${RELEASE}" >> "$GITHUB_OUTPUT"
BUMP=none
if echo "$BODIES" | grep -qE 'BREAKING[ -]CHANGE' ; then BUMP=major; fi
if echo "$SUBJECTS" | grep -qE '^[a-z]+(\([^)]+\))?!:' ; then BUMP=major; fi
if [ "$BUMP" = none ] && echo "$SUBJECTS" | grep -qE '^feat(\([^)]+\))?:' ; then BUMP=minor; fi
if [ "$BUMP" = none ] && echo "$SUBJECTS" | grep -qE '^(fix|perf)(\([^)]+\))?:' ; then BUMP=patch; fi
bump() { # <x.y.z> <major|minor|patch> -> bumped
IFS=. read -r MA MI PA <<< "$1"
case "$2" in
major) echo "$((MA+1)).0.0" ;;
minor) echo "${MA}.$((MI+1)).0" ;;
patch) echo "${MA}.${MI}.$((PA+1))" ;;
esac
}
# `sort -V` orders version strings, so the higher of two is its last
# line. Used rather than a hand-rolled field compare because 0.10.0 vs
# 0.9.0 is exactly the comparison a string sort gets wrong.
higher() { printf '%s\n%s\n' "$1" "$2" | sort -V | tail -1; }
rank() { case "$1" in major) echo 3 ;; minor) echo 2 ;; patch) echo 1 ;; *) echo 0 ;; esac; }
bigger_bump() { if [ "$(rank "$1")" -ge "$(rank "$2")" ]; then echo "$1"; else echo "$2"; fi; }
VERSION=""
if [ -n "${IN_VERSION:-}" ]; then
# The backdoor's exact form. Deliberately unvalidated against the log:
# a human typed it, and the already-released check below is the only
# guard that matters.
VERSION="${IN_VERSION}"
echo "dispatch: publishing the requested version ${VERSION}"
else
LEVEL="$BUMP"
# A dispatch with nothing releasable in the log still releases — that
# is what the button is for. Where the log DOES say something, the
# larger of the two wins rather than the input: pressing the button on
# a log full of `feat:` without touching the dropdown would otherwise
# publish its `patch` default over a minor's worth of work, and a
# version that undersells its own contents cannot be taken back.
if [ "${EVENT:-}" = workflow_dispatch ]; then
LEVEL="$(bigger_bump "$LEVEL" "${IN_BUMP:-patch}")"
if [ "$BUMP" = none ]; then
echo "dispatch: nothing releasable in the log, bumping ${LEVEL} anyway"
elif [ "$LEVEL" != "$BUMP" ]; then
echo "dispatch: the log says ${BUMP}, the run asked for ${LEVEL} — taking ${LEVEL}"
fi
fi
if [ -z "$CURRENT" ]; then
VERSION="$DECLARED" # first ever release: ship what is declared
elif [ "$LEVEL" != none ]; then
VERSION="$(bump "$CURRENT" "$LEVEL")"
fi
# The floor. A `module.json` above the newest tag releases at that
# version even when the log says nothing and even when the log says
# patch — which is the pre-2026-08-19 model, kept as a special case.
if [ -n "$CURRENT" ] && [ "$DECLARED" != "$CURRENT" ] \
&& [ "$(higher "$DECLARED" "$CURRENT")" = "$DECLARED" ]; then
if [ -z "$VERSION" ] || [ "$(higher "$DECLARED" "$VERSION")" = "$DECLARED" ]; then
echo "module.json declares ${DECLARED}, above both ${CURRENT} and the derived version — releasing that."
VERSION="$DECLARED"
fi
fi
fi
RELEASE=true
if [ -z "$VERSION" ]; then
RELEASE=false
VERSION="$CURRENT"
echo "Nothing releasable since ${LAST_TAG} (no feat/fix/perf/breaking subject) — standing down."
fi
# An existing tag is NOT automatically "nothing to do". A tag with no
# release behind it means a previous run tagged and then died before
# publishing — which is what happened on servuo-plugins' first release,
# where absent secrets took the release API call to 401 after the tag
# had already been pushed. Standing down on the tag alone makes that
# state permanent. Note this deliberately OVERRIDES the RELEASE=false
# above: with the tag in place there is nothing releasable after it, so
# the normal path would stand down, which is why it could never
# self-heal. Anything other than 200/404 — a network failure, a bad
# token — is not evidence of absence, and guessing "no" would publish
# over a good release, so refuse instead.
REUSE_TAG=false
if [ -n "$VERSION" ] && git rev-parse -q --verify "refs/tags/v${VERSION}" >/dev/null; then
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN:-}" | tr -d '\r\n')"
REL_HTTP="$(curl -s -o /dev/null -w '%{http_code}' \
-H "Authorization: token ${CI_TOKEN}" \
"https://${GITEA_HOST}/api/v1/repos/${REPO}/releases/tags/v${VERSION}" || echo 000)"
case "$REL_HTTP" in
200) echo "v${VERSION} is already released — nothing to do."; RELEASE=false ;;
404) echo "::warning::Tag v${VERSION} exists but has no release — a previous run failed after tagging. Reusing the tag and publishing the release it is missing."
REUSE_TAG=true; RELEASE=true ;;
*) echo "::error::Could not determine whether v${VERSION} is released (HTTP ${REL_HTTP}). Refusing to guess."; exit 1 ;;
esac
fi
# Changelog range. A recovery run has nothing after the tag, so
# summarize what the tag itself contains rather than emitting an empty
# list: the range that produced it, i.e. previous-tag..this-tag.
if [ "$REUSE_TAG" = true ]; then
PREV_TAG="$(git describe --tags --match 'v*' --abbrev=0 "v${VERSION}^" 2>/dev/null || true)"
CL_RANGE="${PREV_TAG:+${PREV_TAG}..}v${VERSION}"
SINCE="$PREV_TAG"
else
CL_RANGE="$RANGE"
SINCE="$LAST_TAG"
fi
CL_SUBJECTS="$(git log --no-merges --format='%s' $CL_RANGE || true)"
{
echo "## module-uo v${VERSION}"
echo
echo "Install from the website's Admin → Modules screen by pasting the URL of"
echo "\`module-uo-${VERSION}.json\`, or unpack the tarball onto the modules volume"
echo "as \`modules/uo/\`. Requires a core whose \`MODULE_API_VERSION\` satisfies"
echo "\`$(node -p "require('./module.json').coreApi")\`."
echo
FEATS="$(echo "$CL_SUBJECTS" | grep -E '^feat' || true)"
FIXES="$(echo "$CL_SUBJECTS" | grep -E '^(fix|perf)' || true)"
[ -n "$FEATS" ] && { echo "### Features"; echo "$FEATS" | sed 's/^/- /'; echo; }
[ -n "$FIXES" ] && { echo "### Fixes"; echo "$FIXES" | sed 's/^/- /'; echo; }
echo "### All changes"
if [ -n "$SINCE" ]; then echo "Since ${SINCE}:"; fi
echo "$CL_SUBJECTS" | sed 's/^/- /'
echo
echo "### Verifying this download"
echo
echo "Releases are **unsigned** — the \`sha256\` in \`module-uo-${VERSION}.json\` is the"
echo "trust anchor, and the website verifies it before unpacking."
echo
echo '```bash'
echo "sha256sum -c SHA256SUMS --ignore-missing"
echo '```'
} > dist/CHANGELOG.md
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
echo "release=${RELEASE}" >> "$GITHUB_OUTPUT"
echo "reuse_tag=${REUSE_TAG}" >> "$GITHUB_OUTPUT"
echo "bump=${BUMP}" >> "$GITHUB_OUTPUT"
echo "==> release=${RELEASE} version=${VERSION} bump=${BUMP} declared=${DECLARED} last_tag=${LAST_TAG:-<none>}"
# Before anything is built or tagged, so a repo without secrets fails
# legibly rather than half-publishing: the tag push can succeed on the
@@ -125,25 +301,41 @@ jobs:
# Stated as an INCLUDE list, not an exclude list. An exclude list ships
# whatever it forgot: the day someone adds `server/tools/` with a scratch
# credential in it, an exclude list packs it and nobody finds out.
#
# The list itself lives in `ci/bundle.json`, not here, because it has a
# second reader: `server/scripts/checkBundle.js` runs in PR checks and asks
# whether the list still covers everything `server/index.js` reaches. It
# was hardcoded in this file until v1.0.0 shipped without `server/commands/`
# — added by the Teams cutover, never added here — and the module died at
# the register stage on the operator's box. One declaration, two readers,
# so the next directory cannot go missing quietly.
- name: Assemble the bundle
if: ${{ steps.plan.outputs.release == 'true' }}
run: |
set -euo pipefail
VERSION="${{ steps.plan.outputs.version }}"
OUT="dist/module-uo-${VERSION}"
rm -rf dist && mkdir -p "$OUT"
rm -rf "$OUT" && mkdir -p "$OUT"
# The manifest core reads, the two fragments, and the licence the code
# is under — a bundle that ships GPL code without its licence is not
# distributable.
cp module.json swagger-fragment.json LICENSE.md README.md "$OUT/"
# The manifest core reads — with the RELEASED version written into it.
# The committed `module.json` is a floor, not a record of the last
# release (see the header), so copying it verbatim would ship a bundle
# whose `installed_modules` row and admin screen disagree with the tag
# it came from. This is the one place the derived number becomes the
# module's own.
jq --arg v "$VERSION" '.version = $v' module.json > "$OUT/module.json"
# The two fragments, and the licence the code is under — a bundle that
# ships GPL code without its licence is not distributable.
for f in $(jq -r '.root[]' ci/bundle.json); do
cp "$f" "$OUT/"
done
# The server half, minus what never runs inside core's process.
mkdir -p "$OUT/server"
for d in boot.js core.js index.js config data db model router utils; do
for d in $(jq -r '.server[]' ci/bundle.json); do
cp -r "server/$d" "$OUT/server/"
done
cp server/package.json "$OUT/server/"
cp -r server/node_modules "$OUT/server/"
# The client half is the BUILT chunk only. `client/src` is 5,000 lines
@@ -154,16 +346,36 @@ jobs:
# Prove the bundle is loadable before it is published: these are the
# paths core's loader resolves out of module.json, and a release whose
# entry point is missing fails on an operator's box with a
# `startup_failed` row instead of here.
# `startup_failed` row instead of here. The version assertion guards the
# rewrite above — a bundle that still carries the declared version would
# install under a number that is not the one it was released as.
node -e '
const fs = require("fs"), path = require("path");
const root = process.argv[1];
const [root, want] = process.argv.slice(1);
const m = JSON.parse(fs.readFileSync(path.join(root, "module.json"), "utf8"));
if (m.version !== want) {
console.error(`bundle declares ${m.version}, but this is release ${want}`);
process.exit(1);
}
for (const p of [m.server, m.schema, m.purge, m.client.entry, "swagger-fragment.json"]) {
if (!fs.existsSync(path.join(root, p))) { console.error("bundle is missing " + p); process.exit(1); }
}
console.log("bundle contents check: ok");
' "$OUT"
' "$OUT" "$VERSION"
# ── And that it can actually LOAD ─────────────────────────────────
#
# The check above stats the paths `module.json` declares, which is a
# real question but a shallow one: v1.0.0 passed it and was still
# missing `server/commands/`, because a file reached only by a require
# inside `register()` is named nowhere in `module.json`. This resolves
# every relative require in the assembled tree and asserts the target is
# in it — asked of the artifact, so it also catches a copy that half
# failed or a list naming a path that has since moved.
#
# Run from the SOURCE tree (`server/scripts/` never ships) against the
# assembled bundle.
node server/scripts/checkBundle.js --bundle "$OUT"
tar -C dist -czf "dist/module-uo-${VERSION}.tar.gz" "module-uo-${VERSION}"
rm -rf "$OUT"
@@ -191,36 +403,10 @@ jobs:
echo "${SHA} module-uo-${VERSION}.tar.gz" > dist/SHA256SUMS
cat "dist/module-uo-${VERSION}.json"
- name: Write the changelog
if: ${{ steps.plan.outputs.release == 'true' }}
run: |
set -euo pipefail
VERSION="${{ steps.plan.outputs.version }}"
LAST_TAG="$(git describe --tags --match 'v*' --abbrev=0 2>/dev/null || true)"
RANGE="${LAST_TAG:+${LAST_TAG}..}HEAD"
{
echo "## module-uo v${VERSION}"
echo
echo "Install from the website's Admin → Modules screen, or unpack onto the"
echo "modules volume as \`modules/uo/\`. Requires a core whose \`MODULE_API_VERSION\`"
echo "satisfies \`$(node -p "require('./module.json').coreApi")\`."
echo
echo "### Changes"
if [ -n "$LAST_TAG" ]; then echo "Since ${LAST_TAG}:"; fi
git log --no-merges --format='- %s' $RANGE || true
echo
echo "### Verifying this download"
echo
echo "Releases are **unsigned** — the \`sha256\` in \`module-uo-${VERSION}.json\` is the"
echo "trust anchor, and the website verifies it before unpacking."
echo
echo '```bash'
echo "sha256sum -c SHA256SUMS --ignore-missing"
echo '```'
} > dist/CHANGELOG.md
# Skipped on a recovery run: the tag is already there and is the thing being
# published against.
- name: Tag the release
if: ${{ steps.plan.outputs.release == 'true' }}
if: ${{ steps.plan.outputs.release == 'true' && steps.plan.outputs.reuse_tag != 'true' }}
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |

View File

@@ -0,0 +1,103 @@
# Run SonarQube static analysis against the code that just landed on `main` and
# report the results to the self-hosted SonarQube server for review. This is
# intentionally NON-BLOCKING: it triggers on push to main (i.e. AFTER merge),
# not on pull_request, so it never gates a PR. It complements pr-checks.yml
# (which gates PRs) and release.yml (which publishes the bundle) — this one only
# feeds the dashboard.
#
# Mirrors RunicGateway/website's sonarqube.yml, for the same reason pr-checks.yml
# does: this module is two npm packages shaped like that repo's `server/` and
# `client/`, and it is loaded into that repo's process. Until now it was the one
# part of the platform that had never been scanned — 75 files that arrived in the
# Phase 3 extraction with core's Sonar history left behind in core's project.
#
# Prerequisites (one-time, in the Gitea UI — Repo → Settings → Actions):
# • Secret SONAR_TOKEN — a SonarQube "Analysis" token generated at
# My Account → Security in SonarQube for the
# Module-uo project (or a global one).
# • Variable SONAR_HOST_URL — the SonarQube base URL on your LAN, e.g.
# http://192.168.0.56:9000
# (kept as a variable, not committed, so the internal address stays out of git.)
#
# The runner (self-hosted `ubuntu-latest`, same as the other workflows) must be
# able to reach SONAR_HOST_URL on your network. Nothing here waits on the
# SonarQube Quality Gate, so a failing gate does not fail this job — check the
# dashboard when you want to.
name: SonarQube
on:
push:
branches: [main]
# Allow re-running the analysis on demand from the Actions tab.
workflow_dispatch: {}
concurrency:
group: sonarqube-${{ github.ref }}
cancel-in-progress: true
jobs:
analysis:
runs-on: ubuntu-latest
steps:
- name: Check out (full history for accurate new-code + blame)
uses: actions/checkout@v4
with:
# SonarQube uses git history to attribute issues to authors and to
# compute "new code". A shallow clone degrades both.
fetch-depth: 0
# Node 22, where pr-checks.yml pins 20: the built-in `lcov` coverage
# reporter this job depends on needs >= 22. The version that matters for
# correctness is the one in pr-checks.yml, which matches the core process
# this module is loaded into; nothing here ships.
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Install deps for both halves
run: |
npm ci --prefix server
npm ci --prefix client
# The chunk has to exist before the client suite runs: build.test.js and
# registration.test.js read `client/dist/entry.js`, and both SKIP when
# there is no build. Run the other way round they skip silently and this
# job reports coverage for a suite that quietly asked less than it looks
# like it did — the same ordering pr-checks.yml calls load-bearing.
- name: Build the client chunk
run: npm run build --prefix client
# SonarQube runs static analysis only — it never executes the test suite,
# so we must produce the coverage report ourselves and hand it to the
# scanner (see sonar.javascript.lcov.reportPaths in sonar-project.properties).
#
# Both suites are invoked from the REPO ROOT rather than with `--prefix`,
# so the LCOV `SF:` paths come out repo-root-relative (`server/router/...`,
# `client/src/...`) and resolve against sonar.sources. That is also why the
# server suite's `--require` is spelled out here instead of reusing
# `npm test --prefix server`, whose path is relative to `server/`.
- name: Generate server test coverage (LCOV)
run: |
mkdir -p server/coverage
node --test --experimental-test-coverage \
--require ./server/test/_setup.js \
--test-reporter=spec --test-reporter-destination=stdout \
--test-reporter=lcov --test-reporter-destination=server/coverage/lcov.info \
--test-reporter=./scripts/sonar-test-reporter.mjs --test-reporter-destination=server/coverage/test-execution.xml \
server/test/*.test.js
- name: Generate client test coverage (LCOV)
run: |
mkdir -p client/coverage
node --test --experimental-test-coverage \
--test-reporter=spec --test-reporter-destination=stdout \
--test-reporter=lcov --test-reporter-destination=client/coverage/lcov.info \
--test-reporter=./scripts/sonar-test-reporter.mjs --test-reporter-destination=client/coverage/test-execution.xml \
client/test/*.test.js
- name: Run SonarQube scan
uses: sonarsource/sonarqube-scan-action@v4
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
SONAR_HOST_URL: ${{ vars.SONAR_HOST_URL }}

View File

@@ -96,6 +96,19 @@ when someone builds on the server is not shippable.
branch and no cutover, unlike `website`, whose module work accumulates on `edge`
and reaches `main` once.
### Static analysis runs after the merge, not on the PR
`.gitea/workflows/sonarqube.yml` scans `main` on push and reports to the
self-hosted SonarQube instance under the project key **`Module-uo`**. It is
deliberately non-blocking: it never gates a pull request, and a failing quality
gate does not fail the job. Check the dashboard when you want to; the things
that must not reach `main` are gated by `pr-checks.yml` instead.
It runs both suites from the repo root to produce coverage, and builds the
client chunk first — two of the client tests read `dist/entry.js` and skip
without it, which would leave this job reporting on a suite that quietly asked
less than it appears to.
### Commit messages
We use [Conventional Commits](https://www.conventionalcommits.org/) —

View File

@@ -38,7 +38,7 @@ in the docs repo — **read them before opening a PR here.** Where the two diffe
| 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` | |
| 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
@@ -101,7 +101,7 @@ server/index.js the entry point — register(ctx, api), synchronous, no
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/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
@@ -112,6 +112,14 @@ 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`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/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
@@ -134,11 +142,20 @@ 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.
**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:

43
ci/bundle.json Normal file
View File

@@ -0,0 +1,43 @@
{
"$comment": [
"What a release copies into the bundle, declared ONCE. Read by .gitea/workflows/release.yml",
"when it assembles the tarball, and by server/scripts/checkBundle.js when CI asks whether",
"that list still covers everything the module's entry point can reach.",
"",
"This is an INCLUDE list on purpose (release.yml's header argues the case): an exclude list",
"ships whatever it forgot, so the day someone adds server/tools/ with a scratch credential",
"in it, an exclude list packs it and nobody finds out. The cost of that choice is that a new",
"top-level directory silently drops OUT of every release instead — which is exactly what",
"happened to server/commands/ between v0.3.0 and v1.0.0, and is why checkBundle.js exists.",
"",
"server[] entries are paths under server/; root[] and generated[] are paths under the module",
"root. node_modules is not listed: the release installs it with `npm ci --omit=dev` and copies",
"it separately, so it is not a checked-in path.",
"",
"generated[] ships but is not copied — release.yml writes module.json through jq to stamp the",
"released version into it, since the committed one is a floor rather than a record of the last",
"release. It is listed because server/index.js requires it, and a check that did not know it",
"ships would report the module's own manifest as missing from the bundle."
],
"server": [
"boot.js",
"commands",
"config",
"core.js",
"data",
"db",
"index.js",
"model",
"package.json",
"router",
"utils"
],
"root": [
"swagger-fragment.json",
"LICENSE.md",
"README.md"
],
"generated": [
"module.json"
]
}

View File

@@ -1,6 +1,6 @@
{
"$comment": "The core this module is proved against. MODULE_API.md §5.3: the frozen-manifest job clones RunicGateway/website at this exact ref, drops this module in as modules/uo and runs CORE's own routeManifest.js — nothing else can answer whether the URLs the module claims are the URLs it actually serves. Pinned rather than tracking `edge` on purpose: core moves for reasons that have nothing to do with this module, and a bump is then a deliberate commit saying which core the module was last proved against, instead of an unexplained red X on someone else's PR. Bump it, regenerate routes.manifest.json, and commit both together.",
"$comment": "The core this module is proved against. MODULE_API.md §5.3: the frozen-manifest job clones RunicGateway/website at this exact ref, drops this module in as modules/uo and runs CORE's own routeManifest.js — nothing else can answer whether the URLs the module claims are the URLs it actually serves. Pinned rather than tracking a branch on purpose: core moves for reasons that have nothing to do with this module, and a bump is then a deliberate commit saying which core the module was last proved against, instead of an unexplained red X on someone else's PR. Bump it, regenerate routes.manifest.json, and commit both together. **It pointed at `edge` for the length of the Event System window** (org lead, 2026-09-04), and this commit ends that: `api.registerEventActions` exists only from MODULE_API 1.10.0, so under the previous `main` pin `register()` threw and the module did not load at all — the job would have been red by construction for eight phases and would have proved nothing while a real regression hid behind it. The Phase 16b cutover put 1.10.0 on `main`, so the pin comes home, and this is the same move that turns the Integration kit green again. **routes.manifest.json needed NO regeneration**: the job's own steps were run against this exact ref and answered `routes.manifest.json is current — 73 routes, all documented`, so the \"commit both together\" instruction above had nothing to pair with this time.",
"repo": "https://gitea.whitlocktech.com/RunicGateway/website.git",
"ref": "87230c879aa6e9adde3507718aed6bc4e4d86009",
"refName": "edge @ phase 3 slice 4 (website#140)"
"ref": "655fbf3f69a6a1fd650ecbc81afd6cf9c2ad9f66",
"refName": "main @ MODULE_API 1.10.0, the Event System cutover (website#199)"
}

View File

@@ -39,6 +39,7 @@ export const shard = {
champs: () => req('/public/shard/champs'),
// Protocol 2.0 boards.
guilds: () => req('/public/shard/guilds'),
guild: (id) => req(`/public/shard/guilds/${encodeURIComponent(id)}`),
governors: () => req('/public/shard/governors'),
governorHistory: (city, limit) =>
req(`/public/shard/governors/${encodeURIComponent(city)}/history${withQs(limit ? `limit=${limit}` : '')}`),

View File

@@ -48,7 +48,7 @@ if (createElement !== rg.react.createElement || createRoot !== rg.reactDom.creat
)
}
// The curated kit (§3.4). Seven members, closed: anything else this module needs
// The curated kit (§3.4). Eight members, closed: anything else this module needs
// it bundles itself, which is why `components/` next door exists at all.
export const {
PublicLayout,
@@ -59,6 +59,11 @@ export const {
useAsync,
useAuth,
useSite,
// Eighth member (MODULE_API 1.6.0): the slot renderer, for the INVERTED
// direction — this module declares a place on its own page and CORE fills it.
// Shared rather than reimplemented so core's content failing inside our page is
// contained by core's own error boundary.
Slot,
} = rg.ui
// The registry, for entry.jsx. Everything else here is read by pages.

View File

@@ -26,6 +26,7 @@ import Shard from './routes/public/Shard.jsx'
import ShardActivity from './routes/public/ShardActivity.jsx'
import ChampSpawns from './routes/public/ChampSpawns.jsx'
import Guilds from './routes/public/Guilds.jsx'
import Guild from './routes/public/Guild.jsx'
import Governors from './routes/public/Governors.jsx'
import Houses from './routes/public/Houses.jsx'
import Rules from './routes/public/Rules.jsx'
@@ -81,6 +82,7 @@ registry.registerRoutes(ID, {
{ path: 'shard/activity', element: <ShardActivity /> },
{ path: 'champs', element: <ChampSpawns /> },
{ path: 'guilds', element: <Guilds /> },
{ path: 'guilds/:id', element: <Guild /> },
{ path: 'governors', element: <Governors /> },
{ path: 'houses', element: <Houses /> },
{ path: 'rules', element: <Rules /> },
@@ -180,6 +182,35 @@ registry.registerFeatureProvider(ID, ID, useShardFlags)
registry.registerExtension(ID, 'site.footer.status', ShardStatusLink)
registry.registerExtension(ID, 'admin.users.detail', UserShardSections)
registry.registerExtension(ID, 'player.invite.accepted', InviteGameAccountStep)
// ── The inverted slot: this module DECLARES, core fills ────────────────────
//
// The other three above are core's slots that this module fills. This one is the
// reverse (TEAMS.md Part 3): Teams are a core primitive that this module
// populates, but core does not own the word "guild" and publishes no Team page of
// its own — so the page is ours and core contributes the activity feed to it.
//
// Declared under this module's own namespace, which core enforces. The second
// argument is what gets core's content into the place: **core offers a
// CONTRIBUTION and never names a slot**, so this module says where each one goes
// and keeps its own word for the place. Core's fills are applied after every
// module chunk has evaluated, so declaring here is early enough; on a core that
// knows nothing of Teams the slot simply stays empty.
registry.declareModuleSlot(ID, 'uo.guild.detail', { core: 'team.activity' })
// A SECOND place on the same page, for core's Team forum (TEAMS.md Part 5). Two
// declarations rather than one, because a slot holds one component and this module
// wants to decide where each of core's two contributions sits on its own page —
// the feed reads as part of the guild's story, the forum is a room you go into.
// Neither knows the other exists, and a core that fills only one leaves the other
// empty.
registry.declareModuleSlot(ID, 'uo.guild.forum', { core: 'team.forum' })
// And a THIRD, at the top of the same page, for core's per-Team notification
// control (TEAMS.md §6.3). Same reasoning as the other two and a different place:
// muting a guild is an action ON this page, so it sits with the page's heading
// rather than after its content. Core resolves whether this viewer is in the
// Team at all — this module neither knows nor asks.
registry.declareModuleSlot(ID, 'uo.guild.header', { core: 'team.notify' })
// `module.json`'s `coreApi` range is checked by the loader before this file is
// ever served, so there is nothing to re-check here. It is logged because a

View File

@@ -0,0 +1,122 @@
import { useParams, Link } from 'react-router-dom'
import api from '../../api.js'
import { ErrorState, Loading, PageHeader, PublicLayout, Slot, useAsync } from '../../core.js'
// One guild: its roster, and the place core puts the Team activity feed.
//
// **This page is the reason the extension-slot direction inverts**
// (docs/website/TEAMS.md Part 3). Teams are a core platform primitive and this
// module is what populates them — but core does not own the word "guild", so it
// publishes no Team page of its own. The page is this module's; the activity feed
// on it is core's, because only core can resolve whether the viewer is inside the
// Team, and the public/members split on that feed is a security boundary.
//
// So the module declares `uo.guild.detail` (entry.jsx) and core fills it. On a
// core that does not know about Teams the slot is simply never filled and this
// page renders its roster alone, which is the same tolerance every other slot has.
//
// The roster comes from this module's OWN board — the same data it answers core's
// Team provider from — rather than from core's Team API. That is deliberate: the
// board is the authoritative copy here, and reading core's projection of our own
// answer back would be a round trip through a staler copy of our own data.
function rankOf(m) {
// Absent rank means NOT KNOWN, never rank 0. The bridge omits it entirely for
// staff, because ServUO reports GameMaster-and-above as Leader whatever their
// real rank — emitting that verbatim would publish every staff member in a
// guild as one of its leaders (docs/link/v4.md).
if (m.rankName) return m.rankName
return null
}
function MemberRow({ m }) {
const rank = rankOf(m)
const linked = m.webId != null || m.acct != null
return (
<tr style={{ borderTop: '1px solid var(--line)' }}>
<td style={{ padding: '9px 10px', color: 'var(--head)' }}>
{m.name || 'Unknown'}
{m.rank === 4 && (
<span className="sans" style={{ color: 'var(--accent)', marginLeft: 8, fontSize: '0.72rem' }}>Leader</span>
)}
</td>
<td className="sans dim" style={{ padding: '9px 10px', fontSize: '0.86rem' }}>{rank || '—'}</td>
<td className="sans dim" style={{ padding: '9px 10px', fontSize: '0.86rem' }}>
{linked ? 'Linked' : '—'}
</td>
</tr>
)
}
export default function Guild() {
const { id } = useParams()
const { loading, error, data } = useAsync(() => api.shard.guild(id), [id])
const roster = (data && data.roster) || []
return (
<PublicLayout section="website">
<div className="shell-narrow page-body">
<p style={{ marginBottom: 14 }}>
<Link to="/uo/guilds"> All guilds</Link>
</p>
{loading && <Loading />}
{error && <ErrorState message="Could not load this guild right now." />}
{!loading && !error && data && (
<>
<PageHeader
eyebrow={data.abbr ? `[${data.abbr}]` : 'Guild'}
title={data.name || 'A guild'}
/>
<p className="sans dim" style={{ fontSize: '0.88rem' }}>
{data.members ?? roster.length} members
{data.online != null && ` · ${data.online} online`}
{data.alliance && ` · ${data.alliance}`}
</p>
{/* A third place for core, up here rather than below the roster: core
puts this guild's notification control in it, and a control that
acts on the page belongs beside the page's title and not after its
content. Empty for a visitor with no membership, and on a core
that fills nothing. */}
<Slot name="uo.guild.header" externalId={String(id)} moduleId="uo" />
{roster.length > 0 && (
<div style={{ overflowX: 'auto', marginTop: 18 }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr className="sans dim" style={{ textAlign: 'left', fontSize: '0.72rem', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
<th style={{ padding: '8px 10px' }}>Name</th>
<th style={{ padding: '8px 10px' }}>Rank</th>
<th style={{ padding: '8px 10px' }}>Account</th>
</tr>
</thead>
<tbody>
{/* Keyed by serial: two characters can share a display name,
which this shard's own world actually contains. */}
{roster.map((m) => <MemberRow key={m.serial} m={m} />)}
</tbody>
</table>
</div>
)}
{roster.length === 0 && (
<p className="sans dim" style={{ marginTop: 18 }}>No roster has been received for this guild yet.</p>
)}
{/* Core's Team activity feed lands here. Nothing renders on a core
that does not fill it, or when there is nothing to show. The guild
is named in OUR terms — core maps its own Team from these two. */}
<Slot name="uo.guild.detail" externalId={String(id)} moduleId="uo" />
{/* And the Team forum, in its own place below the feed. Core resolves
who may read it — membership and manual grants are core's rules —
so this module renders the room and never its door policy. */}
<Slot name="uo.guild.forum" externalId={String(id)} moduleId="uo" />
</>
)}
</div>
</PublicLayout>
)
}

View File

@@ -1,4 +1,5 @@
import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { useShardFeed } from '../../lib/useShardFeed.js'
import api from '../../api.js'
import { ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js'
@@ -15,9 +16,12 @@ function Leader({ leader }) {
function GuildRow({ g }) {
return (
<div
// A link now, because the board gained a detail page: the roster and core's
// Team activity feed live there (docs/website/TEAMS.md Part 3).
<Link
to={`/uo/guilds/${encodeURIComponent(g.id)}`}
className="panel"
style={{ padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 14 }}
style={{ padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 14, textDecoration: 'none' }}
>
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8, minWidth: 0 }}>
@@ -59,7 +63,7 @@ function GuildRow({ g }) {
<Leader leader={g.leader} />
</div>
</div>
</div>
</Link>
)
}

View File

@@ -39,11 +39,18 @@ const CHUNK = path.resolve(HERE, '..', 'dist', 'entry.js')
// nothing here renders, so a named stub is enough to be imported and passed on.
const stub = (name) => Object.assign(() => null, { displayName: name })
// Core's contribution catalogue, as of MODULE_API 1.6.0. Written down rather than
// imported — this suite runs against the BUILT chunk with no core in the process
// — which means it is a claim about core that has to be re-read when core's list
// changes. That is the same trade the rest of this fake makes.
const CORE_CONTRIBUTIONS = ['team.activity', 'team.forum', 'team.notify']
function fakeRg() {
const routes = { public: [], admin: [], player: [] }
const nav = { public: [], admin: [], player: [] }
const providers = new Map()
const extensions = new Map()
const declaredSlots = new Map()
return {
version: '1.3.0',
react,
@@ -54,7 +61,7 @@ function fakeRg() {
// object, so the check compares against whatever is here.
reactDom: { createRoot: () => { throw new Error('not in a browser') } },
ui: Object.fromEntries(
['PublicLayout', 'PageHeader', 'Loading', 'ErrorState', 'EmptyState', 'useAsync', 'useAuth', 'useSite']
['PublicLayout', 'PageHeader', 'Loading', 'ErrorState', 'EmptyState', 'useAsync', 'useAuth', 'useSite', 'Slot']
.map((n) => [n, stub(n)]),
),
api: { request: async () => ({}), ApiError: Error, BASE: '/api/v1' },
@@ -72,10 +79,24 @@ function fakeRg() {
if (extensions.has(slot)) throw new Error(`slot "${slot}" already filled`)
extensions.set(slot, { id, Component })
},
// The INVERTED direction (core API 1.6.0): this module declares a place on
// its OWN page and core fills it. Core enforces the namespace and the
// contribution name, so the fake does too — a chunk that declared an
// unnamespaced slot, or asked for a contribution core does not offer, would
// pass here and throw in a browser.
declareModuleSlot(id, name, options = {}) {
if (!name.startsWith(`${id}.`)) throw new Error(`declareModuleSlot: "${name}" must be namespaced "${id}."`)
if (declaredSlots.has(name)) throw new Error(`extension slot "${name}" already declared`)
const wants = options.core ?? null
if (wants !== null && !CORE_CONTRIBUTIONS.includes(wants)) {
throw new Error(`declareModuleSlot: "${name}" asks for core contribution "${wants}", which core does not offer`)
}
declaredSlots.set(name, wants)
},
routesFor: (area) => routes[area],
navFor: (area) => nav[area],
},
_read: () => ({ routes, nav, providers, extensions }),
_read: () => ({ routes, nav, providers, extensions, declaredSlots }),
}
}
@@ -98,7 +119,7 @@ const it = (name, fn) => test(name, { skip: skip && 'no dist/entry.js — run np
it('registers routes in all three areas, namespaced under the module id', () => {
const { routes } = registered
assert.equal(routes.public.length, 12)
assert.equal(routes.public.length, 13)
assert.equal(routes.admin.length, 7)
assert.equal(routes.player.length, 2)
for (const area of ['public', 'admin', 'player']) {
@@ -166,7 +187,7 @@ it('a nav row that gates on a feature is gated by a namespace this module provid
assert.ok(registered.providers.has('uo'), 'rows carry feature gates but no provider was registered')
})
it('fills the three extension slots, each with a component', () => {
it('fills the three CORE extension slots, each with a component', () => {
const { extensions } = registered
assert.deepEqual(
[...extensions.keys()].sort(),
@@ -198,3 +219,34 @@ it('registers under exactly one module id, matching the manifest', () => {
])
assert.deepEqual([...owners], [manifest.id])
})
it('declares its own guild slots, each naming the core contribution it wants', () => {
// The inverted direction (TEAMS.md Part 3). Teams are a core primitive with no
// core page: core owns the activity feed and the forum, this module owns the
// word "guild", so this module declares the places and core puts them in.
//
// THREE slots rather than one because a slot holds one component: stacking the
// feed, the forum and the notification control into a single fill would take
// away this module's ability to place them separately on its own page — and it
// does place them separately, the control above the roster and the other two
// below it.
//
// The second argument is what actually gets core's content here. **Core offers
// a contribution and never names a slot** — the first cut of this reached only
// this module, because core filled the literal name `uo.guild.detail` and any
// other game's page went empty with no error.
assert.deepEqual([...registered.declaredSlots.entries()], [
['uo.guild.detail', 'team.activity'],
['uo.guild.forum', 'team.forum'],
['uo.guild.header', 'team.notify'],
])
})
it('every declared slot is rendered by the page that owns it', () => {
// A slot nothing renders is a slot core fills into the void. Asserted against
// the source rather than the chunk, since the chunk is minified.
const page = fs.readFileSync(path.resolve(HERE, '..', 'src', 'routes', 'public', 'Guild.jsx'), 'utf8')
for (const name of registered.declaredSlots.keys()) {
assert.match(page, new RegExp(`name="${name.replace(/\./g, '\.')}"`))
}
})

View File

@@ -1,8 +1,8 @@
{
"id": "uo",
"name": "Ultima Online",
"version": "0.3.0",
"coreApi": "^1.3.0",
"version": "0.6.0",
"coreApi": "^1.10.0",
"server": "server/index.js",
"client": { "entry": "client/dist/entry.js" },
"schema": "server/db/schema.sql",

View File

@@ -201,6 +201,11 @@
"path": "/api/v1/public/shard/guilds",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/public/shard/guilds/:id",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/public/shard/houses",

View File

@@ -0,0 +1,64 @@
// Custom node:test reporter that emits SonarQube's Generic Test Execution XML.
//
// Node's built-in reporters give us coverage (`lcov`) and pass/fail output
// (`spec`/`tap`/`junit`), but SonarQube's "Unit Tests" measure is fed by a
// SEPARATE report in *its own* format via `sonar.testExecutionReportPaths` — the
// lcov report only populates Coverage, which is why the dashboard shows coverage
// while the Unit Tests tile stays "-". This reporter produces that missing report.
//
// Format: https://docs.sonarsource.com/sonarqube/latest/analyzing-source-code/test-coverage/generic-test-data/
// <testExecutions version="1">
// <file path="server/test/foo.test.js">
// <testCase name="..." duration="12"/> <!-- duration = integer ms -->
// </file>
// </testExecutions>
//
// Paths are emitted repo-root-relative (POSIX separators) so they match the
// `sonar.tests` roots; the workflow runs `node --test` from the repo root, so the
// absolute `file` on each event strips cleanly against process.cwd().
import path from 'node:path'
function xmlEscape(s) {
return String(s).replace(/[<>&"']/g, (c) => ({
'<': '&lt;',
'>': '&gt;',
'&': '&amp;',
'"': '&quot;',
"'": '&apos;',
})[c])
}
export default async function* sonarTestReporter(source) {
const byFile = new Map()
const cwd = process.cwd()
for await (const event of source) {
if (event.type !== 'test:pass' && event.type !== 'test:fail') continue
const d = event.data
// Skip the container events (a `describe` suite) and anything without a file
// — only real test cases go in the report, so the count matches the runner's.
if (!d.file || (d.details && d.details.type === 'suite')) continue
const rel = path.relative(cwd, d.file).split(path.sep).join('/')
if (!byFile.has(rel)) byFile.set(rel, [])
byFile.get(rel).push({
name: d.name,
duration: Math.max(0, Math.round(d.details?.duration_ms ?? 0)),
failed: event.type === 'test:fail',
skipped: Boolean(d.skip || d.todo),
})
}
yield '<?xml version="1.0" encoding="UTF-8"?>\n<testExecutions version="1">\n'
for (const [file, cases] of byFile) {
yield ` <file path="${xmlEscape(file)}">\n`
for (const c of cases) {
const attrs = `name="${xmlEscape(c.name)}" duration="${c.duration}"`
if (c.failed) yield ` <testCase ${attrs}><failure message="test failed"/></testCase>\n`
else if (c.skipped) yield ` <testCase ${attrs}><skipped/></testCase>\n`
else yield ` <testCase ${attrs}/>\n`
}
yield ' </file>\n'
}
yield '</testExecutions>\n'
}

View File

@@ -0,0 +1,201 @@
// ── `/guild` — the first chat command through the module contract ──────────
//
// Registered with `api.registerSlashCommands` (MODULE_API 1.6.0, TEAMS.md §7.1).
// The definition and this handler live here; the bot pulls the definition over
// the app's internal API and runs nothing of ours. Nothing in this file knows
// what Discord is — it is handed an `actor` and returns an envelope, and the
// same handler would serve a second platform unchanged.
//
// **Why `/guild` and not `/team`.** Teams are core's primitive and "guild" is
// this module's word for one; core does not own the word, so it does not publish
// the noun in a channel either. That is the same correction that deleted core's
// Team pages in phase 3, applied to the chat surface.
//
// **The audience rungs are enforced here, exactly as they are on the website.**
// A shard whose `guilds` feature is gated to staff does not become public
// because the question arrived over Discord — this handler resolves the caller's
// rung through the same `shardVisibility` config the routes use. It is the one
// piece of this file that is a security boundary rather than presentation.
const core = require('../core')
const db = require('../model/teamProvider/teamProvider.db')
const provider = require('../model/teamProvider/teamProvider.model')
const visibility = require('../utils/shardVisibility')
const log = core.logger('guild-command')
// How many guilds the no-argument form lists. A Discord embed takes 25 fields;
// ten is a summary a person reads rather than a table they scroll past.
const LIST_LIMIT = 10
/**
* Where the caller sits on this module's ladder.
*
* The same resolution `projectRoster` does, and it is duplicated in shape rather
* than shared because the inputs differ: that one is handed a viewer core
* described, this one an actor. Both end at `viewerLevel`, and both answer
* `anonymous` DIRECTLY for a caller with no site account — handing `viewerLevel`
* a synthetic empty request makes it fall through to `auth.getUserFromRequest`,
* which expects real cookies and throws (the phase 3 bug).
*/
async function levelFor(actor) {
if (!actor || !actor.userId) return 'anonymous'
return visibility.viewerLevel({ user: { id: actor.userId, role: actor.role } })
}
// The nudge §9 answer 5 asks for, and only when it is TRUE.
//
// **Linking reaches exactly two rungs and no further.** Signing in gets a caller
// to `logged_in` and linking a game account to `player`; `staff` and `admin` are
// roles an operator grants and no amount of linking will earn. So a shard that
// gates guilds to staff refuses an unlinked caller WITHOUT the invitation —
// telling them to link would be telling them to do something that changes
// nothing, which is worse than saying no.
//
// The live walk found this: gated to `staff`, the refusal still read "this shard
// shows guild information to linked players".
const LINKING_REACHES = new Set(['logged_in', 'player'])
function linkPrompt(actor, audience) {
if (actor.isLinked) return null
if (!LINKING_REACHES.has(audience)) return null
return 'Link your account on the site to see more — this shard shows guild information to linked players.'
}
const pageUrl = (externalId) =>
`${core.baseUrl}${provider.pageUrlTemplate.replace('{externalId}', externalId)}`
// Match on abbreviation first, then an exact name, then a unique prefix. Players
// type the abbreviation — it is what appears over a character's head — and a
// wrong-guild answer is worse than "say which one".
function findByName(rows, wanted) {
const needle = wanted.trim().toLowerCase()
const byAbbr = rows.filter((r) => (r.abbr || '').toLowerCase() === needle)
if (byAbbr.length === 1) return { guild: byAbbr[0] }
const exact = rows.filter((r) => r.name.toLowerCase() === needle)
if (exact.length === 1) return { guild: exact[0] }
const partial = rows.filter((r) => r.name.toLowerCase().includes(needle))
if (partial.length === 1) return { guild: partial[0] }
if (partial.length > 1) return { ambiguous: partial.slice(0, LIST_LIMIT) }
return {}
}
/** The counts for one guild, from the roster rather than the board's assertions. */
async function summarise(guild) {
const members = await db.listGuildMembers(guild.id)
const leaders = members
.filter((m) => Number(m.rank) >= db.LEADER_RANK)
.map((m) => m.name)
// The board's founder-leader is folded in as a floor, the same way
// getTeamLeaders does it: it arrives on a different frame, and a shard whose
// roster predates the rank amendment has no other leadership signal.
if (guild.leader_name && !leaders.includes(guild.leader_name)) leaders.push(guild.leader_name)
return {
// `members`/`online` are the BOARD's counts, which is what the shard asserts;
// the roster is what it enumerated, and the two legitimately disagree for the
// moment between a membership change and the sweep that reports it. The
// assertion is the more current of the two, so it is what is shown.
members: guild.members,
online: guild.online,
linked: members.filter((m) => provider.resolveUserId(m) !== null).length,
leaders,
}
}
async function detail(guild, actor, audience) {
const counts = await summarise(guild)
const fields = [
{ name: 'Members', value: String(counts.members ?? '—'), inline: true },
{ name: 'Online', value: String(counts.online ?? 0), inline: true },
{ name: 'Linked accounts', value: String(counts.linked), inline: true },
]
if (counts.leaders.length) {
fields.push({ name: 'Leaders', value: counts.leaders.join(', ') })
}
return {
title: guild.abbr ? `${guild.name} [${guild.abbr}]` : guild.name,
text: guild.alliance ? `Alliance: ${guild.alliance}` : undefined,
fields,
url: pageUrl(guild.id),
notice: linkPrompt(actor, audience),
}
}
/**
* `/guild [name]` — one guild's summary, or the shard's largest guilds.
*
* Never throws for an ordinary miss: "no such guild" and "the shard is offline"
* are answers, and letting either become an exception would turn a routine
* question into "that command failed" with nothing an operator could act on.
*/
async function handler({ options, actor }) {
const config = await visibility.getConfig()
const feature = config.guilds
// An admin turned guilds off. The switch means "this shard does not publish
// guild data" — over any surface, to anyone, staff included.
if (!feature || !feature.enabled) {
return { text: 'This shard does not publish guild information.', ephemeral: true }
}
const level = await levelFor(actor)
if (!visibility.meets(level, feature.audience)) {
return {
text: 'Guild information on this shard is not shown to your account.',
ephemeral: true,
notice: linkPrompt(actor, feature.audience),
}
}
// The provider's own staleness guard, asked before any board read: an
// unreachable sidecar means the board is a snapshot of unknown age, and
// reporting it as current here would contradict what every other surface says.
const ready = await provider.boardIsCurrent()
if (!ready.ok) {
log.info('guild command answered offline', { reason: ready.reason })
return { text: 'The shard is not connected right now, so guild information may be out of date.', ephemeral: true }
}
const rows = await db.listGuilds()
if (!rows.length) return { text: 'No guilds are on the board yet.', ephemeral: true }
const wanted = options && typeof options.name === 'string' ? options.name : null
if (!wanted) {
const top = [...rows].sort((a, b) => (b.members || 0) - (a.members || 0)).slice(0, LIST_LIMIT)
return {
// Not "Guilds on <host>": `ctx.site` carries a base URL and no brand name,
// so naming the deployment here can only mean printing its hostname into
// an embed title, which is noise on a shard's own Discord server.
title: 'Guilds on this shard',
fields: top.map((g) => ({
name: g.abbr ? `${g.name} [${g.abbr}]` : g.name,
value: `${g.members || 0} members · ${g.online || 0} online`,
inline: true,
})),
notice: linkPrompt(actor, feature.audience),
}
}
const { guild, ambiguous } = findByName(rows, wanted)
if (ambiguous) {
return {
text: `Several guilds match “${wanted}”: ${ambiguous.map((g) => g.name).join(', ')}`,
ephemeral: true,
}
}
if (!guild) return { text: `No guild matches “${wanted}”.`, ephemeral: true }
return detail(guild, actor, feature.audience)
}
module.exports = {
name: 'guild',
description: 'Show a guild on this shard — members, who is online, and its leaders',
options: [
{ name: 'name', type: 'string', description: 'Guild name or abbreviation', required: false },
],
// Everyone, deliberately. The gate that matters is the shard's own audience
// rung, resolved inside the handler — `access: 'linked'` would hide the command
// from exactly the unlinked members §9 answer 5 wants to invite to link.
access: 'everyone',
handler,
}

View File

@@ -0,0 +1,46 @@
// ── The module's own client paths, in one place ────────────────────────────
//
// Every link a notification puts in front of a player is a path into this
// module's SPA routes, and Phase 11b's live walk found that not one of them was
// right: the declared examples all read `/shard/…` (module.json's `mounts`), the
// bodies hard-coded a mixture of `/shard/…` and `/player/uo/…`, and the mapper
// populated none of the URL variables at all — so every in-universe letter shipped
// with an empty href and every template preview showed a dead one.
//
// **The prefix is the module ID, not the mount.** `registry.registerRoutes`
// prefixes a module's client routes with `<id>/` and nothing else
// (`client/src/modules/registry.js`), which is why `module.json`'s `mounts` is not
// the answer — that field says what the module CLAIMS, and the router says where
// it landed. `client/src/entry.jsx`'s own `registerNav` is the check: the hrefs it
// gives the sidebar are these, and if the two ever disagree the sidebar is right.
//
// Kept server-side and shared by BOTH the trigger declarations (their `example`s,
// which the template editor previews and test-sends with) and the seeded bodies,
// so a route that moves is one edit rather than thirty.
const ID = 'uo'
const PATHS = {
shard: `/${ID}/shard`,
champs: `/${ID}/champs`,
guilds: `/${ID}/guilds`,
governors: `/${ID}/governors`,
houses: `/${ID}/houses`,
atlas: `/${ID}/atlas`,
leaderboards: `/${ID}/leaderboards`,
market: `/${ID}/market`,
// Self-service and staff areas sit under core's own wrappers, so they carry
// core's prefix as well as the module's.
characters: `/player/${ID}/characters`,
ops: `/admin/${ID}/ops`,
}
/** One guild's roster, when the frame names a guild; the list otherwise. */
const guildPath = (guildId) =>
(guildId === undefined || guildId === null ? PATHS.guilds : `${PATHS.guilds}/${guildId}`)
/** One vendor's page, when the frame names one; the market otherwise. */
const vendorPath = (serial) =>
(serial ? `${PATHS.market}/vendors/${serial}` : PATHS.market)
module.exports = { PATHS, guildPath, vendorPath }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,99 @@
// ── module-uo's registered audiences ───────────────────────────────────────
//
// ENGAGEMENT.md §5.1a, and this module's first three. An audience is a NAMED SET
// OF PEOPLE an operator can point a rule at, or compose into a saved segment with
// and/or/not — "the members of guild 1042", "the governors", "everyone who has
// linked a game account".
//
// **This is a different mechanism from the `members` audience the guild triggers
// use, and the difference is worth stating because the words are the same.** A
// guild event is about the members of THAT guild, which is a different answer for
// every firing; a segment's parameters are CONSTANTS, so it cannot express it,
// and the access-checked set travels on the envelope as `recipientUserIds`
// instead (Phase 6, decision 2). What is here answers the same question every
// time it is asked, which is exactly what makes it composable and storable.
//
// **Four rules, all of them from §5.1a:**
//
// 1. **Core learns no game vocabulary.** It knows an id, a label, a parameter
// list and a `resolve` it may call. It has never heard of a guild.
// 2. **The resolver returns user ids and NOTHING else.** It is not handed a
// template, a channel or an address and cannot enumerate them. A module still
// cannot send mail, and this must not become the door that lets it — core
// maps ids to addresses on its own side, after preferences, suppression and
// the verification gate.
// 3. **Composition narrows, never widens.** The `ceiling` below is the widest
// this audience can EVER resolve to; a segment takes the narrowest ceiling it
// contains, and the result is still checked against the trigger's own.
// 4. **An uninstalled module's audience goes dormant**, resolving empty, rather
// than erroring or silently reaching a different set of people.
//
// All three ceiling at `members`, and none higher. `members` is the lattice value
// for "a module-declared list", and it is the honest one here: these sets are not
// "everyone signed in" narrowed down, they are lists this module happens to know.
//
// Every resolver is bounded by `shardLinks.MAX_AUDIENCE` through the queries it
// calls, and every one of them fails to the EMPTY set rather than throwing — a
// dormant audience is a rule that reaches nobody, which is §5.1a rule 4's
// behaviour and much better than a rule that 500s the engine.
const shardLinks = require('../model/shardLinks/shardLinks.model')
const shardState = require('../model/shardState/shardState.model')
const core = require('../core')
const log = core.logger('shard-audiences')
// One wrapper, so every resolver has the same failure behaviour and none of them
// has to remember it. A resolver that throws would fail the whole enqueue for
// every other audience in the same segment.
const safely = (id, fn) => async (params) => {
try {
return await fn(params || {})
} catch (err) {
log.warn('audience resolve failed — treating as empty', { audience: id, message: err.message })
return []
}
}
const AUDIENCES = [
{
// `namespaced()` requires the module's own prefix, so these are declared with
// it rather than relying on core to add one. Audiences have their own id
// space — an audience names a set of PEOPLE and a trigger names an EVENT — so
// `uo.guild.members` here does not collide with any trigger id.
id: 'uo.guild.members',
label: 'Members of a guild',
description: 'Everyone with a linked game account on one guild\'s roster.',
params: [{ id: 'guildId', type: 'int', required: true }],
ceiling: 'members',
resolve: safely('uo.guild.members', async ({ guildId }) => {
if (guildId == null) return []
const accounts = await shardState.listGuildMemberAccounts(guildId)
return shardLinks.userIdsForAccounts(accounts)
}),
},
{
id: 'uo.governors',
label: 'Town governors',
description: 'Everyone with a linked game account currently holding a city governorship.',
params: [],
ceiling: 'members',
resolve: safely('uo.governors', async () => {
const accounts = await shardState.listGovernorAccounts()
return shardLinks.userIdsForAccounts(accounts)
}),
},
{
id: 'uo.linked.accounts',
label: 'Players with a linked game account',
// The set an operator reaches for first, and — more usefully — the one a
// `not` composes against: "everyone who has NOT linked" is the audience for
// the message that asks them to.
description: 'Every website user who has linked at least one game account.',
params: [],
ceiling: 'members',
resolve: safely('uo.linked.accounts', () => shardLinks.allLinkedUserIds()),
},
]
module.exports = { AUDIENCES }

View File

@@ -0,0 +1,871 @@
// ── module-uo's engagement triggers ────────────────────────────────────────
//
// ENGAGEMENT.md §8.6 and Phase 11. The twin of `config/shardStreams.js`: that
// file declares which shard events a player may get a content-free PUSH tickle
// for, and this one declares the PAYLOAD CONTRACT behind an event — what a rule
// may fire on, what a template may interpolate, and the widest audience an
// operator may ever give it.
//
// **One namespace, two facets** (§7.2, the org lead's Phase 2 decision). A
// trigger id and a stream id live in the same space and an id has exactly one
// owner across both, so the seven grandfathered stream ids in `shardStreams.js`
// (`idoc.warning`, `house.idoc`, …) are ALSO this module's for trigger purposes.
// Nothing below reuses one: the trigger ids here are the `uo.*`-prefixed names
// §8.6 specifies, and they are new. A trigger-only id gets email and in-app
// preferences and no push toggle, which is correct — `allStreams()` serves the
// stream facet only, so the shipped Android client's catalog is unchanged.
//
// **Every ✅ row of §8.6 is here except four, and each carve-out is recorded**
// in ENGAGEMENT.md §8.6 with its reason rather than being silently absent:
//
// • `uo.market.item_listed` — a saved SEARCH, not a trigger. Its audience is
// "users whose stored query matches this listing" and no per-user query store
// exists anywhere in the tree.
// • `uo.guild.joined` — core's `team.member.joined` already fires for it. A UO
// guild IS a Team and this module is the Team provider, so `teamSync` emits
// on every roster reconcile; a second trigger would be two mails for one join.
// `uo.guild.left` and `uo.guild.disbanded` DO ship — core has neither.
// • `uo.link.requested` — no addressable recipient by construction (the account
// is not yet linked, which is the point of the event) and a ~5-minute TTL no
// channel can beat.
// • `uo.points.rank_changed`'s personal half — `points.board`'s `top[]` names a
// mobile SERIAL and `shard_account_links` is keyed by ACCOUNT. The board-change
// feed ships at `subscribers`; "you were pushed out" does not.
//
// **Three rules every declaration below obeys, all of them enforced at
// registration** (`registries.js`), so a mistake here is a boot failure rather
// than a defect discovered in someone's mailbox:
//
// 1. **`ceiling` is required and there is no default.** It is the widest
// audience a rule may ever be given (G24), re-checked at save AND at send.
// `uo.cheat.detected` is why the lattice exists: `owner` would mail the
// cheat report to the player who was detected, and `staff` is the answer.
// 2. **Every variable carries an `example`.** It is what the template editor
// previews and test-sends with; without one, testing a template needs a live
// game event, which is how template systems ship untested (§4.3 property 3).
// 3. **A `url` variable is site-RELATIVE** and validated as such. A payload
// value ends up in an href in an email, and `//evil.test/x` passes an "is it
// rooted" check while being protocol-relative.
//
// **Nothing here emits.** `utils/shardEngagement.js` is the mapper that turns a
// wire frame into a call; this file is only the contract. Keeping them apart is
// what lets the declarations be read as a catalogue and diffed against §8.6.
// Every trigger's `version`. Bumped per declaration when a variable's MEANING
// changes, not when one is added — an added optional is what `required: false`
// is for, and a stored rule keeps working across it.
const V1 = 1
// ── The presentational fragments (Phase 11b, decision 8) ────────────────────────
//
// Sixteen of these triggers render through an IN-UNIVERSE body — a letter from
// the Office of Deeds, a herald's notice, a dispatch from Lord Blackthorn's
// court. A letter is a sentence, and a template has no conditionals by design
// (`interpolate.js`), so an unset optional interpolates to the EMPTY STRING and
// leaves a hole mid-clause: "The house , in , stands in peril."
//
// The fix is Phase 5a's `forWhom` precedent, not a template language: the
// ternary stays in `utils/shardEngagement.js` and its RESULT arrives here as a
// declared optional. Two shapes, and each `example` shows which it is —
//
// • a LABEL always has a value, so it can carry a sentence's spine;
// • a TRAILING FRAGMENT may be empty and leads with its OWN SPACE, so the
// sentence closes cleanly without it (`{{slainBy}}.` → "has fallen.").
//
// They are `required: false` and therefore additive: adding one is not a
// version bump (§4.3 — that is what `required: false` is for), and a rule or a
// template written before them keeps working unchanged.
// ── Owned asset at risk — the flagship family ──────────────────────────────
//
// All three resolve through the frame's `ownerAcct` → `shard_account_links` →
// a website user, which is what `ownerUserId` on the envelope carries. A house
// or vendor whose owner never linked an account is nobody to notify, and the
// mapper drops it rather than treating it as an error.
const OWNED_ASSET = [
{
id: 'uo.house.idoc_warning',
label: 'Your house is decaying',
description: 'One of your houses reached a late decay stage and will collapse if it is not refreshed.',
kind: 'event',
// The house, not the owner. A player with three decaying houses should hear
// about all three; a cooldown keyed on them would report one and swallow the
// rest. This is the case that makes `subjectKey` worth having at all.
subjectKey: 'houseSerial',
audience: 'owner',
ceiling: 'owner',
version: V1,
variables: [
{ name: 'houseSerial', type: 'string', required: true, example: '0x400142F9',
description: 'The house, as the shard names it. Also the cooldown subject.' },
{ name: 'houseName', type: 'string', required: false, example: 'Millrace',
description: 'The house sign\'s name, when it has one.' },
{ name: 'stage', type: 'string', required: true, example: 'Greatly',
description: 'The decay stage it just entered: Slightly, Somewhat, Fairly, Greatly or IDOC.' },
{ name: 'previousStage', type: 'string', required: false, example: 'Fairly',
description: 'The stage it was in before.' },
{ name: 'region', type: 'string', required: false, example: 'Britain',
description: 'The named region the house stands in.' },
{ name: 'location', type: 'string', required: false, example: 'Felucca 1480, 1600',
description: 'Facet and coordinates, already formatted for reading.' },
// **Protocol 5, and both are `required: false` on purpose.** A shard still
// running a v4 overlay emits no `schedule` at all, and a dynamic-decay shard
// omits `estimatedCollapse` at every stage before IDOC because ServUO draws
// each stage's duration at random when the stage is entered. So the mail has
// to read correctly without them — which is exactly what an optional
// variable and a template that omits an absent one give you.
{ name: 'nextStage', type: 'datetime', required: false, example: '2026-09-01T20:33:15Z',
description: 'When it leaves this stage. Absent under static decay, which keeps no stage clock.' },
{ name: 'estimatedCollapse', type: 'datetime', required: false, example: '2026-09-06T20:33:15Z',
description: 'When it collapses — present ONLY when the shard can state it exactly. Absent is "not knowable", never "not yet read".' },
{ name: 'lastRefreshed', type: 'datetime', required: false, example: '2026-08-25T17:21:14Z',
description: 'When the house was last refreshed.' },
{ name: 'houseUrl', type: 'url', required: false, example: '/uo/houses',
description: 'Site-relative path to the IDOC page.' },
{ name: 'houseLabel', type: 'string', required: false, example: '“The Silver Anvil”, in Britain',
description: 'A label: the house\'s name in quotes with its region, or its seal number when it has no name.' },
{ name: 'stageLabel', type: 'string', required: false, example: 'greatly worn',
description: 'The decay stage as words rather than as the wire\'s enum.' },
{ name: 'whereLine', type: 'string', required: false, example: 'Recorded at: Felucca 1480, 1600. Stage entered: Greatly.',
description: 'A whole detail line, assembled from the parts the frame actually carried. Absent when it carried none.' },
],
},
{
id: 'uo.house.collapsed',
label: 'Your house collapsed',
description: 'One of your houses fell — the bad news, so that it is not a surprise.',
kind: 'event',
subjectKey: 'houseSerial',
audience: 'owner',
ceiling: 'owner',
version: V1,
variables: [
{ name: 'houseSerial', type: 'string', required: true, example: '0x400142F9',
description: 'The house, as the shard names it. Also the cooldown subject.' },
{ name: 'houseName', type: 'string', required: false, example: 'Millrace',
description: 'The house sign\'s name, when it had one.' },
{ name: 'region', type: 'string', required: false, example: 'Britain',
description: 'The named region it stood in.' },
{ name: 'location', type: 'string', required: false, example: 'Felucca 1480, 1600',
description: 'Facet and coordinates, already formatted for reading.' },
{ name: 'houseLabel', type: 'string', required: false, example: '“The Silver Anvil”, in Britain',
description: 'A label: the house\'s name in quotes with its region, or its seal number when it had no name.' },
{ name: 'whereLine', type: 'string', required: false, example: 'Last recorded at: Felucca 1480, 1600.',
description: 'A whole detail line, assembled from the parts the frame actually carried.' },
],
},
{
// **The good outcome, and it exists because a delay without a cancel is just
// a late mail** (ENGAGEMENT.md §4.2a). `uo.house.idoc_warning` ships
// `delay_seconds: 900` so an owner who repairs the house inside the window is
// never told it is in peril — and until Phase 11b's live walk there was
// nothing that could cancel it: the mapper returned early on every transition
// that was not a late stage, so a refresh reached the engine as silence. The
// wire already carried the transition; only this declaration was missing.
//
// It is a real notification as well as a cancel signal (decision 11), so it
// carries the labels a body needs rather than the serial alone.
id: 'uo.house.refreshed',
label: 'Your house was refreshed',
description: 'One of your houses was refreshed and is out of danger. Cancels a pending decay warning.',
kind: 'event',
// The SAME subject as the warning it cancels, and that is load-bearing rather
// than tidy: `outboxDb.cancel` matches on (rule, subject_key), so a refresh
// whose subject were anything else would cancel nothing.
subjectKey: 'houseSerial',
audience: 'owner',
ceiling: 'owner',
version: V1,
variables: [
{ name: 'houseSerial', type: 'string', required: true, example: '0x400142F9',
description: 'The house, as the shard names it. Also the cooldown subject, and what the cancellation matches on.' },
{ name: 'houseName', type: 'string', required: false, example: 'Millrace',
description: 'The house sign\'s name, when it has one.' },
{ name: 'previousStage', type: 'string', required: false, example: 'Greatly',
description: 'The decay stage it was in before it was refreshed.' },
{ name: 'region', type: 'string', required: false, example: 'Britain',
description: 'The named region the house stands in.' },
{ name: 'location', type: 'string', required: false, example: 'Felucca 1480, 1600',
description: 'Facet and coordinates, already formatted for reading.' },
{ name: 'houseUrl', type: 'url', required: false, example: '/uo/houses',
description: 'Site-relative path to the housing page.' },
{ name: 'houseLabel', type: 'string', required: false, example: '“The Silver Anvil”, in Britain',
description: 'A label: the house\'s name in quotes with its region, or its seal number when it has no name.' },
{ name: 'fromLine', type: 'string', required: false, example: ' It stood greatly worn.',
description: 'A trailing fragment naming the stage it was rescued from. Leads with its own space, and is empty when the frame carried no previous stage.' },
],
},
{
id: 'uo.vendor.expiring',
label: 'Your vendor is about to be dismissed',
description: 'One of your player vendors is running out of gold for its fees and will be dismissed.',
kind: 'event',
subjectKey: 'vendorSerial',
audience: 'owner',
ceiling: 'owner',
version: V1,
variables: [
{ name: 'vendorSerial', type: 'string', required: true, example: '0x40001234',
description: 'The vendor, as the shard names it. Also the cooldown subject.' },
{ name: 'shopName', type: 'string', required: false, example: 'Darrow\'s Bargains',
description: 'The shop\'s name.' },
{ name: 'dismissalAt', type: 'datetime', required: true, example: '2026-09-08T21:01:21Z',
description: 'When the vendor is destroyed if nothing is deposited. Exact — unlike a house\'s collapse, there is no randomness in it.' },
// **The int an operator narrows with**, because `conditions.js` compares a
// declared variable against a LITERAL and has no relative-time operator:
// "within 24 hours of dismissal" is not expressible as `dismissalAt < now +
// 24h`. So the hours are computed at emit and the operator writes
// `hoursRemaining is at most 24`. The mapper additionally fires only on a
// threshold CROSSING, because `vendor.listing` is a sweep frame re-emitted
// on any price change.
{ name: 'hoursRemaining', type: 'int', required: true, example: 22,
description: 'Whole hours until dismissal at the moment this fired. The value to write a rule condition against.' },
{ name: 'periodsRemaining', type: 'int', required: false, example: 1,
description: 'Pay ticks the vendor survives. NOT days — under the old vendor system a period is one UO day (~2 real hours).' },
{ name: 'funds', type: 'int', required: false, example: 8204,
description: 'Gold available to pay the fees.' },
{ name: 'chargePerPeriod', type: 'int', required: false, example: 10548,
description: 'What each tick deducts.' },
{ name: 'location', type: 'string', required: false, example: 'Trammel 1421, 1699 (Britain)',
description: 'Where the shop stands, already formatted for reading.' },
{ name: 'marketUrl', type: 'url', required: false, example: '/uo/market',
description: 'Site-relative path to the market page.' },
{ name: 'shopLabel', type: 'string', required: false, example: 'thy shop “The Silver Anvil”',
description: 'A label: the shop named, or simply \'thy vendor\' when it has no name.' },
{ name: 'ledgerLine', type: 'string', required: false, example: 'On hand: 1200 gold. Charged each period: 400 gold. Periods remaining: 3.',
description: 'The whole ledger line, assembled from the fee fields the frame carried. A pre-v5 overlay carries none, and then there is no line.' },
],
},
]
// ── Passive income ─────────────────────────────────────────────────────────
const PASSIVE_INCOME = [
{
id: 'uo.vendor.sale',
label: 'Your vendor sold something',
// **The tier caveat belongs in the operator-facing text, not only in a
// comment.** `vendor.sale` is emitted by a `PlayerVendorSale` EventSink that
// lives in `servuo-plugins/patches/` — the opt-in patch tier — and is verified
// only against ServUO 57.4. A shard that declined the tier emits this kind
// never, so a rule on it is silently dormant rather than broken, and the only
// way an operator finds out is if something says so where they are looking.
description:
'One of your player vendors made a sale. Requires the optional ServUO patch tier — a shard that '
+ 'declined it never emits this event, and a rule on it stays silent.',
kind: 'event',
subjectKey: 'vendorSerial',
audience: 'owner',
ceiling: 'owner',
version: V1,
variables: [
{ name: 'vendorSerial', type: 'string', required: true, example: '0x2E1',
description: 'The vendor that made the sale. Also the cooldown subject.' },
{ name: 'itemName', type: 'string', required: true, example: 'Longsword',
description: 'What was sold.' },
{ name: 'amount', type: 'int', required: false, example: 1,
description: 'How many.' },
{ name: 'price', type: 'int', required: true, example: 100,
description: 'What it sold for, in gold.' },
{ name: 'commission', type: 'int', required: false, example: 0,
description: 'Commission taken, on a commission vendor.' },
{ name: 'shopLabel', type: 'string', required: false, example: 'thy shop “The Silver Anvil”',
description: 'A label: the shop named, or simply \'thy vendor\' when it has no name.' },
{ name: 'itemLine', type: 'string', required: false, example: '3 × Iron Ingot',
description: 'A label: the item with its count when more than one was sold, the item alone otherwise.' },
{ name: 'ledgerLine', type: 'string', required: false, example: 'Commission withheld: 5 gold.',
description: 'The whole ledger line, or absent when the sale carried no commission.' },
],
},
]
// ── Personal security ──────────────────────────────────────────────────────
const PERSONAL_SECURITY = [
{
id: 'uo.account.login_failed',
label: 'A failed login to your game account',
description: 'Someone tried to log into your game account and was refused.',
kind: 'event',
// The account, so a burst of attempts against one account is one mail and
// attempts against two accounts are two.
subjectKey: 'account',
audience: 'owner',
ceiling: 'owner',
version: V1,
variables: [
{ name: 'account', type: 'string', required: true, example: 'seed_000',
description: 'The game account that was tried. Also the cooldown subject.' },
{ name: 'reason', type: 'string', required: false, example: 'BadPass',
description: 'The shard\'s refusal reason: BadPass, Invalid, Blocked, InUse or BadComm.' },
{ name: 'ip', type: 'string', required: false, example: '203.0.113.9',
description: 'Where the attempt came from.' },
],
},
{
id: 'uo.account.unlinked',
label: 'Your game account was unlinked',
description: 'Someone severed the tie between this game account and your website account, from in game.',
kind: 'event',
subjectKey: 'account',
audience: 'owner',
ceiling: 'owner',
version: V1,
variables: [
{ name: 'account', type: 'string', required: true, example: 'seed_000',
description: 'The game account that was unlinked. Also the cooldown subject.' },
{ name: 'characterName', type: 'string', required: false, example: 'Zara Crowe',
description: 'The character who ran the command.' },
],
},
]
// ── Personal milestone ─────────────────────────────────────────────────────
//
// The two death triggers are a killfeed some players want and most do not.
// Every rule ships disabled anyway (Q3), and 11b's seeded rules for these two
// additionally default their channels `off` rather than relying on the rule
// switch alone.
const PERSONAL_MILESTONE = [
{
id: 'uo.skill.capped',
label: 'You capped a skill',
description: 'One of your characters reached the cap in a skill.',
kind: 'event',
subjectKey: 'skill',
audience: 'owner',
ceiling: 'owner',
version: V1,
variables: [
{ name: 'characterName', type: 'string', required: true, example: 'Zara Crowe',
description: 'The character who capped it.' },
{ name: 'skill', type: 'string', required: true, example: 'Blacksmithy',
description: 'The skill. Also the cooldown subject — capping two skills is two events.' },
{ name: 'cap', type: 'float', required: true, example: 100,
description: 'The cap that was reached.' },
],
},
{
id: 'uo.quest.complete',
label: 'You completed a quest',
description: 'One of your characters finished a quest.',
kind: 'event',
subjectKey: 'quest',
audience: 'owner',
ceiling: 'owner',
version: V1,
variables: [
{ name: 'characterName', type: 'string', required: true, example: 'Zara Crowe',
description: 'The character who finished it.' },
{ name: 'quest', type: 'string', required: true, example: 'The Ancient Tome',
description: 'The quest. Also the cooldown subject.' },
],
},
{
id: 'uo.character.death',
label: 'Your character died',
description: 'One of your characters was killed. Opt-in — most players do not want this.',
kind: 'event',
subjectKey: 'characterName',
audience: 'owner',
ceiling: 'owner',
version: V1,
variables: [
{ name: 'characterName', type: 'string', required: true, example: 'Zara Crowe',
description: 'Who died. Also the cooldown subject.' },
{ name: 'killerName', type: 'string', required: false, example: 'an ogre lord',
description: 'What killed them, when the shard names it.' },
{ name: 'slainBy', type: 'string', required: false, example: ' at the hands of a lich lord',
description: 'A trailing fragment, LEADING SPACE included, or empty when the killer is unknown.' },
],
},
{
id: 'uo.character.murdered',
label: 'Your character was murdered',
description: 'One of your characters was killed by another player. Opt-in — most players do not want this.',
kind: 'event',
subjectKey: 'characterName',
audience: 'owner',
ceiling: 'owner',
version: V1,
variables: [
{ name: 'characterName', type: 'string', required: true, example: 'Zara Crowe',
description: 'Who was murdered. Also the cooldown subject.' },
{ name: 'murdererName', type: 'string', required: false, example: 'Darrow',
description: 'Who did it, when the shard names them.' },
{ name: 'slainBy', type: 'string', required: false, example: ' by the hand of Aldric',
description: 'A trailing fragment, LEADING SPACE included, or empty when the murderer is unknown.' },
],
},
]
// ── Social / civic ─────────────────────────────────────────────────────────
//
// The two guild triggers ceiling at `members` and resolve through the recipient
// set the emit carries, not through a saved segment: "the members of THIS guild"
// is a different answer for every firing, which a segment's constant params
// cannot express. That is Phase 6's decision 2, and the Team fan-out is the
// precedent it was built for.
const SOCIAL_CIVIC = [
{
id: 'uo.guild.left',
label: 'A member left your guild',
description: 'Someone left a guild you are in.',
kind: 'event',
subjectKey: 'guildName',
audience: 'members',
ceiling: 'members',
version: V1,
variables: [
{ name: 'guildName', type: 'string', required: true, example: 'The Silver Hand',
description: 'The guild. Also the cooldown subject.' },
// `guild.leave`'s `who` is a bare SERIAL string, not an actor object — the
// mobile has already left, so the shard has nothing to attribute. The name
// comes from this module's own roster mirror (`shard_guild_members`), and
// is optional because a member the sweep never saw has no row there.
{ name: 'memberName', type: 'string', required: false, example: 'Bran',
description: 'Who left, when the roster mirror still knows their name.' },
{ name: 'guildUrl', type: 'url', required: false, example: '/uo/guilds/1042',
description: 'Site-relative path to the guilds page.' },
{ name: 'memberLabel', type: 'string', required: false, example: 'Aldric',
description: 'A label: the departing member\'s name, or \'A member\' when the roster mirror has no name for them.' },
],
},
{
id: 'uo.guild.disbanded',
label: 'Your guild disbanded',
description: 'A guild you are in was disbanded or removed.',
kind: 'event',
subjectKey: 'guildName',
audience: 'members',
ceiling: 'members',
version: V1,
variables: [
{ name: 'guildName', type: 'string', required: true, example: 'The Silver Hand',
description: 'The guild that is gone. Also the cooldown subject.' },
{ name: 'abbreviation', type: 'string', required: false, example: 'TSH',
description: 'Its abbreviation.' },
],
},
{
// **The town's bulletin and the governor's letter are two triggers, not one**
// (ENGAGEMENT.md Phase 11b, decision 10). §8.6 records that
// `uo.points.rank_changed` cannot address a person — `top[]` names a mobile
// serial and links are keyed by account — and the same reasoning was silently
// assumed to cover this one. It does not: `city.update`'s `governor` field is
// written by `BridgeJson.Actor()`, which emits `serial`, `name`, `acct` and
// `webId`. The new governor is addressable today, with no protocol change.
//
// Widening `uo.governor.elected` to two audiences was the tempting answer and
// was refused: one trigger means one rule means ONE template, and the town's
// announcement and the governor's letter are not the same text. Two also lets
// an operator run the announcement and leave the letter off, or the reverse.
id: 'uo.governor.appointed',
label: 'You were named governor',
description: 'You hold the governor\'s seat of a city — the letter to the person who won it.',
kind: 'event',
// The city, not the governor: a player who somehow takes two seats in an hour
// should get two letters, and the seat is what the event is about.
subjectKey: 'city',
audience: 'owner',
ceiling: 'owner',
version: V1,
variables: [
{ name: 'city', type: 'string', required: true, example: 'Britain',
description: 'The city whose seat you now hold. Also the cooldown subject.' },
{ name: 'governorName', type: 'string', required: true, example: 'Darrow',
description: 'Your character\'s name, as the city knows it.' },
{ name: 'previousGovernorName', type: 'string', required: false, example: 'Mireille',
description: 'Who held the seat before, when there was someone.' },
{ name: 'governorsUrl', type: 'url', required: false, example: '/uo/governors',
description: 'Site-relative path to the governors page.' },
{ name: 'inSuccessionTo', type: 'string', required: false, example: ' in succession to Mireille',
description: 'A trailing fragment, LEADING SPACE included. Empty today: the frame names no outgoing governor.' },
],
},
{
id: 'uo.governor.elected',
label: 'A town elected a governor',
description: 'A city has a new governor.',
kind: 'event',
subjectKey: 'city',
audience: 'subscribers',
ceiling: 'authenticated',
version: V1,
variables: [
{ name: 'city', type: 'string', required: true, example: 'Britain',
description: 'The city. Also the cooldown subject.' },
{ name: 'governorName', type: 'string', required: true, example: 'Darrow',
description: 'The new governor.' },
{ name: 'previousGovernorName', type: 'string', required: false, example: 'Mireille',
description: 'Who held the seat before, when there was someone.' },
{ name: 'governorsUrl', type: 'url', required: false, example: '/uo/governors',
description: 'Site-relative path to the governors page.' },
{ name: 'inSuccessionTo', type: 'string', required: false, example: ' in succession to Mireille',
description: 'A trailing fragment, LEADING SPACE included. Empty today: the frame names no outgoing governor.' },
],
},
{
id: 'uo.election.opened',
label: 'Voting opened in a town',
// **The first trigger whose call to action genuinely expires**, which is why
// `autoPickAt` is required rather than decorative: a mail saying "vote" with
// no deadline is a mail nobody acts on, and one delivered after the deadline
// is worse than none. 11b's template says the date, and the seeded rule uses
// no delay for the same reason.
description: 'A city\'s election entered its nomination or voting phase, with a deadline.',
kind: 'event',
subjectKey: 'city',
audience: 'subscribers',
ceiling: 'authenticated',
version: V1,
variables: [
{ name: 'city', type: 'string', required: true, example: 'Britain',
description: 'The city. Also the cooldown subject.' },
{ name: 'phase', type: 'string', required: true, example: 'vote',
description: 'Which phase opened: nominate or vote.' },
{ name: 'autoPickAt', type: 'datetime', required: true, example: '2026-09-04T00:00:00Z',
description: 'When the game decides for itself — the real deadline.' },
// The same instant a person can read. A `datetime` renders as the string the
// payload holds and core has no interpolation filters by design, so a body
// that interpolates the machine value prints an ISO-8601 stamp mid-sentence.
// The machine value STAYS — an operator writes `is at most` conditions
// against it — and the body uses this one.
{ name: 'autoPickWhen', type: 'string', required: false, example: '4 September 2026, 00:00 UTC',
description: 'The deadline as prose, for a body. `autoPickAt` remains the machine value a condition compares.' },
{ name: 'candidates', type: 'int', required: false, example: 3,
description: 'How many candidates stand.' },
{ name: 'governorsUrl', type: 'url', required: false, example: '/uo/governors',
description: 'Site-relative path to the governors page.' },
{ name: 'phaseLabel', type: 'string', required: false, example: 'The ballot is open',
description: 'The phase as a clause rather than as the wire\'s enum.' },
{ name: 'candidateNote', type: 'string', required: false, example: ' 3 candidates stand.',
description: 'A trailing sentence, LEADING SPACE included, or empty when the count is unknown.' },
],
},
]
// ── Come online now ────────────────────────────────────────────────────────
const COME_ONLINE = [
{
id: 'uo.champ.started',
label: 'A champion spawn started',
description: 'A champion spawn became active.',
kind: 'event',
subjectKey: 'spawnSerial',
audience: 'subscribers',
ceiling: 'authenticated',
version: V1,
variables: [
{ name: 'spawnSerial', type: 'string', required: true, example: '0x40012345',
description: 'The spawn controller. Also the cooldown subject.' },
{ name: 'spawnName', type: 'string', required: true, example: 'Abyss',
description: 'What is spawning.' },
{ name: 'category', type: 'string', required: false, example: 'champion',
description: 'champion, mini or sea.' },
{ name: 'location', type: 'string', required: false, example: 'Felucca 5187, 570',
description: 'Where, already formatted for reading.' },
{ name: 'champsUrl', type: 'url', required: false, example: '/uo/champs',
description: 'Site-relative path to the champions page.' },
{ name: 'atPlace', type: 'string', required: false, example: ' at Felucca 1480, 1600 (Destard)',
description: 'A trailing fragment, LEADING SPACE included, or empty when the frame carries no location.' },
],
},
{
id: 'uo.champ.boss_up',
label: 'A champion boss is up',
description: 'A champion spawn reached its boss.',
kind: 'event',
subjectKey: 'spawnSerial',
audience: 'subscribers',
ceiling: 'authenticated',
version: V1,
variables: [
{ name: 'spawnSerial', type: 'string', required: true, example: '0x40012345',
description: 'The spawn controller. Also the cooldown subject.' },
{ name: 'spawnName', type: 'string', required: true, example: 'Abyss',
description: 'The spawn.' },
{ name: 'bossName', type: 'string', required: false, example: 'Semidar',
description: 'The boss, when the shard names it.' },
{ name: 'location', type: 'string', required: false, example: 'Felucca 5187, 570',
description: 'Where, already formatted for reading.' },
{ name: 'champsUrl', type: 'url', required: false, example: '/uo/champs',
description: 'Site-relative path to the champions page.' },
{ name: 'atPlace', type: 'string', required: false, example: ' at Felucca 1480, 1600 (Destard)',
description: 'A trailing fragment, LEADING SPACE included, or empty when the frame carries no location.' },
],
},
{
// Protocol 6, and the reason the kind exists at all. Its first consumer is not
// a mail rule but an EVENT PHASE CONDITION: `{ on: 'uo.champ.boss_killed',
// where: [...], count: 1 }` is how an author says "move to the next phase when
// the boss falls", and a condition is expressed over a trigger firing. That is
// also why it is declared here rather than only ingested — a kind nothing
// declares is a kind no event can wait on.
id: 'uo.champ.boss_killed',
label: 'A champion boss was defeated',
description: 'Players brought down a champion spawn boss.',
kind: 'event',
subjectKey: 'spawnSerial',
audience: 'subscribers',
ceiling: 'authenticated',
version: V1,
variables: [
{ name: 'spawnSerial', type: 'string', required: true, example: '0x40012345',
description: 'The spawn controller, or the boss itself where the shard could not name an altar. Also the cooldown subject.' },
{ name: 'bossName', type: 'string', required: true, example: 'Semidar',
description: 'The boss that fell.' },
{ name: 'category', type: 'string', required: false, example: 'champion',
description: 'champion or sea.' },
{ name: 'location', type: 'string', required: false, example: 'Felucca 5187, 570 (Destard)',
description: 'Where, already formatted for reading.' },
{ name: 'killerName', type: 'string', required: false, example: 'Aldric',
description: 'Who struck the last blow, when the shard names one.' },
{ name: 'damagerCount', type: 'int', required: false, example: 14,
description: 'How many players did damage to it. The names themselves are staff-only and are deliberately not offered here.' },
{ name: 'damagerNote', type: 'string', required: false, example: ' 14 players fought it.',
description: 'A trailing sentence, LEADING SPACE included, or empty when nobody is credited.' },
{ name: 'champsUrl', type: 'url', required: false, example: '/uo/champs',
description: 'Site-relative path to the champions page.' },
{ name: 'atPlace', type: 'string', required: false, example: ' at Felucca 1480, 1600 (Destard)',
description: 'A trailing fragment, LEADING SPACE included, or empty when the frame carries no location.' },
],
},
{
id: 'uo.server.up',
label: 'The shard came online',
description: 'The game server started or came back after an outage.',
kind: 'event',
// **No `subjectKey`, and that is the whole point of this pair.** There is one
// shard, so the subject a cooldown counts is the RECIPIENT — "do not tell me
// the shard bounced more than once an hour". Keying it on a boot id would make
// every restart a new subject and every cooldown a no-op, which is precisely
// the mail loop §8.6 warns a flapping shard produces. 11b's seeded rules carry
// a hard cooldown; this declaration is what makes that cooldown mean anything.
audience: 'subscribers',
ceiling: 'authenticated',
version: V1,
variables: [
{ name: 'shardName', type: 'string', required: false, example: 'UOMysticmoon',
description: 'What the shard calls itself.' },
{ name: 'statusUrl', type: 'url', required: false, example: '/uo/shard',
description: 'Site-relative path to the shard status page.' },
],
},
{
id: 'uo.server.down',
label: 'The shard went offline',
description: 'The game server shut down or crashed.',
kind: 'event',
audience: 'subscribers',
ceiling: 'authenticated',
version: V1,
variables: [
{ name: 'shardName', type: 'string', required: false, example: 'UOMysticmoon',
description: 'What the shard calls itself.' },
{ name: 'clean', type: 'boolean', required: false, example: true,
description: 'Whether it was a clean shutdown rather than a crash.' },
{ name: 'statusUrl', type: 'url', required: false, example: '/uo/shard',
description: 'Site-relative path to the shard status page.' },
],
},
]
// ── Leaderboard ────────────────────────────────────────────────────────────
const LEADERBOARD = [
{
id: 'uo.points.rank_changed',
label: 'A leaderboard top spot changed',
// §8.6 originally described this firing both ways — "you entered a top N" and
// "you were pushed out". The personal half is carved out: `points.board`'s
// `top[]` entries are `{rank, serial, name, points}` and `shard_account_links`
// is keyed by game ACCOUNT, so a serial resolves to a person only for someone
// currently online (`shard_online`) or in a guild (`shard_guild_members`). A
// leaderboard mail that reaches half the board reads as favouritism, so the
// board feed ships and the personal one waits for a serial→account map.
description: 'The top of a leaderboard changed hands.',
kind: 'event',
subjectKey: 'system',
audience: 'subscribers',
ceiling: 'authenticated',
version: V1,
variables: [
{ name: 'system', type: 'string', required: true, example: 'QueensLoyalty',
description: 'The points system. Also the cooldown subject.' },
{ name: 'systemName', type: 'string', required: false, example: 'Queen\'s Loyalty',
description: 'Its display name, when the shard gives one.' },
{ name: 'leaderName', type: 'string', required: true, example: 'Darrow',
description: 'Who is first now.' },
{ name: 'previousLeaderName', type: 'string', required: false, example: 'Mireille',
description: 'Who was first before.' },
{ name: 'points', type: 'int', required: false, example: 29500,
description: 'The new leader\'s points.' },
{ name: 'boardLabel', type: 'string', required: false, example: 'Virtue',
description: 'A label: the board\'s display name, or its system id when it has none.' },
{ name: 'standingLine', type: 'string', required: false, example: 'Darrow now stands first upon it, with 4210 to their name.',
description: 'The whole standing sentence, with the score when the board carried one and without it when it did not.' },
],
},
]
// ── Staff-facing ───────────────────────────────────────────────────────────
//
// These are why the ceiling exists. Phase 3 already filters a role-ceilinged
// trigger out of a player's preferences catalogue AND gates it on write, so this
// family is the production proof of that work rather than new mechanism.
const STAFF_FACING = [
{
id: 'uo.page.new',
label: 'A player opened a help page',
description: 'A player raised a support ticket in game.',
kind: 'event',
subjectKey: 'pageType',
audience: 'staff',
ceiling: 'staff',
version: V1,
variables: [
{ name: 'pageType', type: 'string', required: true, example: 'Stuck',
description: 'Bug, Stuck, Account, Question, Suggestion, Other, VerbalHarassment or PhysicalHarassment. Also the cooldown subject.' },
{ name: 'senderName', type: 'string', required: false, example: 'Zara Crowe',
description: 'Who raised it.' },
{ name: 'message', type: 'string', required: false, example: 'I am stuck under the Britain bank.',
description: 'What they wrote.' },
{ name: 'location', type: 'string', required: false, example: 'Trammel 1421, 1699',
description: 'Where they are, already formatted for reading.' },
{ name: 'pagesUrl', type: 'url', required: false, example: '/admin/uo/ops',
description: 'Site-relative path to the help-page queue.' },
],
},
{
id: 'uo.cheat.detected',
label: 'The cheat detector fired',
description: 'The shard\'s own speed-hack detector flagged a player.',
kind: 'event',
// **`staff`, and never `owner`.** This is the declaration the whole lattice
// was written for: under a flat "fewer people is narrower" ordering a
// `staff` ceiling would also permit `owner`, and the rule an operator would
// then be able to save mails the cheat report to the player who was detected.
subjectKey: 'characterName',
audience: 'staff',
ceiling: 'staff',
version: V1,
variables: [
{ name: 'characterName', type: 'string', required: true, example: 'Zara Crowe',
description: 'Who was flagged. Also the cooldown subject.' },
{ name: 'account', type: 'string', required: false, example: 'seed_000',
description: 'Their game account.' },
{ name: 'ip', type: 'string', required: false, example: '203.0.113.9',
description: 'Where they were connected from.' },
{ name: 'detector', type: 'string', required: false, example: 'fastwalk',
description: 'Which detector fired.' },
],
},
]
// ── Operator-facing ────────────────────────────────────────────────────────
//
// `admin`, the ceiling Phase 11 added to the lattice (decision 1). The narrowest
// value before it was `staff` — admin, editor AND moderator — so ceilinging a
// digest of what moderators did at `staff` would have sent it to the moderators.
// All three are digest-shaped by nature; none should ever be instant, which is a
// property of 11b's seeded rules rather than of these declarations.
const OPERATOR_FACING = [
{
id: 'uo.audit.staff_action',
label: 'A staff member acted in game',
description: 'A staff command, a property change, or a moderation action.',
kind: 'event',
subjectKey: 'staffName',
audience: 'admin',
ceiling: 'admin',
version: V1,
variables: [
{ name: 'staffName', type: 'string', required: false, example: 'Mireille',
description: 'Who acted. Absent when the shard cannot attribute it. Also the cooldown subject.' },
{ name: 'action', type: 'string', required: true, example: 'set',
description: 'What kind of action: set, command, ban, kick, mute…' },
{ name: 'detail', type: 'string', required: false, example: 'Str 100 → 125 on Zara Crowe',
description: 'The action in one line, already formatted for reading.' },
{ name: 'target', type: 'string', required: false, example: 'Zara Crowe',
description: 'Who or what it was applied to.' },
{ name: 'origin', type: 'string', required: false, example: 'in-game',
description: 'web or in-game — where the action was issued from.' },
],
},
{
id: 'uo.economy.milestone',
label: 'The economy crossed a threshold',
description: 'The shard\'s total gold supply or account count crossed one of the module\'s reporting thresholds.',
kind: 'event',
subjectKey: 'metric',
audience: 'admin',
ceiling: 'admin',
version: V1,
variables: [
{ name: 'metric', type: 'string', required: true, example: 'gold',
description: 'gold or accounts. Also the cooldown subject.' },
{ name: 'value', type: 'int', required: true, example: 1000000000,
description: 'The value that crossed.' },
{ name: 'threshold', type: 'int', required: true, example: 1000000000,
description: 'The threshold it crossed.' },
{ name: 'direction', type: 'string', required: true, example: 'up',
description: 'up or down.' },
{ name: 'economyUrl', type: 'url', required: false, example: '/uo/shard',
description: 'Site-relative path to the shard status page.' },
],
},
{
id: 'uo.world.saved',
label: 'The world saved',
description: 'A world save completed, with the item and mobile counts it wrote.',
kind: 'event',
audience: 'admin',
ceiling: 'admin',
version: V1,
variables: [
{ name: 'items', type: 'int', required: false, example: 1482301,
description: 'Items written.' },
{ name: 'mobiles', type: 'int', required: false, example: 41022,
description: 'Mobiles written.' },
],
},
]
const TRIGGERS = [
...OWNED_ASSET,
...PASSIVE_INCOME,
...PERSONAL_SECURITY,
...PERSONAL_MILESTONE,
...SOCIAL_CIVIC,
...COME_ONLINE,
...LEADERBOARD,
...STAFF_FACING,
...OPERATOR_FACING,
]
// The ids, as a Set, for the mapper's own guard: `shardEngagement.js` refuses to
// emit an id this file does not declare, so a typo there is a boot-time-visible
// mistake rather than a dropped event nobody notices.
const TRIGGER_IDS = new Set(TRIGGERS.map((t) => t.id))
module.exports = {
TRIGGERS,
TRIGGER_IDS,
OWNED_ASSET,
PASSIVE_INCOME,
PERSONAL_SECURITY,
PERSONAL_MILESTONE,
SOCIAL_CIVIC,
COME_ONLINE,
LEADERBOARD,
STAFF_FACING,
OPERATOR_FACING,
}

File diff suppressed because it is too large Load Diff

View File

@@ -93,6 +93,28 @@ module.exports = {
},
auth: { getUserFromRequest: (...args) => need().auth.getUserFromRequest(...args) },
push: { publish: (...args) => need().push.publish(...args) },
// The engagement seam (MODULE_API 1.7.0, ENGAGEMENT.md §5.1). `emit` says an
// event this module DECLARED has happened; the engine decides whether anyone is
// told, on which channel, subject to which rule and preference. `inbox.push`
// writes an in-app item with no rule at all, for the cases that are not events.
//
// Both are fire-and-forget and return undefined by contract — a module calls
// them from inside a game-event handler and there is nothing it could correctly
// do with a storage failure of core's. `inbox.push` additionally does not report
// "the user has this switched off", because a module that could see that would
// be a module that could enumerate people's preferences one write at a time.
events: {
emit: (...args) => need().events.emit(...args),
// MODULE_API 1.10.0 (EVENTS.md F, Phase 8). "Ask every action of mine which
// of its ledgered resources the game still has." Core cannot know when to
// ask -- it has no concept of the game being up -- so the module says when,
// and `shardIngest` says it on a changed `bootId`. Fire-and-forget like
// `emit`, and for the same reason: core owns what happens next and there is
// nothing a game-event handler could correctly do with the answer.
reconcile: (...args) => need().events.reconcile(...args),
},
inbox: { push: (...args) => need().inbox.push(...args) },
secretBox: {
encrypt: (...args) => need().secretBox.encrypt(...args),
decrypt: (...args) => need().secretBox.decrypt(...args),

View File

@@ -45,6 +45,7 @@ DROP TABLE IF EXISTS `shard_ruleset`;
DROP TABLE IF EXISTS `shard_presence`;
DROP TABLE IF EXISTS `shard_governor_terms`;
DROP TABLE IF EXISTS `shard_governors`;
DROP TABLE IF EXISTS `shard_guild_members`;
DROP TABLE IF EXISTS `shard_guilds`;
DROP TABLE IF EXISTS `shard_pages`;
DROP TABLE IF EXISTS `shard_champs`;

View File

@@ -47,7 +47,7 @@ CREATE TABLE IF NOT EXISTS uo_link_config (
base_url VARCHAR(255) NULL,
ws_url VARCHAR(255) NULL,
auth_token_enc TEXT NULL,
protocol INT NOT NULL DEFAULT 3,
protocol INT NOT NULL DEFAULT 7,
enabled TINYINT(1) NOT NULL DEFAULT 0,
status VARCHAR(20) NOT NULL DEFAULT 'disconnected',
status_detail VARCHAR(500) NULL,
@@ -220,6 +220,52 @@ CREATE TABLE IF NOT EXISTS shard_guilds (
INDEX idx_shard_guilds_name (name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Guild membership (Protocol 4). One row per member per guild, replaced on
-- guild.roster and thinned by guild.leave. Protocol 2 could only say HOW MANY
-- members a guild had, so this table has no pre-4 equivalent and the Guilds page
-- could show a count but never a roster.
--
-- `acct` / `web_id` are the site-identity fields and are stored because the
-- sidecar forwards them; they are NOT public. shardVisibility locks any key that
-- is or ends in acct/webId to `admin` and recurses into arrays, so a projected
-- roster loses them below that rung — storing them here is what lets a linked
-- member be matched to a site user at all.
--
-- A roster over the shard's per-frame cap arrives in several frames, so rows are
-- keyed on (guild_id, serial) and the frame carrying seq 0 clears the guild first;
-- see upsertGuildRoster.
CREATE TABLE IF NOT EXISTS shard_guild_members (
guild_id INT NOT NULL,
serial VARCHAR(20) NOT NULL, -- in-game mobile serial, "0x1F5"
name VARCHAR(120) NULL,
acct VARCHAR(120) NULL, -- absent for a mobile with no account
web_id INT NULL, -- set only when the account is linked
is_player TINYINT(1) NOT NULL DEFAULT 1,
-- Guild rank, 0-4, with 4 being Leader (ServUO RankDefinition.Ranks). NULL means
-- "not known", which is a real state and not a demotion: the shard omits the rank
-- for a staff account, because PlayerMobile.GuildRank reports Leader for anyone at
-- GameMaster or above whatever their actual rank, and publishing that would put a
-- staff member on a public roster as a guild leader.
-- Backticked, like `int` on shard_online: RANK is a reserved word in MySQL 8 and
-- a non-reserved keyword in MariaDB, so it parses here bare but must not be
-- written that way anywhere it might not.
`rank` TINYINT NULL,
-- The rank's NAME, as the game states it: a cliloc id for the five standard ranks
-- (1062959-1062963, which ship with no text), or a literal string when a shard has
-- replaced the rank table with custom definitions. Resolving one to a label is this
-- module's job -- it owns the cliloc table and the game vocabulary.
rank_cliloc INT NULL,
rank_name VARCHAR(64) NULL,
t BIGINT NULL, -- roster event time, epoch ms
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (guild_id, serial),
INDEX idx_shard_guild_members_acct (acct),
INDEX idx_shard_guild_members_web (web_id),
-- Leadership is "rank >= 4", asked per guild, which is the query the Team provider
-- runs on every reconcile.
INDEX idx_shard_guild_members_rank (guild_id, rank)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Town-governor board (Protocol 2.0, City Loyalty). One row per city, upserted on
-- city.update (full-state, emitted only on change; there is no remove event since
-- the set of cities is fixed). governor / governorElect are actor objects
@@ -439,6 +485,13 @@ CREATE TABLE IF NOT EXISTS shard_spawn_points (
id INT AUTO_INCREMENT PRIMARY KEY,
facet VARCHAR(40) NOT NULL,
name VARCHAR(120) NULL, -- the ServUO spawner's own name
-- `XmlSpawner.UniqueId` (Phase 12b): the only name for one particular spawner
-- that exists OFF the shard. A property lease is targeted by it, because a
-- serial is assigned when the world is built and nothing here could know one --
-- so without this column the lease's target field could have no dropdown at
-- all. NULLable: a shard's own spawners, added in-world rather than from the
-- spawn files, carry none, and they are addressed by serial instead.
unique_id VARCHAR(64) NULL,
x INT NOT NULL,
y INT NOT NULL,
width INT NOT NULL DEFAULT 0,
@@ -454,7 +507,10 @@ CREATE TABLE IF NOT EXISTS shard_spawn_points (
landmark VARCHAR(120) NULL,
label VARCHAR(120) NOT NULL DEFAULT 'Wilderness',
INDEX idx_shard_spawn_points_facet (facet),
INDEX idx_shard_spawn_points_label (label)
INDEX idx_shard_spawn_points_label (label),
-- The spawner target's dropdown searches by name, and 6,707 rows is more than
-- a dropdown holds, so the search is the read rather than a filter over one.
INDEX idx_shard_spawn_points_name (name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- The many-to-many between the two above: one spawner commonly carries several
@@ -498,6 +554,26 @@ CREATE TABLE IF NOT EXISTS shard_landmarks (
INDEX idx_shard_landmarks_name (name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Item types this shard uses as decoration, from Data/Decoration/**/*.cfg.
--
-- Import-owned like every other shard_* atlas table. It exists so the events
-- decoration verb can offer an author a dropdown of what THIS shard already
-- calls scenery, rather than a list of item types curated by us: a shard with
-- custom decoration gets its own, and the list resolves with the shard offline
-- because it came out of the tree at import time.
--
-- `item_id` is a preview, not an identity. A type appears under as many item
-- ids as it has facings or variants (a BarredMetalDoor under eight), and the
-- first one seen is kept; the plugin constructs from the TYPE NAME and picks
-- its own graphic. `uses` is how many times the shard's own decoration reaches
-- for the type, which is the only ordering signal available that means anything.
CREATE TABLE IF NOT EXISTS shard_decor_types (
type VARCHAR(120) NOT NULL PRIMARY KEY,
item_id INT NOT NULL DEFAULT 0,
uses INT NOT NULL DEFAULT 0,
INDEX idx_shard_decor_types_uses (uses)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Configured champion altars from Config/ChampionSpawns.xml. This is static
-- roster data ("there is an Unholy Terror altar in Deceit") and is distinct from
-- the live champ.update feed in shard_champs ("it is on level 3 right now").
@@ -629,6 +705,35 @@ UPDATE uo_link_config SET protocol = 3
-- not cut over yet.
INSERT IGNORE INTO settings (`key`, value) VALUES ('uo_link_protocol_3_migrated', '1');
-- Protocol 4 cutover: the same migration one step later, and the one this module
-- OWED and did not pay.
--
-- The protocol-4 work shipped across three repos — `link`'s PROTOCOL_VERSION, the
-- overlay's `overlay.toml`, and this module's `guild.roster` / `guild.leave` ingest —
-- but the pinned version stayed at 3 on both of its declaration sites here. A fresh
-- install therefore came up speaking 3 to a sidecar speaking 4, and a sidecar answers
-- a stale client with `409 protocol version mismatch` rather than mis-parsing it. The
-- symptom is total: every REST read fails and the WS closes on ws.hello, so a new
-- deployment shows an empty marketplace, an empty guild board and no shard status,
-- with the cause visible only in the server log. Found while standing up a demo
-- deployment for the marketing site's screenshots.
--
-- Same shape as the block above, for the same reasons: MODIFY fixes the column
-- default for databases created before the bump, and the UPDATE is one-shot against
-- its own marker so that an operator who deliberately pins an older sidecar in
-- Admin → Shard stays pinned. `protocol < 4` and not `= 3`, so an install that
-- somehow never took the protocol-3 migration is carried the whole way rather than
-- one step.
ALTER TABLE uo_link_config MODIFY COLUMN protocol INT NOT NULL DEFAULT 4;
UPDATE uo_link_config SET protocol = 4
WHERE id = 1 AND protocol < 4
AND NOT EXISTS (SELECT 1 FROM settings WHERE `key` = 'uo_link_protocol_4_migrated');
-- The marker is written HERE, in this module's fragment, for the reason spelled out
-- above: core's schema is replayed in full BEFORE any module fragment, so a marker
-- left in core would already exist when this UPDATE read it and the one-shot could
-- never fire.
INSERT IGNORE INTO settings (`key`, value) VALUES ('uo_link_protocol_4_migrated', '1');
-- ── Settings rows this module owns ─────────────────────────────────────────
--
-- Both keys predate the module system and both name a game concept, so core
@@ -641,4 +746,94 @@ INSERT IGNORE INTO settings (`key`, value) VALUES ('uo_link_protocol_3_migrated'
-- only a database that has never seen the key gets the default. Nothing in core
-- reads either one; `game_account_signup` is read through ctx.settings by
-- server/utils/gameSignup.js, which owns the policy.
INSERT IGNORE INTO settings (`key`, value) VALUES ('game_account_signup', 'disabled');
INSERT IGNORE INTO settings (`key`, value) VALUES ('game_account_signup', 'disabled');
-- Protocol 4 guild rank, added to databases that already have shard_guild_members.
--
-- The table itself is new in Protocol 4 and unreleased, so no production install has
-- it — but `edge` deployments do, from the roster work that landed before the rank
-- amendment, and CREATE TABLE IF NOT EXISTS adds a table and never a column. This is
-- the same gap the sidecar's own store hit when `guilds.members` was added.
ALTER TABLE shard_guild_members ADD COLUMN IF NOT EXISTS `rank` TINYINT NULL;
ALTER TABLE shard_guild_members ADD COLUMN IF NOT EXISTS rank_cliloc INT NULL;
ALTER TABLE shard_guild_members ADD COLUMN IF NOT EXISTS rank_name VARCHAR(64) NULL;
ALTER TABLE shard_guild_members ADD INDEX IF NOT EXISTS idx_shard_guild_members_rank (guild_id, `rank`);
-- ── Protocol 5 ───────────────────────────────────────────────────────────────
--
-- Three wire enrichments, bumped together (link/sidecar/src/main.rs, overlay.toml).
-- Two of them land as columns here; the third is a new event kind and needs none.
--
-- 1. house.decay's decay SCHEDULE. `shard_houses` could say what stage a house was
-- at and when it was last refreshed, but nothing about WHEN the next thing
-- happens — which is the only part a player can act on. `estimated_collapse` is
-- nullable and stays null far more often than not, deliberately: under dynamic
-- decay (Core.ML) ServUO draws each stage's duration at random when the stage is
-- entered, so collapse is exactly knowable only once the house is already at
-- IDOC. A null here means "not knowable", never "not yet read".
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS next_stage DATETIME NULL;
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS estimated_collapse DATETIME NULL;
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS decay_period_sec INT NULL;
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS dynamic_decay TINYINT(1) NULL;
-- 2. vendor.listing's owner account and fee state.
--
-- `owner_acct` is the one that matters structurally: the table has carried
-- `owner_name` since Protocol 3, but a character name is not an identity — only
-- the game ACCOUNT joins to shard_account_links, so until now a vendor row named
-- an owner the site could not resolve to a user.
--
-- The fee columns describe PlayerVendor.PayTimer's dismissal rule: at each tick
-- the charge is compared with the funds and the vendor is destroyed when the
-- charge wins. `dismissal_at` is that comparison resolved into an instant, which
-- is what any surface actually wants; the parts are kept alongside it so a
-- display can explain the number rather than only state it.
--
-- `fees_exempt` marks a commission vendor: it has no pay timer at all and is
-- never dismissed for fees, which is a different thing from having a long time
-- left and must not render as one.
ALTER TABLE shard_vendors ADD COLUMN IF NOT EXISTS owner_acct VARCHAR(120) NULL;
ALTER TABLE shard_vendors ADD COLUMN IF NOT EXISTS fees_exempt TINYINT(1) NOT NULL DEFAULT 0;
ALTER TABLE shard_vendors ADD COLUMN IF NOT EXISTS charge_per_period INT NULL;
ALTER TABLE shard_vendors ADD COLUMN IF NOT EXISTS funds INT NULL;
ALTER TABLE shard_vendors ADD COLUMN IF NOT EXISTS pay_interval_sec INT NULL;
ALTER TABLE shard_vendors ADD COLUMN IF NOT EXISTS next_pay_at DATETIME NULL;
ALTER TABLE shard_vendors ADD COLUMN IF NOT EXISTS periods_remaining INT NULL;
ALTER TABLE shard_vendors ADD COLUMN IF NOT EXISTS dismissal_at DATETIME NULL;
-- Both of these exist for the same reader: the Phase 11 trigger that has to find
-- "vendors about to be dismissed" without scanning every shop, and the owner join
-- that turns one into a person.
ALTER TABLE shard_vendors ADD INDEX IF NOT EXISTS idx_shard_vendors_dismissal (dismissal_at);
ALTER TABLE shard_vendors ADD INDEX IF NOT EXISTS idx_shard_vendors_owner_acct (owner_acct);
-- 3. The protocol pin, one step on from the Protocol 4 block above and for exactly
-- the reasons it spells out. `protocol < 5` rather than `= 4`, so an install that
-- missed an earlier migration is carried the whole way; the one-shot marker is
-- written here in the module's own fragment, because core's schema is replayed in
-- full BEFORE any module fragment and a marker left in core would already exist
-- when this UPDATE read it.
ALTER TABLE uo_link_config MODIFY COLUMN protocol INT NOT NULL DEFAULT 5;
UPDATE uo_link_config SET protocol = 5
WHERE id = 1 AND protocol < 5
AND NOT EXISTS (SELECT 1 FROM settings WHERE `key` = 'uo_link_protocol_5_migrated');
INSERT IGNORE INTO settings (`key`, value) VALUES ('uo_link_protocol_5_migrated', '1');
-- 4. The protocol pin again, at 7 -- and this block is a FIX to already-merged
-- code rather than ordinary Phase 12b work.
--
-- Phase 11a took the wire to 6 and Phase 12a took it to 7, and neither moved
-- this. `uoLinkClient` sends `X-UOLink-Version: <this column>` on every call and
-- the sidecar answers an exact mismatch with a 409, so a deployment that installed
-- this module at any point since Phase 10 would have had EVERY sidecar call
-- refused against a protocol-7 sidecar -- the whole event plane dead, loudly but
-- for a reason nobody would look here for.
--
-- It survived two phases because both live walks set the column by hand while
-- standing the rig up, which is exactly the shape of a migration nobody runs.
-- One block carries an install the whole way rather than one per missed version:
-- `protocol < 7` is deliberate, and it is why the 4 and 5 blocks above wrote
-- `< n` rather than `= n-1`.
ALTER TABLE uo_link_config MODIFY COLUMN protocol INT NOT NULL DEFAULT 7;
UPDATE uo_link_config SET protocol = 7
WHERE id = 1 AND protocol < 7
AND NOT EXISTS (SELECT 1 FROM settings WHERE `key` = 'uo_link_protocol_7_migrated');
INSERT IGNORE INTO settings (`key`, value) VALUES ('uo_link_protocol_7_migrated', '1');

View File

@@ -44,7 +44,13 @@ module.exports = function register(ctx, api) {
const usersShardExtension = require('./router/admin/usersShard.router')
const shardStreams = require('./config/shardStreams')
const shardTriggers = require('./config/shardTriggers')
const shardAudiences = require('./config/shardAudiences')
const engagementSeeds = require('./config/engagementSeeds')
const uoEventActions = require('./config/uoEventActions')
const townCrierLeg = require('./utils/shardAnnounce')
const teamProvider = require('./model/teamProvider/teamProvider.model')
const guildCommand = require('./commands/guild.command')
const boot = require('./boot')
/* eslint-enable global-require */
@@ -86,6 +92,95 @@ module.exports = function register(ctx, api) {
api.registerNotificationStreams(shardStreams.STREAMS)
api.registerAnnounceLeg(townCrierLeg.leg)
// The engagement contract (MODULE_API 1.7.0, ENGAGEMENT.md Phase 11). Triggers
// are PAYLOAD contracts: what a rule may fire on, what a template may
// interpolate, and — the part that is a security boundary — the widest audience
// an operator may ever give each one. `uo.cheat.detected` ceilings at `staff`
// and the three operator-facing ones at `admin` (added to the lattice in 1.8.0),
// and core refuses a rule that widens either.
//
// **Triggers and notification streams share ONE id namespace** (§7.2), so this
// registration and the one above are two facets of one space and core enforces
// that an id has exactly one owner across both. None of the ids below reuses a
// stream id: the stream catalog keeps its seven grandfathered names and these
// are the `uo.*`-prefixed ones §8.6 specifies. A trigger-only id gets email and
// in-app preferences and no push toggle, which is correct — there is nothing to
// push it to, and the shipped Android client's catalog is unchanged.
api.registerEventTriggers(shardTriggers.TRIGGERS)
// Audiences are named sets of PEOPLE an operator composes rules and segments
// out of (§5.1a). Their own id space, and their own ceiling arithmetic: a
// composition takes the narrowest ceiling it contains, never the widest.
//
// Registration is a claim; nothing resolves until the engine asks, which is
// after `onBoot` — and it must be, because every resolver reads the database
// and registration must not (§2.2 rule 1).
api.registerAudiences(shardAudiences.AUDIENCES)
// What this module SHIPS behind those two (MODULE_API 1.9.0, ENGAGEMENT.md
// Phase 11b): sixteen in-universe message bodies on two channels each, and
// twenty-five rules — every one of them `enabled = 0`, which the registry
// enforces rather than trusts.
//
// **A catalogue an operator turns on, not a switch that fires on upgrade.**
// Nothing here mails anybody: a rule that is off produces nothing, and a rule
// that is on still passes the ceiling, the per-user preference, the suppression
// list and the verification gate before anything is sent — all of them core's.
//
// The nine security and operational triggers point at core's generic bodies
// (decision 9). A cheat report should read like a cheat report.
//
// ONE rule group, and the choice is deliberate: a group is seeded once, so a
// twenty-sixth rule appended to `triggers-v1` in a later version would reach
// fresh installs ONLY. A future trigger wants its own group key.
api.registerEngagementSeeds({
templates: engagementSeeds.TEMPLATES,
ruleGroups: engagementSeeds.RULE_GROUPS,
})
// Teams: a UO guild is a Team, and this module is the authoritative source of
// them for this deployment (MODULE_API 1.6.0). Core asks the three questions;
// everything about what a guild IS stays here.
//
// Registration is a claim, not a call — nothing below runs until core
// reconciles, which is after `onBoot`. That matters because every method reads
// the database, and registration must not.
api.registerTeamProvider(teamProvider)
// `/guild` — the chat surface for the same guilds (MODULE_API 1.6.0, TEAMS.md
// §7.1). The definition travels to the bot; the handler stays here and runs in
// the website process, because the bot container has no `modules` volume and
// cannot load a line of this module's code.
//
// Core registers NO commands of its own. "Guild" is this module's word — core
// does not own it on a page (phase 3) and does not publish it in a channel
// either.
api.registerSlashCommands([guildCommand])
// The event contract (MODULE_API 1.10.0, EVENTS.md F, EVENTS_PLAN.md Phase 9).
// Three verbs an event author can put in a step, the one budget dimension that
// bounds a broadcast, and the three option sources the spawn atlas answers.
//
// **All of it is optional, by the contract's own posture.** A deployment
// without this module still has an event engine that can announce, wait, cue a
// human and publish results; what these add is the ability for an event to
// reach the GAME. Nothing here is a precondition for anything of core's.
//
// The wave is deliberately the verbs that need no protocol change: the write
// plane they use has existed since protocol 2.1 and the admin screens have
// driven it by hand for months. The world verbs -- creatures, gates, leases --
// wait for Phase 11 to put an idempotency key and a lease deadline on the wire,
// because a world write core cannot prove ran exactly once is not one this
// module is willing to make unattended.
api.registerEventBudgets(uoEventActions.BUDGETS)
api.registerEventActions(uoEventActions.ACTIONS)
// Phase 11b. One live-read config key, and the module never writes it: an author
// puts `core.lease` in a step and core owns the duration bound, the
// two-events-one-target check and the teardown restore.
api.registerEventLeases(uoEventActions.LEASES)
api.registerEventOptionSources(uoEventActions.OPTION_SOURCES)
api.onBoot(boot.onBoot)
api.onShutdown(boot.onShutdown)
@@ -93,5 +188,8 @@ module.exports = function register(ctx, api) {
version: require('../module.json').version,
routes: 'public:/shard,/atlas admin:/shard,/uo-link player:/shard',
streams: shardStreams.STREAMS.length,
triggers: shardTriggers.TRIGGERS.length,
audiences: shardAudiences.AUDIENCES.length,
eventActions: uoEventActions.ACTIONS.length,
})
}

View File

@@ -16,6 +16,7 @@ const ATLAS_TABLES = [
'shard_regions',
'shard_landmarks',
'shard_champion_spawns',
'shard_decor_types',
]
async function insertBatched(conn, sql, rows) {
@@ -103,6 +104,16 @@ async function replaceAtlas(atlas, art = {}) {
]),
)
// Optional: a tree with no Data/Decoration leaves this empty rather than
// failing the import, and the decoration verb then simply has nothing to
// offer. `?? []` rather than a guard, so an atlas built by an older parser
// (no `decor` key at all) reloads cleanly instead of throwing here.
counts.decor = await insertBatched(
conn,
'INSERT INTO shard_decor_types (type, item_id, uses) VALUES (?,?,?)',
(atlas.decor ?? []).map((d) => [d.type, d.itemId ?? 0, d.uses ?? 0]),
)
// Point ids are assigned explicitly rather than left to AUTO_INCREMENT: the
// join rows need to know them and `conn.batch()` reports no usable insertId
// for a multi-row insert. Safe because this transaction just emptied the
@@ -110,13 +121,14 @@ async function replaceAtlas(atlas, art = {}) {
counts.points = await insertBatched(
conn,
'INSERT INTO shard_spawn_points ' +
'(id, facet, name, x, y, width, height, spawn_range, max_count, min_delay, max_delay, ' +
'(id, facet, name, unique_id, x, y, width, height, spawn_range, max_count, min_delay, max_delay, ' +
'tod_start, tod_end, tod_mode, region, landmark, label) ' +
'VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
'VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
atlas.points.map((p, i) => [
i + 1,
p.facet,
p.name,
p.uniqueId || null,
p.x,
p.y,
p.width ?? 0,
@@ -357,6 +369,73 @@ function listLandmarks({ facet = '', q = '' } = {}) {
)
}
/**
* Every decoration type this shard uses, most-used first.
*
* Ordered by `uses` because a dropdown of 313 types needs the ones the shard
* actually reaches for at the top; the alphabetical tiebreak keeps the order
* stable across imports, which matters for a form an author scrolls.
*/
function listDecorTypes({ q = '' } = {}) {
const where = []
const params = []
if (q) {
where.push('type LIKE ?')
params.push(`%${q}%`)
}
return query(
`SELECT type, item_id, uses
FROM shard_decor_types
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
ORDER BY uses DESC, type ASC`,
params,
)
}
/**
* Spawners an author can name, searched by name and bounded (Phase 12b).
*
* **A search rather than a list, and the numbers are why.** This tree has 6,707
* spawn points against a 2,000-entry dropdown bound, so a flat read would drop
* two thirds of the world and say nothing about which two thirds — the failure
* Phase 12a named for decoration, arriving for real. `resolveOptionSource` grew
* a `q` for this.
*
* Only rows with a `unique_id` are offered: that is the only name for a spawner
* that exists off the shard, and a row without one cannot be targeted from a
* form however it is labelled. A shard's own in-world spawners have none and are
* addressed by serial, which an author types rather than picks.
*
* Ordered by `max_count DESC` so the spawners worth an event's attention come
* first, with a stable alphabetical tiebreak for a form somebody scrolls.
*/
function listSpawners({ q = '', limit = 200 } = {}) {
const where = ['unique_id IS NOT NULL', "unique_id <> ''"]
const params = []
if (q) {
where.push('(name LIKE ? OR region LIKE ? OR landmark LIKE ?)')
params.push(`%${q}%`, `%${q}%`, `%${q}%`)
}
params.push(Number(limit) || 200)
return query(
`SELECT unique_id, name, facet, region, landmark, max_count
FROM shard_spawn_points
WHERE ${where.join(' AND ')}
ORDER BY max_count DESC, name ASC
LIMIT ?`,
params,
)
}
/** One decoration type, or nothing when this shard's files never name it. */
async function getDecorType(type) {
const rows = await query(
'SELECT type, item_id, uses FROM shard_decor_types WHERE type = ?',
[type],
)
return rows[0] || null
}
function listChampions({ facet = '' } = {}) {
const params = []
let where = ''
@@ -388,5 +467,8 @@ module.exports = {
listCreatureCompanions,
listRegions,
listLandmarks,
listDecorTypes,
listSpawners,
getDecorType,
listChampions,
}

View File

@@ -403,6 +403,59 @@ async function getCreature(slug, { facet = '', points = 200 } = {}) {
}
}
/**
* Decoration types, shaped for a dropdown.
*
* `type` is both the value and the label: it is the ServUO class name and it is
* what the plugin constructs from, so showing the author anything else would
* put a name on the screen that does not appear in the refusal if the shard
* declines it.
*/
async function listDecorTypes(opts = {}) {
const rows = await db.listDecorTypes(opts)
return rows.map((r) => ({
type: r.type,
itemId: Number(r.item_id) || 0,
uses: Number(r.uses) || 0,
}))
}
/**
* Spawners an author can name, searched (Phase 12b).
*
* The value is the `UniqueId` because that is what the shard resolves a target
* by; the label is the spawner's own name, which is what an author recognises
* ("fel bulbous putrification" is a place they know). A row with no name still
* answers, labelled by its id, rather than being dropped: a nameless spawner is
* still a spawner somebody may need to turn down.
*/
async function listSpawners(opts = {}) {
const rows = await db.listSpawners(opts)
return rows.map((r) => ({
uniqueId: r.unique_id,
name: r.name || null,
facet: r.facet,
region: r.region || null,
landmark: r.landmark || null,
maxCount: Number(r.max_count) || 0,
}))
}
/**
* One decoration type, or null.
*
* The events decoration verb resolves through this rather than passing a type
* name straight through, which does two things at once: it fetches the item id
* the graphic-holder classes need, and it keeps the verb to the vocabulary this
* shard's own decoration files use. A type the atlas has never seen is refused
* here rather than constructed there.
*/
async function getDecorType(type) {
const row = await db.getDecorType(String(type == null ? '' : type).trim())
if (!row) return null
return { type: row.type, itemId: Number(row.item_id) || 0, uses: Number(row.uses) || 0 }
}
async function listRegions(opts = {}) {
const rows = await db.listRegions(opts)
return rows.map((r) => ({
@@ -485,6 +538,9 @@ module.exports = {
getCreature,
listRegions,
listLandmarks,
listDecorTypes,
listSpawners,
getDecorType,
listChampions,
listFacets,
publicMeta,

View File

@@ -39,4 +39,56 @@ const remove = (account, userId) =>
const removeByAccount = (account) =>
query('DELETE FROM shard_account_links WHERE account = ?', [account])
module.exports = { upsert, getByAccount, listByUser, isOwnedBy, remove, removeByAccount }
// A bound on every "resolve a set of people" read below. It mirrors core's own
// `MAX_AUDIENCE` (engagementRecipients.db.js) rather than importing it: a module
// cannot reach into core's models, and the number this file has to respect is
// "no more ids than core will accept" whatever core calls it.
const MAX_AUDIENCE = 5000
// **Website user ids for a set of game accounts.** The bulk form of
// `getByAccount`, and the one the engagement mapper needs: a guild event's
// audience is its members, and turning a roster into a set of people is one join
// rather than one query per member (Phase 11).
//
// DISTINCT because two characters on one guild roster can share an account, and
// the caller wants people rather than characters.
async function userIdsForAccounts(accounts) {
const wanted = [...new Set((accounts || []).filter((a) => typeof a === 'string' && a))]
if (!wanted.length) return []
const capped = wanted.slice(0, MAX_AUDIENCE)
const marks = capped.map(() => '?').join(', ')
const rows = await query(
`SELECT DISTINCT user_id FROM shard_account_links WHERE account IN (${marks})`,
capped,
)
return rows.map((r) => Number(r.user_id)).filter((n) => Number.isInteger(n) && n > 0)
}
// **Every website user with a linked game account** — the `uo.linked.accounts`
// audience (ENGAGEMENT.md §5.1a). The set an operator reaches for first, and the
// one a `not` composes against ("everyone who has NOT linked").
//
// It returns ids and nothing else: §5.1a rule 2 is that a module's resolver
// never sees an address, a channel or a template, and core maps ids to addresses
// on its own side after preferences, suppression and the verification gate.
async function allLinkedUserIds(limit = MAX_AUDIENCE) {
const rows = await query(
'SELECT DISTINCT user_id FROM shard_account_links ORDER BY user_id LIMIT ?',
[limit],
)
return rows.map((r) => Number(r.user_id)).filter((n) => Number.isInteger(n) && n > 0)
}
module.exports = {
upsert,
getByAccount,
listByUser,
isOwnedBy,
remove,
removeByAccount,
userIdsForAccounts,
allLinkedUserIds,
MAX_AUDIENCE,
}

View File

@@ -34,4 +34,20 @@ const unlink = (account, userId) => db.remove(account, userId)
// Drop the local mirror for an account (source-of-truth severed elsewhere).
const removeByAccount = (account) => db.removeByAccount(account)
module.exports = { link, listForUser, ownsAccount, getByAccount, unlink, removeByAccount }
// The bulk resolvers the engagement audiences and the guild mapper need
// (Phase 11). Thin pass-throughs, like `ownsAccount` above: there is no logic to
// put here, and a module's audience resolver returning ids and nothing else is
// the contract (§5.1a rule 2).
const userIdsForAccounts = (accounts) => db.userIdsForAccounts(accounts)
const allLinkedUserIds = (limit) => db.allLinkedUserIds(limit)
module.exports = {
link,
listForUser,
ownsAccount,
getByAccount,
unlink,
removeByAccount,
userIdsForAccounts,
allLinkedUserIds,
}

View File

@@ -41,14 +41,25 @@ async function replaceVendor(vendor, items) {
await conn.query(
`INSERT INTO shard_vendors
(serial, shop_name, owner_serial, owner_name, map, x, y, z, region, house,
item_count, item_total, truncated, t)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
(serial, shop_name, owner_serial, owner_name, owner_acct, map, x, y, z, region, house,
item_count, item_total, truncated, t,
fees_exempt, charge_per_period, funds, pay_interval_sec, next_pay_at,
periods_remaining, dismissal_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON DUPLICATE KEY UPDATE shop_name = VALUES(shop_name), owner_serial = VALUES(owner_serial),
owner_name = VALUES(owner_name), map = VALUES(map), x = VALUES(x), y = VALUES(y),
owner_name = VALUES(owner_name), owner_acct = VALUES(owner_acct),
map = VALUES(map), x = VALUES(x), y = VALUES(y),
z = VALUES(z), region = VALUES(region), house = VALUES(house),
item_count = VALUES(item_count), item_total = VALUES(item_total),
truncated = VALUES(truncated), t = VALUES(t),
-- Protocol 5. Written back unconditionally, INCLUDING when they are null:
-- a shard downgraded to a pre-v5 overlay stops sending the fees object, and
-- leaving the last v5 values in place would leave a dismissal date standing
-- that nothing is maintaining any more. A stale deadline is worse than none.
fees_exempt = VALUES(fees_exempt), charge_per_period = VALUES(charge_per_period),
funds = VALUES(funds), pay_interval_sec = VALUES(pay_interval_sec),
next_pay_at = VALUES(next_pay_at), periods_remaining = VALUES(periods_remaining),
dismissal_at = VALUES(dismissal_at),
-- Touched explicitly rather than left to ON UPDATE CURRENT_TIMESTAMP:
-- MariaDB does not fire that when every column is written back
-- unchanged, and a shop that is re-published identically is still
@@ -60,6 +71,7 @@ async function replaceVendor(vendor, items) {
vendor.shopName ?? null,
vendor.ownerSerial ?? null,
vendor.ownerName ?? null,
vendor.ownerAcct ?? null,
vendor.map ?? null,
Number.isFinite(vendor.x) ? vendor.x : null,
Number.isFinite(vendor.y) ? vendor.y : null,
@@ -70,6 +82,13 @@ async function replaceVendor(vendor, items) {
Number.isFinite(vendor.itemTotal) ? vendor.itemTotal : items.length,
vendor.truncated ? 1 : 0,
Number.isFinite(vendor.t) ? vendor.t : null,
vendor.feesExempt ? 1 : 0,
Number.isFinite(vendor.chargePerPeriod) ? vendor.chargePerPeriod : null,
Number.isFinite(vendor.funds) ? vendor.funds : null,
Number.isFinite(vendor.payIntervalSec) ? vendor.payIntervalSec : null,
vendor.nextPayAt ?? null,
Number.isFinite(vendor.periodsRemaining) ? vendor.periodsRemaining : null,
vendor.dismissalAt ?? null,
],
)

View File

@@ -31,6 +31,7 @@ const MAX_OWNER = 64
const MAX_MAP = 40
const MAX_REGION = 80
const MAX_SERIAL = 20
const MAX_ACCT = 120
const clip = (value, max) => {
if (value == null) return null
@@ -43,6 +44,42 @@ const int = (value, fallback = 0) => {
return Number.isFinite(n) ? Math.trunc(n) : fallback
}
// A wire timestamp -> a Date the DB layer can bind, or null. The shard emits ISO-8601
// (`DateTime.ToString("o")`); anything else is a plugin we do not recognise and is
// dropped rather than stored as an Invalid Date, which MariaDB rejects in strict mode
// and which would fail the whole vendor over one bad field.
const when = (value) => {
if (!value) return null
const d = new Date(value)
return Number.isNaN(d.getTime()) ? null : d
}
// Protocol 5. The vendor's fee state, normalised out of the frame's `fees` object.
//
// Two things this deliberately does NOT do. It does not recompute `dismissalAt` from
// the parts -- the shard resolved it against ServUO's own two vendor systems (the
// charge, the funds and the interval all differ between them) and re-deriving it here
// would be a second implementation of a rule that lives in PlayerVendor.PayTimer. And
// it does not treat a missing `fees` object as zero: a pre-v5 overlay simply omits it,
// and nulls are how a v5 website says "this shard has not told me" rather than
// "this vendor is broke", which is the difference between silence and a false alarm.
const fees = (f) => {
if (!f || typeof f !== 'object') return { feesExempt: false, chargePerPeriod: null, funds: null, payIntervalSec: null, nextPayAt: null, periodsRemaining: null, dismissalAt: null }
// A commission vendor has no pay timer and is never dismissed for fees. Reporting it
// as exempt with no schedule is not the same as reporting a very long one, and a
// surface that renders "never" must be able to tell them apart.
if (f.exempt === true) return { feesExempt: true, chargePerPeriod: null, funds: null, payIntervalSec: null, nextPayAt: null, periodsRemaining: null, dismissalAt: null }
return {
feesExempt: false,
chargePerPeriod: Number.isFinite(f.chargePerPeriod) ? Math.trunc(f.chargePerPeriod) : null,
funds: Number.isFinite(f.funds) ? Math.trunc(f.funds) : null,
payIntervalSec: Number.isFinite(f.payIntervalSec) ? Math.trunc(f.payIntervalSec) : null,
nextPayAt: when(f.nextPayAt),
periodsRemaining: Number.isFinite(f.periodsRemaining) ? Math.trunc(f.periodsRemaining) : null,
dismissalAt: when(f.dismissalAt),
}
}
// ── Ingest ─────────────────────────────────────────────────────────────────
/**
@@ -64,6 +101,10 @@ function flattenFrame(ev) {
shopName: clip(ev.shopName, MAX_SHOP),
ownerSerial: clip(ev.ownerSerial, MAX_SERIAL),
ownerName: clip(ev.ownerName, MAX_OWNER),
// Protocol 5. The character name has been here since v3, but only the game
// ACCOUNT joins to shard_account_links -- so this is the field that makes a
// vendor row resolvable to a person at all.
ownerAcct: clip(ev.ownerAcct, MAX_ACCT),
map: clip(loc.map, MAX_MAP),
x: Number.isFinite(loc.x) ? Math.trunc(loc.x) : null,
y: Number.isFinite(loc.y) ? Math.trunc(loc.y) : null,
@@ -76,6 +117,7 @@ function flattenFrame(ev) {
itemTotal: int(ev.total, int(ev.count, 0)),
truncated: ev.truncated === true,
t: Number.isFinite(ev.t) ? ev.t : null,
...fees(ev.fees),
}
}

View File

@@ -98,7 +98,12 @@ async function latestEconomy() {
// ── Houses / IDOC ────────────────────────────────────────────────────────
const HOUSE_COLS =
'serial, stage, map, x, y, z, region, name, owner_serial, owner_acct, built_on, last_refreshed, is_idoc, updated_at'
'serial, stage, map, x, y, z, region, name, owner_serial, owner_acct, built_on, last_refreshed, is_idoc, updated_at' +
// Protocol 5's decay schedule. Added to the BASE column list rather than to
// HOUSE_REG_COLS because it arrives on house.decay, so a decay-only row -- one the
// registry sweep has never seen -- carries it too, and the public IDOC page reads
// exactly those rows.
', next_stage, estimated_collapse, decay_period_sec, dynamic_decay'
const upsertHouse = (serial, fields) => upsertRow('shard_houses', 'serial', serial, fields)
@@ -171,6 +176,70 @@ const removeGuild = (id) => query('DELETE FROM shard_guilds WHERE id = ?', [id])
const clearGuilds = () => query('DELETE FROM shard_guilds')
const listGuilds = () => query(`SELECT ${GUILD_COLS} FROM shard_guilds ORDER BY name ASC`)
// ── Guild membership (Protocol 4) ──────────────────────────────────────────
// `rank` is backticked wherever it is written, like `int` on shard_online: it is a
// reserved word in MySQL 8 and merely a keyword in MariaDB, so it parses bare here
// and must not be relied on to.
const MEMBER_COLS = 'guild_id, serial, name, acct, web_id, is_player, `rank`, rank_cliloc, rank_name, t'
// Upsert rather than plain insert: a roster frame can be redelivered (the /history
// backfill replays stored frames on every reconnect), and a redelivery must be a
// no-op rather than a duplicate-key error.
//
// The rank columns are assigned unconditionally, NULL included. A member whose rank
// the shard withheld — a staff account, whose GuildRank getter reports Leader
// regardless of the truth — must go back to "not known" rather than keeping a rank
// from before they were promoted.
const upsertGuildMembers = (rows) => {
if (!rows.length) return Promise.resolve()
const values = rows.map(() => '(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)').join(', ')
const params = rows.flatMap((r) => [
r.guild_id, r.serial, r.name, r.acct, r.web_id, r.is_player,
r.rank, r.rank_cliloc, r.rank_name, r.t,
])
return query(
`INSERT INTO shard_guild_members (${MEMBER_COLS}) VALUES ${values}
ON DUPLICATE KEY UPDATE name = VALUES(name), acct = VALUES(acct),
web_id = VALUES(web_id), is_player = VALUES(is_player),
\`rank\` = VALUES(\`rank\`), rank_cliloc = VALUES(rank_cliloc),
rank_name = VALUES(rank_name), t = VALUES(t)`,
params,
)
}
const clearGuildMembers = (guildId) =>
query('DELETE FROM shard_guild_members WHERE guild_id = ?', [guildId])
const removeGuildMember = (guildId, serial) =>
query('DELETE FROM shard_guild_members WHERE guild_id = ? AND serial = ?', [guildId, serial])
const clearAllGuildMembers = () => query('DELETE FROM shard_guild_members')
const listGuildMembers = (guildId) =>
query(`SELECT ${MEMBER_COLS} FROM shard_guild_members WHERE guild_id = ? ORDER BY name ASC`, [
guildId,
])
// **The game accounts on one guild's roster** — the input to
// `shardLinks.userIdsForAccounts`, and therefore to the `members` audience a
// guild event carries (Phase 11). Accounts rather than `web_id`, deliberately:
// `web_id` is a value MIRRORED off the wire actor, and `shard_account_links` is
// the authoritative map. A mirror that has drifted would mail the wrong person,
// and a mirror that is behind would mail nobody, so the query that decides who
// is told reads the table whose job that is.
const listGuildMemberAccounts = (guildId) =>
query(
'SELECT DISTINCT acct FROM shard_guild_members WHERE guild_id = ? AND acct IS NOT NULL',
[guildId],
)
// The accounts of every sitting governor — the `uo.governors` audience.
// `governor_acct` is NULL on a city with no governor and on one whose governor's
// mobile has no account, and both are simply nobody.
const listGovernorAccounts = () =>
query('SELECT DISTINCT governor_acct FROM shard_governors WHERE governor_acct IS NOT NULL')
// The guild an actor LEADS — matched on the current board (leader_serial or the
// linked leader_acct), so it reflects live state. Guild MEMBERSHIP for non-leaders
// is not modelled (the board carries only counts + leader), so we don't guess it.
@@ -342,6 +411,13 @@ module.exports = {
removeGuild,
clearGuilds,
listGuilds,
upsertGuildMembers,
clearGuildMembers,
removeGuildMember,
clearAllGuildMembers,
listGuildMembers,
listGuildMemberAccounts,
listGovernorAccounts,
findGuildLedByActor,
listGuildsLedByAccounts,
upsertGovernor,

View File

@@ -124,10 +124,39 @@ async function upsertHouse(data) {
built_on: data.builtOn ? new Date(data.builtOn) : null,
last_refreshed: data.lastRefreshed ? new Date(data.lastRefreshed) : null,
is_idoc: String(data.stage).toUpperCase() === 'IDOC' ? 1 : 0,
// Protocol 5. `ownerName` is written back only when the frame carries one, and
// that asymmetry is deliberate: house.update also writes this column, from a
// different sweep, and a pre-v5 overlay's house.decay frame has no ownerName at
// all. Coalescing to null here would let every decay transition ERASE a name the
// registry had already resolved.
...(data.ownerName ? { owner_name: String(data.ownerName).slice(0, 120) } : {}),
...decayScheduleFields(data.schedule),
}
await db.upsertHouse(data.serial, fields)
}
// Protocol 5's `schedule` object, flattened into its columns.
//
// Unlike ownerName above, these are written back UNCONDITIONALLY, including as nulls.
// A schedule is a claim about the future and it goes stale on its own: if a shard is
// rolled back to a pre-v5 overlay, or a house leaves IDOC so its collapse time stops
// being knowable, the right stored value is "nothing" rather than the last thing we
// were told. A dated promise nobody is maintaining is worse than no promise.
function decayScheduleFields(schedule) {
const s = schedule && typeof schedule === 'object' ? schedule : {}
const when = (v) => {
if (!v) return null
const d = new Date(v)
return Number.isNaN(d.getTime()) ? null : d
}
return {
next_stage: when(s.nextStage),
estimated_collapse: when(s.estimatedCollapse),
decay_period_sec: Number.isFinite(s.decayPeriodSec) ? Math.trunc(s.decayPeriodSec) : null,
dynamic_decay: typeof s.dynamicDecay === 'boolean' ? (s.dynamicDecay ? 1 : 0) : null,
}
}
function shapeHouse(r) {
return {
serial: r.serial,
@@ -149,6 +178,16 @@ function shapeHouse(r) {
inRegistry: r.in_registry == null ? undefined : Boolean(r.in_registry),
builtOn: r.built_on,
lastRefreshed: r.last_refreshed,
// Protocol 5. Re-nested on read for the reason shardMarket re-nests `location`:
// the visibility projection matches literal JSON keys, so the stored read model
// and the live wire frame have to spell this the same way or the one admin rule
// covers only one of the two paths.
schedule: {
dynamicDecay: r.dynamic_decay == null ? null : Boolean(r.dynamic_decay),
nextStage: r.next_stage,
decayPeriodSec: r.decay_period_sec,
estimatedCollapse: r.estimated_collapse,
},
isIdoc: Boolean(r.is_idoc),
updatedAt: r.updated_at,
}
@@ -340,8 +379,101 @@ async function upsertGuild(ev) {
})
}
const removeGuild = (id) => (id == null ? Promise.resolve() : db.removeGuild(id))
const clearGuilds = () => db.clearGuilds()
const removeGuild = async (id) => {
if (id == null) return
await db.removeGuild(id)
await db.clearGuildMembers(id)
}
const clearGuilds = async () => {
await db.clearGuilds()
await db.clearAllGuildMembers()
}
// ── Guild membership (Protocol 4) ──────────────────────────────────────────
// Apply one guild.roster frame.
//
// A roster larger than the shard's per-frame cap arrives as several frames
// carrying seq/more/total. The sidecar reassembles them for its OWN board, but the
// live WebSocket feed and the /history backfill both carry the individual frames,
// so this ingest sees them unreassembled and has to cope.
//
// It copes without buffering, because a table can express what a single JSON column
// could not: the frame carrying seq 0 clears the guild first and every frame then
// upserts its own rows. Rows are keyed on (guild_id, serial), so a redelivered frame
// — the /history backfill replays stored frames on every reconnect — is idempotent
// rather than a duplicate-key error.
//
// The cost is a brief window during a multi-frame update where the table holds part
// of a roster. That is acceptable for a projection that is already only as fresh as
// a 60s sweep, and the frames arrive back-to-back in one burst; buffering to close
// it would duplicate the sidecar's reassembly for a sub-second inconsistency.
async function upsertGuildRoster(ev) {
if (!ev || ev.id == null) return
const seq = Number.isFinite(ev.seq) ? ev.seq : 0
const members = Array.isArray(ev.members) ? ev.members : []
// seq 0 begins a roster and supersedes whatever was held for this guild.
if (seq === 0) await db.clearGuildMembers(ev.id)
const rows = members
.filter((m) => m && m.serial)
.map((m) => ({
guild_id: ev.id,
serial: m.serial,
name: m.name ?? null,
acct: m.acct ?? null,
web_id: Number.isFinite(m.webId) ? m.webId : null,
is_player: m.player ? 1 : 0,
// Guild rank (Protocol 4). ABSENT is a real state and is stored as NULL: the
// shard withholds the rank for a staff account, because ServUO's GuildRank
// getter reports Leader for anyone at GameMaster or above whatever their
// actual rank. Defaulting a missing rank to 0 here would turn "we were not
// told" into "rank 0", which is a demotion invented by this line.
rank: Number.isInteger(m.rank) ? m.rank : null,
rank_cliloc: Number.isInteger(m.rankCliloc) ? m.rankCliloc : null,
rank_name: typeof m.rankName === 'string' && m.rankName ? m.rankName.slice(0, 64) : null,
t: Number.isFinite(ev.t) ? ev.t : null,
}))
await db.upsertGuildMembers(rows)
}
// A single departure (guild.leave). Advisory: the shard re-emits the full roster
// whenever the member set changes, so the table would converge on the next frame
// even if this were dropped. Applying it makes the change visible immediately
// instead of at the end of the sweep that produced it.
async function removeGuildMember(ev) {
if (!ev || ev.id == null || !ev.who) return
await db.removeGuildMember(ev.id, ev.who)
}
// The membership roster for one guild, in the wire shape the projection expects
// (an array of actor objects), so shardVisibility strips acct/webId by the same
// rule it applies to guild.leader.
async function listGuildMembers(guildId) {
const rows = await db.listGuildMembers(guildId)
return rows.map((r) => ({
serial: r.serial,
name: r.name,
...(r.acct == null ? {} : { acct: r.acct }),
...(r.web_id == null ? {} : { webId: r.web_id }),
player: !!r.is_player,
}))
}
// **Just the accounts, for the engagement audiences** (Phase 11). Deliberately
// NOT `listGuildMembers().map(m => m.acct)`: that shape exists to be projected
// through `shardVisibility`, which strips `acct` for anyone below admin, so
// building an audience out of it would either leak the projection's job into
// this one or silently resolve to nobody depending on who asked. These two go to
// the database for exactly the column they need and pass nothing else on.
const listGuildMemberAccounts = async (guildId) =>
(await db.listGuildMemberAccounts(guildId)).map((r) => r.acct).filter(Boolean)
const listGovernorAccounts = async () =>
(await db.listGovernorAccounts()).map((r) => r.governor_acct).filter(Boolean)
function shapeGuild(r) {
const payload = typeof r.payload === 'string' ? safeJson(r.payload) : r.payload
@@ -620,6 +752,11 @@ module.exports = {
removeGuild,
clearGuilds,
listGuilds,
upsertGuildRoster,
removeGuildMember,
listGuildMembers,
listGuildMemberAccounts,
listGovernorAccounts,
replaceGuilds,
findGuildForActor,
listGuildsLedForAccounts,

View File

@@ -0,0 +1,87 @@
// SQL behind the Team provider — three questions core asks, answered from the
// guild board and the roster Protocol 4 put there.
//
// Every statement reads only THIS module's tables. Core's Team tables are
// core-internal (docs/website/TEAMS.md §10.3) and this module must never name
// one, even though it is what fills them.
// `query` is destructured from the core facade at require time, like every other
// *.db.js here. The facade resolves `ctx` per call, so taking it now is safe even
// though `ctx` does not exist yet when this file is first required.
const { query } = require('../../core')
/** ServUO's `RankDefinition.Ranks[4]` is Leader, and 4 is the top of the ladder. */
const LEADER_RANK = 4
/**
* The guild board — one row per guild the shard has told us about.
*
* `members`/`online` here are the COUNTS `guild.update` carries; the roster is a
* separate table (Protocol 4). Both are read, because a count is what the shard
* asserts and a roster is what it enumerated, and they can legitimately disagree
* for the moment between a membership change and the sweep that reports it.
*/
const listGuilds = () =>
query(
`SELECT id, name, abbr, alliance, members, online, leader_serial, leader_name, leader_acct
FROM shard_guilds ORDER BY name ASC`,
)
const findGuild = (id) =>
query(
`SELECT id, name, abbr, alliance, members, online, leader_serial, leader_name, leader_acct
FROM shard_guilds WHERE id = ? LIMIT 1`,
[id],
)
/**
* One guild's roster, with the site link and live presence folded in.
*
* Two LEFT JOINs, both deliberate:
*
* - `shard_account_links` resolves `user_id` HERE rather than in core, because
* this module owns that table and a core that read it would be core naming a
* module's table by name (§2.3). It is also why a freshly linked account
* appears as linked on the next reconcile rather than needing core to know
* anything about linking.
* - `shard_online` is how a member's `online` is answered at all. The roster
* frame does not carry it — the wire's member is the standard actor object
* (`serial`, `name`, `player`, `acct?`, `webId?`), and the board's `online` is
* a count, not a set. Presence therefore comes from the online table, which
* is the same source the public "who's online" surface already uses.
*
* `web_id` on the roster row is preferred over the link table when present: it is
* what the shard itself asserted at roster time, and the join is the fallback for
* a member whose row predates their link.
*/
const listGuildMembers = (guildId) =>
query(
"SELECT m.serial, m.name, m.acct, m.web_id, m.is_player, m.`rank`, m.rank_cliloc, m.rank_name, " +
` l.user_id AS linked_user_id,
(o.serial IS NOT NULL) AS is_online
FROM shard_guild_members m
LEFT JOIN shard_account_links l ON l.account = m.acct
LEFT JOIN shard_online o ON o.serial = m.serial
WHERE m.guild_id = ?
ORDER BY m.name ASC`,
[guildId],
)
/**
* Every member at leader rank — rank 4, the top of ServUO's `RankDefinition.Ranks`.
*
* A set, not a single row, and that is the whole reason Protocol 4 grew a per-member
* rank: the guild board carries one `leader_serial`, so before this the website could
* only ever be told about one leader, while a UO guild routinely has several.
*
* A NULL rank is excluded by the comparison, which is correct — the shard withholds
* the rank for a staff account rather than publishing the Leader its getter falsely
* reports, and "not known" must not be read as "leads this guild".
*/
const listGuildLeaders = (guildId) =>
query(
'SELECT serial FROM shard_guild_members WHERE guild_id = ? AND `rank` >= ? ORDER BY name ASC',
[guildId, LEADER_RANK],
)
module.exports = { listGuilds, findGuild, listGuildMembers, listGuildLeaders, LEADER_RANK }

View File

@@ -0,0 +1,339 @@
// ── module-uo's Team provider ──────────────────────────────────────────────
//
// The three questions core asks this module about Teams
// (docs/website/MODULE_API.md — `api.registerTeamProvider`, and TEAMS.md §2.3).
// A UO guild is a Team; this file is the whole of the translation.
//
// **Every method returns an envelope, and answering `{ ok: false }` is a normal
// outcome, not a failure to handle.** Core's contract is that module
// unavailability becomes staleness and never emptiness, and the only way this
// module can say "I cannot answer" is to say so — an empty array would be read as
// an authoritative "there are none", which during a cold start is how every
// roster on the site gets emptied. So the guard below is the most important code
// in the file, and it is deliberately conservative: **an unreachable or
// never-connected sidecar refuses, rather than reporting the board it happens to
// still hold.**
//
// The board IS durable and would survive a sidecar outage, which is exactly what
// makes this tempting to get wrong. The reason to refuse anyway: core cannot tell
// a board that is five minutes stale from one that is five days stale, and it
// makes destructive decisions — archiving Teams, departing members — from a
// complete answer. Reporting a stale board as authoritative would license those.
const core = require('../../core')
const db = require('./teamProvider.db')
const uoLinkConfig = require('../uoLinkConfig/uoLinkConfig.model')
const uoLinkSocket = require('../../utils/uoLinkSocket')
const clilocs = require('../shardClilocs/shardClilocs.model')
const visibility = require('../../utils/shardVisibility')
const log = core.logger('teams')
/**
* ServUO's five stock rank names, by the cliloc id the game names them with.
*
* A fallback, not the source of truth: the operator's own cliloc table is consulted
* first, and a shard with custom rank definitions sends a literal string that beats
* both. This exists because the cliloc table is populated only if someone ran the
* client-file extraction, and a roster on a shard that has not should still say
* "Warlord" rather than nothing.
*/
const STANDARD_RANK_NAMES = {
1062959: 'Leader',
1062960: 'Warlord',
1062961: 'Emissary',
1062962: 'Member',
1062963: 'Ronin',
}
/** A refusal, in the shape core reads (§2.3). */
const refuse = (reason) => ({ ok: false, reason })
/**
* Is the bridge in a state where the board can be trusted as current?
*
* The board is only as good as the socket that fills it. Three states refuse, and
* they are asked in this order because each is a different thing being wrong:
*
* - **no uo-link configured** — there is no shard behind this website at all;
* - **the integration is disabled** — an admin turned it off, and the board is
* frozen at whatever it held;
* - **the socket is not connected** — the board is a snapshot of unknown age.
*
* The in-process socket state is preferred over the persisted status column,
* which is written on transitions: a process that has just started has not
* transitioned yet, so the column can still say `connected` from the last run
* while this process has never opened a socket.
*/
async function boardIsCurrent() {
const config = await uoLinkConfig.getSafe()
if (!config || !config.baseUrl) return { ok: false, reason: 'no uo-link configured' }
if (!config.enabled) return { ok: false, reason: 'the uo-link integration is disabled' }
const state = uoLinkSocket.getState()
if (!state || !state.connected) {
return { ok: false, reason: 'the uo-link socket is not connected; the guild board may be stale' }
}
return { ok: true }
}
/**
* `getTeams()` — every guild on the board.
*
* `externalId` is the ServUO `Guild.Id`, which survives a rename: renaming a
* guild in-game keeps the id, so core sees "an id whose name changed" and applies
* its rename rule (archive plus create). That mapping is this module's to make —
* only the game knows what identity survives what (§10.5).
*
* `meta` carries the alliance, opaquely. Core stores and displays it and never
* branches on it, which is what lets a UO concept reach a Team page without core
* acquiring an opinion about alliances.
*/
async function getTeams() {
const ready = await boardIsCurrent()
if (!ready.ok) return refuse(ready.reason)
try {
const rows = await db.listGuilds()
return {
ok: true,
complete: true,
teams: rows.map((row) => ({
externalId: String(row.id),
name: row.name,
abbr: row.abbr || null,
meta: row.alliance ? { alliance: row.alliance } : null,
})),
}
} catch (err) {
log.warn('getTeams failed', { message: err.message })
return refuse(`guild board unreadable: ${err.message}`)
}
}
/**
* `getTeamMembers(externalId)` — one guild's roster.
*
* **A guild with no roster rows is refused, not reported empty**, unless the board
* itself says the guild has no members. Protocol 4's roster arrives on its own
* frames, separately from the `guild.update` that creates the board row, so there
* is a real window — a fresh guild, or a website that connected between the two —
* where core would otherwise be told authoritatively that a 155-member guild has
* nobody in it. The board's own `members` count is what distinguishes the two,
* and it is the only thing that can.
*/
async function getTeamMembers(externalId) {
const ready = await boardIsCurrent()
if (!ready.ok) return refuse(ready.reason)
try {
const [guild] = await db.findGuild(externalId)
if (!guild) return refuse(`guild ${externalId} is not on the board`)
const rows = await db.listGuildMembers(externalId)
if (!rows.length && guild.members > 0) {
return refuse(`roster for guild ${externalId} has not arrived yet (board says ${guild.members} members)`)
}
const labels = await rankLabels(rows)
return {
ok: true,
complete: true,
members: rows.map((row) => ({
memberKey: row.serial,
displayName: row.name || null,
rankLabel: labels.get(row.serial) || null,
// Rank 4 is Leader, and several members can hold it. A NULL rank is not a
// leader: the shard withholds the rank for a staff account rather than
// publishing the Leader its getter falsely reports, and "not known" must
// never be read as "leads this guild".
leader: Number.isInteger(row.rank) && row.rank >= db.LEADER_RANK,
online: Boolean(row.is_online),
userId: resolveUserId(row),
})),
}
} catch (err) {
log.warn('getTeamMembers failed', { externalId, message: err.message })
return refuse(`roster unreadable: ${err.message}`)
}
}
/**
* `getTeamLeaders(externalId)` — everyone at leader rank.
*
* **All of them, which is why Protocol 4 grew a per-member rank.** The guild board
* carries one `leader_serial`, so before the rank amendment this could only ever
* name a single member, while a UO guild routinely has several at rank 4 and
* TEAMS.md §2.5 treats multiple leaders as the normal case.
*
* The board's own `leader_serial` is folded in as a floor. It is the guild's
* founder-leader and it comes from a different frame (`guild.update`), so on a
* shard whose roster has not been re-emitted since the amendment it is the only
* leadership signal there is — and it should never be *lost* by moving to ranks.
*/
async function getTeamLeaders(externalId) {
const ready = await boardIsCurrent()
if (!ready.ok) return refuse(ready.reason)
try {
const [guild] = await db.findGuild(externalId)
if (!guild) return refuse(`guild ${externalId} is not on the board`)
const rows = await db.listGuildLeaders(externalId)
const leaders = rows.map((r) => r.serial)
if (guild.leader_serial && !leaders.includes(guild.leader_serial)) {
leaders.push(guild.leader_serial)
}
return { ok: true, leaders }
} catch (err) {
log.warn('getTeamLeaders failed', { externalId, message: err.message })
return refuse(`leadership unreadable: ${err.message}`)
}
}
/**
* Resolve each member's rank to a display label, keyed by serial.
*
* The shard sends the rank's NAME as the game states it — a cliloc id for the five
* standard ranks, or a literal string for a custom rank definition — and never a
* resolved label, because ServUO ships no text for those clilocs. This module does
* have a cliloc table, which is why the resolution belongs here.
*
* Three sources, in order: a custom string wins, then the operator's cliloc table,
* then the five standard names. The last exists because the cliloc table is
* populated only if someone ran the client extraction, and a shard that has not
* should still read "Warlord" rather than nothing.
*
* Never throws: a rank label is decoration on a roster, and a lookup failure must
* not turn a good roster into a refusal.
*/
async function rankLabels(rows) {
const out = new Map()
const wanted = []
for (const row of rows) {
if (row.rank_name) {
out.set(row.serial, row.rank_name)
} else if (Number.isInteger(row.rank_cliloc)) {
wanted.push(row.rank_cliloc)
}
}
let resolved = new Map()
if (wanted.length) {
try {
resolved = await clilocs.resolveMany(wanted)
} catch (err) {
log.warn('rank cliloc lookup failed; falling back to the standard names', { message: err.message })
}
}
for (const row of rows) {
if (out.has(row.serial) || !Number.isInteger(row.rank_cliloc)) continue
const label = resolved.get(row.rank_cliloc) || STANDARD_RANK_NAMES[row.rank_cliloc] || null
if (label) out.set(row.serial, label)
}
return out
}
/**
* The site account behind a character, or null.
*
* `web_id` is what the shard itself asserted when it emitted the roster; the
* account-link join is the fallback for a member whose roster row predates their
* link. Both are coerced through the same check, because `web_id` arrives from
* the wire as a string.
*/
function resolveUserId(row) {
const fromRoster = Number.parseInt(row.web_id, 10)
if (Number.isInteger(fromRoster) && fromRoster > 0) return fromRoster
const fromLink = Number.parseInt(row.linked_user_id, 10)
return Number.isInteger(fromLink) && fromLink > 0 ? fromLink : null
}
/**
* Which roster rows a viewer may see (TEAMS.md §3.3, MODULE_API 1.6.0).
*
* The optional fourth provider method, and the only one core calls on a REQUEST
* path rather than from the reconciler. Core holds the roster and its public
* shape; the question that is this module's is "who is allowed to look", because
* the audience rungs and their configuration live here (`utils/shardVisibility`)
* and core does not know what a rung is.
*
* **The answer is all-or-nothing, and that is correct rather than a shortcut.**
* A rung is a property of the FEATURE, not of a member: `guilds` is either
* visible to this viewer or it is not, and there is no configuration in which
* some members of a guild are public and others are not. Returning every key or
* none is the honest translation of the model this module actually has.
*
* **A refusal here costs visibility, not staleness.** Core fails closed on this
* one call — an unanswered visibility question serves an empty roster rather than
* an unprojected one — so every path below that cannot reach a confident answer
* refuses deliberately, and the catch does too. That is the opposite of the rule
* governing the other three methods, and it is the right way round: for a roster
* SYNC an unanswered call must change nothing, and for a roster READ it must
* publish nothing.
*
* Note what this does NOT do: strip fields. `acct` and `webId` are the leak this
* module's projection exists to prevent on the live feed, and neither is in
* core's roster shape at all — core withholds the member key and the site account
* id from every public roster whatever this returns. So there is nothing here to
* redact, only rows to withhold.
*/
async function projectRoster(externalId, members, viewer) {
try {
const config = await visibility.getConfig()
const feature = config.guilds
// An admin turned guilds off. Nobody sees a roster, including staff — the
// switch means "this shard does not publish guild data", not "publish it
// quietly".
if (!feature || !feature.enabled) return { ok: true, members: [] }
// `viewerLevel` reads a REQUEST; core hands over a described viewer instead,
// which is deliberate — it keeps the `users` row out of the contract.
//
// The no-viewer case is answered here rather than by handing `viewerLevel` an
// empty object: given a request with no `req.user` it falls through to
// `auth.getUserFromRequest`, which expects real cookies and headers and
// throws on a synthetic one. That throw would land in the catch below and
// become a REFUSAL, so every anonymous visitor would have been served an
// empty roster on a shard whose guilds are public. Anonymous is a known
// answer, not a failed lookup.
const level = viewer
? await visibility.viewerLevel({ user: { id: viewer.userId, role: viewer.role } })
: 'anonymous'
if (!visibility.meets(level, feature.audience)) return { ok: true, members: [] }
return { ok: true, members: members.map((m) => m.member_key).filter(Boolean) }
} catch (err) {
// Core reads this as "withhold the roster". Saying so is the whole point: the
// alternative — answering with every key because the config read failed —
// publishes a roster an operator may have gated to staff.
log.warn('projectRoster could not resolve visibility; withholding the roster', {
externalId, message: err.message,
})
return refuse(`visibility could not be resolved: ${err.message}`)
}
}
// Where core should point a link at a guild (MODULE_API 1.6.0, TEAMS.md §6.4).
//
// **Core cannot work this out for itself, and it is not supposed to.** Teams are
// a contract primitive with no core surface — this module owns the guild page,
// because core does not own the word "guild" — so the one thing core needs back
// is where the page it does not own actually lives. A notification email that
// cannot link to the thread it is about is most of the way to useless.
//
// A relative path with `{externalId}` substituted, matching `Guild.jsx`'s route
// (`/uo/guilds/:id`). Core does the substitution and nothing else with it; a
// template naming its own host is refused at registration, which is why this is
// data and not a callback.
const pageUrlTemplate = '/uo/guilds/{externalId}'
// `resolveUserId` is exported for the `/guild` chat command, which counts linked
// members and must decide "linked" by the same rule the roster does — a second
// copy of that two-source check is a copy that drifts.
module.exports = {
getTeams, getTeamMembers, getTeamLeaders, projectRoster, boardIsCurrent, pageUrlTemplate, resolveUserId,
}

View File

@@ -10,7 +10,29 @@ const { secretBox } = require('../../core')
// The wire protocol this build speaks (link/sidecar/src/main.rs PROTOCOL_VERSION).
// Only used before an admin has saved anything — the stored row wins once it exists,
// and UOLINK_PROTOCOL still overrides for an operator running an older sidecar.
const DEFAULT_PROTOCOL = Number(process.env.UOLINK_PROTOCOL) || 3
//
// This says 7 because this build speaks protocol 7: the idempotency key and the
// participation ledger (6), and the world verbs plus the targeted lease planes (7).
//
// It said 4 before 5, and 3 for a while after protocol 4 shipped — which is the bug this
// constant was introduced to fix. A FRESH install pinned 3, the sidecar answered
// `409 protocol version mismatch` to every REST call, and a new deployment read nothing
// from its shard until an admin edited the number by hand in Admin → Shard.
//
// **And it happened again, twice, in Phases 11a and 12a** — this constant and the two in
// `db/schema.sql` all sat at 5 while the wire went to 6 and then 7, so every sidecar call
// on a real deployment would have been refused. Both live walks set the column by hand
// while standing the rig up, which is exactly what makes a migration nobody runs
// invisible. Phase 12b carries all three to 7.
//
// **Nothing in this repo can check this against the wire**, and that is worth knowing
// before trusting the test that guards it: `schemaFragment.test.js` asserts the three
// declarations agree WITH EACH OTHER, which is a real check — they drifted apart once —
// but all three being equally stale passes it. The wire's version lives in `link`
// (`PROTOCOL_VERSION`) and the overlay's in `servuo-plugins/overlay.toml`; the thing that
// actually pairs them is the installer's bundle check, at deploy time. So bumping this in
// the same change as the emitters is still the discipline, and no test here replaces it.
const DEFAULT_PROTOCOL = Number(process.env.UOLINK_PROTOCOL) || 7
function toSafe(row) {
if (!row) {
@@ -85,4 +107,7 @@ async function recordStatus({ status, statusDetail, pluginConnected, lastEventAt
return toSafe(row)
}
module.exports = { getSafe, getWithToken, save, recordStatus }
// DEFAULT_PROTOCOL is exported for the schema test, which asserts that this constant
// and schema.sql's two declarations of the same number AGREE, rather than asserting a
// hardcoded version at each site -- which is what let them drift apart before.
module.exports = { getSafe, getWithToken, save, recordStatus, DEFAULT_PROTOCOL }

View File

@@ -8,6 +8,7 @@
"scripts": {
"test": "node --test --require ./test/_setup.js",
"check:imports": "node scripts/checkImports.js",
"check:bundle": "node scripts/checkBundle.js",
"swagger": "node scripts/swaggerFragment.js",
"check:swagger": "node scripts/swaggerFragment.js --check"
},

View File

@@ -177,6 +177,31 @@ async function getGuilds(req, res) {
}
}
// GET /public/shard/guilds/:id — one guild and its roster.
//
// The board endpoint above returns every guild WITHOUT its roster; this is the
// detail view, and it is the page that hosts core's Team activity feed through
// the `uo.guild.detail` slot (docs/website/TEAMS.md Part 3).
//
// Projected through the same `guilds` feature as the board, so an operator who
// gates guilds to staff gates this too, and `acct`/`webId` on the roster rows
// never survive below admin — those are LOCKED fields, and a roster is where they
// actually appear in bulk.
async function getGuild(req, res) {
try {
const guilds = await shardState.listGuilds()
const guild = guilds.find((g) => String(g.id) === String(req.params.id))
// 404 rather than an empty object: a guild that disbanded is gone, and the
// page needs to say so rather than render an empty shell.
if (!guild) return res.status(404).json({ message: 'Not Found' })
const members = await shardState.listGuildMembers(guild.id)
return res.json(await visibility.project('guilds', { ...guild, roster: members }, req))
} catch (err) {
log.error('shard.getGuild', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// GET /public/shard/governors — the current town-governor board (empty on shards
// without City Loyalty). Live via city.update on the public SSE stream. Projected
// for the same reason as getGuilds: `governor` / `governorElect` are actors.
@@ -421,6 +446,7 @@ module.exports = {
getIdoc,
getChamps,
getGuilds,
getGuild,
getGovernors,
getGovernorHistory,
getPresence,

View File

@@ -100,6 +100,17 @@ shardRouter.get(
/* #swagger.responses[200] = { description: 'Guilds, ordered by name', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
shard.getGuilds,
)
shardRouter.get(
'/guilds/:id',
requireFeature('guilds'),
// #swagger.tags = ['Public · Shard']
// #swagger.summary = 'One guild and its roster'
// #swagger.description = 'The detail view behind the board. Gated and projected through the same `guilds` feature, so an operator who raises that audience raises this too, and the locked acct/webId fields never survive below admin — a roster is where they appear in bulk. This page is also where core renders the Team activity feed, through the `uo.guild.detail` extension slot.'
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The guild id.' }
/* #swagger.responses[200] = { description: 'The guild, with its roster', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[404] = { description: 'No such guild', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
shard.getGuild,
)
shardRouter.get(
'/governors',
requireFeature('governors'),

View File

@@ -0,0 +1,248 @@
#!/usr/bin/env node
// ── Does the release actually ship everything the module needs? ────────────
//
// `ci/bundle.json` says what a release copies. `server/index.js` says what the
// module requires. Nothing kept those two in agreement, and on 2026-08-19 they
// disagreed in production: `server/commands/` was added by the Teams cutover,
// the include list in release.yml was not updated, and v1.0.0 shipped without
// it. Every boot logged
//
// module "uo" failed to load — {"stage":"register","reason":"Cannot find
// module './commands/guild.command'"}
//
// and the module was dead on the operator's box. Nothing caught it: the PR
// checks install the module by copying the WHOLE repo into core, so they only
// ever exercised a tree that had the file. The release is the only place the
// subset exists, and the release had no check that the subset was complete.
//
// This script asks that question in the two places it can be asked:
//
// --check (PR checks) Every file reachable from the entry point by a
// relative require lives under something ci/bundle.json
// lists. Source-tree only, so it is fast and needs no
// assembled bundle — it fails on the PR that adds the
// directory, which is where the fix is cheapest.
//
// --bundle <dir> (release) Every relative specifier inside an ASSEMBLED bundle
// resolves to a file that is in it. Asked of the
// artifact rather than of the source, so it also
// catches a copy that half-failed, a list that names a
// path that has moved, and anything else between the
// declaration and the tarball.
//
// The two are deliberately not the same question. The first is about the list
// being right; the second is about the tarball being right. A release runs both.
//
// ── Why reachability, and not "require the entry point" ────────────────────
//
// The obvious check — require the bundle's entry and see if it throws — does not
// work here, and the reason is in index.js's own header: its requires are inside
// `register()` because require order is load-bearing (`core.init(ctx)` has to run
// before anything under `router/` is required). So requiring the entry evaluates
// exactly one line, `require('./core')`, and reports success on a bundle missing
// every router it has. Calling `register()` for real would need a fake `ctx`
// complete enough to satisfy the whole module — which is what `test/` is for, and
// `test/` does not ship. Walking the requires statically asks the same question
// without needing either.
const fs = require('fs')
const path = require('path')
const { stripCommentsAndTemplates } = require('./checkImports')
const MODULE_ROOT = path.resolve(__dirname, '..', '..')
const SERVER_ROOT = path.join(MODULE_ROOT, 'server')
// Only relative specifiers. A bare one is checkImports.js's question, not this
// one, and the two failures want different advice.
const RELATIVE = /(?:require\(|from\s+|import\()\s*['"](\.[^'"]+)['"]/g
/**
* Resolve a relative specifier the way Node would, for the file cases that can
* appear here: an exact path, `+.js`/`+.json`, or a directory's `index.js`.
*
* Returns null when nothing exists — which is the finding, not an error.
*/
function resolveFile(fromDir, specifier) {
const base = path.resolve(fromDir, specifier)
const candidates = [base, `${base}.js`, `${base}.json`, path.join(base, 'index.js')]
for (const c of candidates) {
if (fs.existsSync(c) && fs.statSync(c).isFile()) return c
}
return null
}
/**
* Every file reachable from `entry` by following relative requires, plus every
* specifier that resolved to nothing.
*
* Exported so the test can point it at fixtures — the same reason checkImports.js
* exports `scan`. A check that has never been shown to fail is a check nobody
* knows the state of, and this one is now load-bearing for every release.
*/
function reachable(entry) {
const seen = new Set()
const missing = []
const queue = [entry]
while (queue.length) {
const file = queue.shift()
if (seen.has(file)) continue
seen.add(file)
// A .json dependency is a leaf: it is reached, it ships, and it has no
// requires of its own to follow.
if (file.endsWith('.json')) continue
const source = stripCommentsAndTemplates(fs.readFileSync(file, 'utf8'))
for (const [, specifier] of source.matchAll(RELATIVE)) {
const target = resolveFile(path.dirname(file), specifier)
if (target) queue.push(target)
else missing.push({ file, specifier })
}
}
return { files: [...seen], missing }
}
/**
* Everything ci/bundle.json says ends up in the bundle, as absolute paths:
* `server[]` relative to server/, `root[]` and `generated[]` relative to the
* module root. All three are equally "in the tarball" as far as a require is
* concerned — the only difference is how they get there.
*/
function declaredServerPaths(moduleRoot = MODULE_ROOT) {
const manifest = JSON.parse(fs.readFileSync(path.join(moduleRoot, 'ci', 'bundle.json'), 'utf8'))
return [
...manifest.server.map((p) => path.join(moduleRoot, 'server', p)),
...(manifest.root || []).map((p) => path.join(moduleRoot, p)),
...(manifest.generated || []).map((p) => path.join(moduleRoot, p))
]
}
const covers = (declared, file) =>
declared.some((d) => file === d || file.startsWith(d + path.sep))
/**
* --check: is ci/bundle.json's list sufficient for what the entry point reaches?
*
* Reports the top-level entry to ADD rather than the individual files, because
* that is the edit: the list is stated in top-level paths, and a new directory
* arrives with a dozen files in it.
*/
function checkDeclaration(moduleRoot = MODULE_ROOT) {
const serverRoot = path.join(moduleRoot, 'server')
const entry = path.join(serverRoot, 'index.js')
const { files, missing } = reachable(entry)
const declared = declaredServerPaths(moduleRoot)
// Grouped by the entry that would have to be added, which is the top-level
// path under server/ — or, for the rare reachable file outside it, the path
// itself, since that one belongs in root[] instead.
const uncovered = new Map()
for (const file of files) {
if (covers(declared, file)) continue
const inServer = file.startsWith(serverRoot + path.sep)
const key = inServer
? `server/${path.relative(serverRoot, file).split(path.sep)[0]}`
: path.relative(moduleRoot, file).split(path.sep).join('/')
if (!uncovered.has(key)) uncovered.set(key, [])
uncovered.get(key).push(file)
}
return { uncovered, missing, reached: files.length }
}
/**
* --bundle: does every relative specifier inside an assembled bundle resolve?
*
* Walks the bundle's own server tree rather than starting from the entry point,
* so a file that ships but is broken is caught too.
*/
function checkBundle(bundleRoot) {
const serverRoot = path.join(bundleRoot, 'server')
const missing = []
const files = []
const walk = (dir) => {
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, e.name)
if (e.isDirectory()) {
// The installed dependency tree is npm's business, not this check's.
if (e.name !== 'node_modules') walk(p)
} else if (/\.(js|mjs|cjs)$/.test(e.name)) {
files.push(p)
}
}
}
walk(serverRoot)
for (const file of files) {
const source = stripCommentsAndTemplates(fs.readFileSync(file, 'utf8'))
for (const [, specifier] of source.matchAll(RELATIVE)) {
if (!resolveFile(path.dirname(file), specifier)) missing.push({ file, specifier })
}
}
return { missing, scanned: files.length }
}
module.exports = { reachable, resolveFile, checkDeclaration, checkBundle, declaredServerPaths }
// Required by a test, or run as the check? Only the second one exits.
if (require.main !== module) return
const bundleFlag = process.argv.indexOf('--bundle')
if (bundleFlag !== -1) {
const root = process.argv[bundleFlag + 1]
if (!root) {
console.error('--bundle needs the path to an assembled bundle')
process.exit(2)
}
const { missing, scanned } = checkBundle(path.resolve(root))
if (missing.length) {
console.error(`\nThe assembled bundle is incomplete — ${missing.length} require(s) resolve to nothing:\n`)
for (const m of missing) {
console.error(` ${path.relative(root, m.file)}\n requires "${m.specifier}" — not in the bundle`)
}
console.error('\nAdd the missing path to ci/bundle.json.\n')
process.exit(1)
}
console.log(`OK — every relative require in the bundle resolves (${scanned} files scanned).`)
} else {
const { uncovered, missing, reached } = checkDeclaration()
if (missing.length) {
console.error(`\n${missing.length} require(s) resolve to nothing in the source tree:\n`)
for (const m of missing) {
console.error(` ${path.relative(MODULE_ROOT, m.file)}\n requires "${m.specifier}"`)
}
console.error('')
process.exit(1)
}
if (uncovered.size) {
console.error(`\nci/bundle.json does not ship everything server/index.js reaches.\n`)
console.error('A release built from this list would install and then fail at the')
console.error('register stage with "Cannot find module", on the operator\'s box.\n')
for (const [key, files] of uncovered) {
console.error(` ${key} (${files.length} file${files.length === 1 ? '' : 's'} reachable)`)
for (const f of files.slice(0, 5)) console.error(` ${path.relative(MODULE_ROOT, f)}`)
if (files.length > 5) console.error(` … and ${files.length - 5} more`)
}
// server[] is written relative to server/, so name the entry to add rather
// than the path just displayed — they differ by exactly that prefix.
const toServer = [...uncovered.keys()].filter((k) => k.startsWith('server/'))
const toRoot = [...uncovered.keys()].filter((k) => !k.startsWith('server/'))
if (toServer.length) {
console.error(`\nAdd ${toServer.map((k) => `"${k.slice('server/'.length)}"`).join(', ')} to ci/bundle.json's server[].`)
}
if (toRoot.length) {
console.error(`\nAdd ${toRoot.map((k) => `"${k}"`).join(', ')} to ci/bundle.json's root[].`)
}
console.error('')
process.exit(1)
}
console.log(`OK — ci/bundle.json ships every file server/index.js reaches (${reached} files).`)
}

View File

@@ -47,6 +47,11 @@ function fakeCtx(overrides = {}) {
settings: { get: spy(Promise.resolve(null)), set: spy(Promise.resolve()), getInstanceName: spy(Promise.resolve('Test')) },
auth: { getUserFromRequest: spy(null) },
push: { publish: spy(Promise.resolve()) },
// MODULE_API 1.7.0. Both are fire-and-forget and return undefined by
// contract — a module gets no delivery answer back, deliberately — so the
// spies return undefined rather than a promise, which is what core does.
events: { emit: spy(undefined), reconcile: spy(undefined) },
inbox: { push: spy(undefined) },
secretBox: { encrypt: spy('enc'), decrypt: spy('dec') },
middleware: {
requireAuth: (req, res, next) => next(),
@@ -95,6 +100,13 @@ function fakeApi() {
extensions: [],
streams: null,
legs: [],
teamProvider: null,
slashCommands: [],
triggers: null,
audiences: null,
eventActions: null,
eventBudgets: null,
eventOptionSources: null,
hooks: {},
}
const called = new Set()
@@ -107,6 +119,34 @@ function fakeApi() {
registerExtension(slot, router) { record.extensions.push({ slot, router }) },
registerNotificationStreams(streams) { once('registerNotificationStreams'); record.streams = streams },
registerAnnounceLeg(leg) { record.legs.push(leg) },
// MODULE_API 1.6.0. `once` because core holds a single provider per
// deployment — a second registration is a collision there, so it has to be
// one here too, or this suite would pass a shape core rejects at load.
registerTeamProvider(provider) { once('registerTeamProvider'); record.teamProvider = provider },
// MODULE_API 1.6.0, live since phase 7. `once` for the same reason core
// takes it: a second call is a module changing its mind halfway through
// register(), which core rejects.
registerSlashCommands(commands) { once('registerSlashCommands'); record.slashCommands = commands },
// MODULE_API 1.7.0, live since ENGAGEMENT.md Phase 11. `once` on both, for
// the reason above: core stages a registrant's whole batch and applies it as
// one, so a second call is a module changing its mind mid-register().
registerEventTriggers(triggers) { once('registerEventTriggers'); record.triggers = triggers },
registerAudiences(audiences) { once('registerAudiences'); record.audiences = audiences },
// MODULE_API 1.9.0 (ENGAGEMENT.md Phase 11b). `once` again, and here it is
// load-bearing rather than tidy: a rule belongs to exactly ONE named group,
// and merging two calls would make "which group is this rule in" — the
// question the one-shot seed guard answers — unanswerable.
registerEngagementSeeds(seeds) { once('registerEngagementSeeds'); record.engagementSeeds = seeds },
// MODULE_API 1.10.0 (EVENTS.md F, EVENTS_PLAN.md Phases 7 and 9). `once` on
// all three, matching core: it stages a registrant's whole batch and applies
// it as one, so a second call is a module changing its mind mid-register().
registerEventActions(actions) { once('registerEventActions'); record.eventActions = actions },
registerEventBudgets(budgets) { once('registerEventBudgets'); record.eventBudgets = budgets },
registerEventOptionSources(sources) { once('registerEventOptionSources'); record.eventOptionSources = sources },
// And the fourth, from Phase 11b. `once` for the same reason, and present here
// for a second one: a verb this module calls and this fake does not have is a
// TypeError in `entry.test.js` rather than a surprise at somebody's boot.
registerEventLeases(leases) { once('registerEventLeases'); record.eventLeases = leases },
onBoot(fn) { once('onBoot'); record.hooks.onBoot = fn },
onShutdown(fn) { once('onShutdown'); record.hooks.onShutdown = fn },
}

View File

@@ -0,0 +1,208 @@
// The bundle check, checked.
//
// `scripts/checkBundle.js` exists because v1.0.0 shipped without
// `server/commands/` and died at the register stage on the operator's box. A
// check written in response to one bug is worth exactly as much as its coverage
// of that bug, so the first two tests below are that bug, in both modes: a list
// that has stopped covering what the entry point reaches, and a tarball with the
// file missing from it.
//
// **Every fixture is a template literal, and that is load-bearing** — the same
// reason checkImports.test.js gives. `scripts/checkImports.js` scans this
// directory too, so an ordinary quoted string holding a relative require would
// make this file fail that check. Templates are blanked by the stripper.
const test = require('node:test')
const assert = require('node:assert')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const {
reachable,
resolveFile,
checkDeclaration,
checkBundle,
declaredServerPaths
} = require('../scripts/checkBundle')
/**
* Write a throwaway module tree: `files` under server/, `bundle` as its
* ci/bundle.json. Returns the module root.
*/
function fixture(files, bundle = { server: ['index.js'] }) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'module-uo-bundle-'))
for (const [name, source] of Object.entries(files)) {
const file = path.join(root, 'server', name)
fs.mkdirSync(path.dirname(file), { recursive: true })
fs.writeFileSync(file, source)
}
fs.mkdirSync(path.join(root, 'ci'), { recursive: true })
fs.writeFileSync(path.join(root, 'ci', 'bundle.json'), JSON.stringify(bundle))
return root
}
const cleanup = (root) => fs.rmSync(root, { recursive: true, force: true })
// ── The regression this script was written for ─────────────────────────────
test('--check catches a directory the include list has stopped covering', () => {
const root = fixture(
{
'index.js': `const g = require('./commands/guild.command')`,
'commands/guild.command.js': `module.exports = {}`
},
{ server: ['index.js'] } // `commands` missing — exactly v1.0.0
)
try {
const { uncovered } = checkDeclaration(root)
assert.strictEqual(uncovered.size, 1)
assert.ok(uncovered.has('server/commands'))
} finally {
cleanup(root)
}
})
test('--bundle catches the file missing from an assembled tarball', () => {
const root = fixture({ 'index.js': `require('./commands/guild.command')` })
try {
const { missing } = checkBundle(root)
assert.strictEqual(missing.length, 1)
assert.strictEqual(missing[0].specifier, './commands/guild.command')
} finally {
cleanup(root)
}
})
// ── It has to reach requires that are not at the top level ─────────────────
test('follows requires written inside a function', () => {
// index.js requires inside `register()` because require order is load-bearing.
// A check that only saw file-scope requires would have missed the real bug.
const root = fixture(
{
'index.js': `module.exports = function register(ctx) { const r = require('./router/a') }`,
'router/a.js': `module.exports = {}`
},
{ server: ['index.js', 'router'] }
)
try {
assert.strictEqual(checkDeclaration(root).uncovered.size, 0)
assert.strictEqual(checkBundle(root).missing.length, 0)
} finally {
cleanup(root)
}
})
test('follows requires transitively, not just one hop', () => {
const root = fixture(
{
'index.js': `require('./a')`,
'a.js': `require('./b')`,
'b.js': `require('./deep/c')`,
'deep/c.js': `module.exports = {}`
},
{ server: ['index.js', 'a.js', 'b.js'] } // `deep` missing
)
try {
const { uncovered } = checkDeclaration(root)
assert.ok(uncovered.has('server/deep'))
} finally {
cleanup(root)
}
})
// ── Resolution has to match Node's, or it invents failures ─────────────────
test('resolves a directory to its index.js', () => {
const root = fixture({ 'index.js': `require('./boot')`, 'boot/index.js': `module.exports = {}` },
{ server: ['index.js', 'boot'] })
try {
assert.strictEqual(checkDeclaration(root).uncovered.size, 0)
} finally {
cleanup(root)
}
})
test('resolves a .json dependency, and does not try to parse it for requires', () => {
const root = fixture({ 'index.js': `require('./data/atlas.json')`, 'data/atlas.json': `{"a":1}` },
{ server: ['index.js', 'data'] })
try {
const { uncovered, missing } = checkDeclaration(root)
assert.strictEqual(missing.length, 0)
assert.strictEqual(uncovered.size, 0)
} finally {
cleanup(root)
}
})
test('survives a require cycle', () => {
const root = fixture({ 'index.js': `require('./a')`, 'a.js': `require('./index')` },
{ server: ['index.js', 'a.js'] })
try {
assert.strictEqual(checkDeclaration(root).uncovered.size, 0)
} finally {
cleanup(root)
}
})
test('a specifier that resolves to nothing is reported, not thrown', () => {
const root = fixture({ 'index.js': `require('./gone')` })
try {
const { missing } = checkDeclaration(root)
assert.strictEqual(missing.length, 1)
assert.strictEqual(missing[0].specifier, './gone')
} finally {
cleanup(root)
}
})
test('prose describing a require is not a require', () => {
// The failure mode checkImports.js hit the first time it ran: index.js's own
// header explains why it must never require express, and comments in this
// repo name module paths constantly.
const root = fixture(
{ 'index.js': `// this file used to require('./commands/gone')\nmodule.exports = 1` },
{ server: ['index.js'] }
)
try {
assert.strictEqual(checkDeclaration(root).missing.length, 0)
} finally {
cleanup(root)
}
})
test('node_modules inside a bundle is npm\'s business, not this check\'s', () => {
const root = fixture({
'index.js': `module.exports = 1`,
'node_modules/ws/index.js': `require('./lib/that-npm-owns')`
})
try {
assert.strictEqual(checkBundle(root).missing.length, 0)
} finally {
cleanup(root)
}
})
// ── And the real repo, which is the check that actually gates a release ────
test('the real ci/bundle.json covers everything the real entry point reaches', () => {
const { uncovered, missing, reached } = checkDeclaration()
assert.deepStrictEqual([...uncovered.keys()], [])
assert.deepStrictEqual(missing, [])
assert.ok(reached > 1, 'the walk should reach more than the entry point itself')
})
test('every path ci/bundle.json declares exists', () => {
// A list naming a path that has moved packs nothing and says nothing — `cp`
// in the release would fail, but only after the tag had been pushed.
for (const p of declaredServerPaths()) {
assert.ok(fs.existsSync(p), `ci/bundle.json names ${p}, which does not exist`)
}
})
test('the entry point is reachable from the declared list', () => {
const entry = path.resolve(__dirname, '..', 'index.js')
assert.ok(reachable(entry).files.includes(entry))
assert.ok(resolveFile(path.dirname(entry), './core'))
})

View File

@@ -0,0 +1,254 @@
// ── The shipped bodies and rules (ENGAGEMENT.md Phase 11b) ─────────────────
//
// `shardEngagement.test.js` proves the mapper produces the right EVENTS. This
// file proves the content shipped alongside them is coherent — which is a
// different failure mode and a quieter one: a rule pointing at a template key
// that does not exist, or a body built around a variable nothing supplies, is
// invisible until somebody enables the rule and a person does not get a mail.
//
// The three properties worth asserting, none of which a hand run would catch:
//
// 1. **Every rule names a trigger this module declares, and a template that
// exists** — its own or core's nine generic keys.
// 2. **Every LABEL a body builds a sentence around is supplied on every path
// that emits its trigger.** This is the one that earns its keep. The
// fragments are declared `required: false` so a missing one can never
// REFUSE an emit — a dropped notification is worse than a cosmetic hole —
// and that leaves nothing at runtime to notice a mapper that forgot one.
// This test is what notices.
// 3. **The plain nine are plain** (decision 9). A security notice drifting
// into the in-universe register is exactly the change nobody would think to
// review, and it is the one with a real cost attached.
const { test, beforeEach } = require('node:test')
const assert = require('node:assert/strict')
const engagement = require('../utils/shardEngagement')
const seeds = require('../config/engagementSeeds')
const { TRIGGERS, TRIGGER_IDS } = require('../config/shardTriggers')
let tracker
beforeEach(() => { tracker = engagement.createTracker() })
const byId = new Map(TRIGGERS.map((t) => [t.id, t]))
// Core's shipped keys, which a module's rule is allowed to name (§4.6.1
// property 1). Spelled out rather than imported: this module cannot require core,
// and a key disappearing from core is exactly the breakage worth failing on.
const CORE_KEYS = new Set(['notify.event', 'inapp.event', 'notify.digest'])
// The nine that stay PLAIN (decision 9): security, infrastructure, staff, admin.
const PLAIN = new Set([
'uo.account.login_failed', 'uo.account.unlinked',
'uo.server.up', 'uo.server.down',
'uo.page.new', 'uo.cheat.detected',
'uo.audit.staff_action', 'uo.economy.milestone', 'uo.world.saved',
])
// ── The shape of the set ───────────────────────────────────────────────────
test('every declared trigger has exactly one rule, and every rule a declared trigger', () => {
const ruled = seeds.RULES.map((r) => r.trigger_id)
assert.equal(new Set(ruled).size, ruled.length, 'no trigger has two rules')
assert.deepEqual([...ruled].sort(), TRIGGERS.map((t) => t.id).sort())
})
test('every rule ships disabled, with a cooldown and a per-hour ceiling', () => {
for (const r of seeds.RULES) {
// `enabled` is not set here at all — the registry forces 0 — so the
// assertion is that nobody added it. Q3's invariant, at the source.
assert.equal(r.enabled, undefined, `${r.trigger_id} does not set enabled`)
assert.ok(Number.isInteger(r.cooldown_seconds), `${r.trigger_id} has a cooldown`)
assert.ok(r.max_sends_per_hour >= 1, `${r.trigger_id} has a per-hour ceiling`)
}
})
test('every template key a rule names exists — its own or core\'s', () => {
const own = new Set(seeds.TEMPLATES.map((t) => t.key))
for (const r of seeds.RULES) {
for (const [channel, key] of Object.entries(r.template_keys)) {
assert.ok(
own.has(key) || CORE_KEYS.has(key),
`${r.trigger_id}.${channel} names "${key}", which is neither ours nor core's`,
)
}
}
})
test('the seventeen in-universe families have both channels; the nine plain ones have neither', () => {
const own = new Set(seeds.TEMPLATES.map((t) => t.key))
let bespoke = 0
for (const r of seeds.RULES) {
const usesOwn = Object.values(r.template_keys).some((k) => own.has(k))
if (PLAIN.has(r.trigger_id)) {
// **Decision 9, as a check.** A security notice written as a letter is
// indistinguishable in register from the phishing mail it warns about.
assert.equal(usesOwn, false, `${r.trigger_id} must stay plain`)
continue
}
bespoke += 1
assert.ok(own.has(r.template_keys.email), `${r.trigger_id} has an in-universe email body`)
// Both channels in the same voice: one rule fires on both at once, and a
// player who reads the inbox item and then the mail must not meet two
// different narrators.
assert.ok(own.has(r.template_keys.inapp), `${r.trigger_id} has an in-universe in-app body`)
// The DIGEST stays core's. A day of events rolled into a list is not a
// letter from anybody.
assert.equal(r.template_keys.digest, 'notify.digest', `${r.trigger_id} digests generically`)
}
// Eighteen since protocol 6: the champion FALLS, in the same crier's voice as
// the champion walking, because they are one story told in two mails.
assert.equal(bespoke, 18)
assert.equal(seeds.TEMPLATES.length, 36)
})
test('a template key is core\'s grammar — dots and hyphens, never an underscore', () => {
// `uo.champ.boss_up` is a legal TRIGGER id and an illegal TEMPLATE key, which
// is a genuinely confusing pair and the reason this is asserted rather than
// remembered. Caught at registration too, as a boot failure.
const KEY = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/
for (const t of seeds.TEMPLATES) {
assert.ok(KEY.test(t.key), `${t.key} matches core's template-key grammar`)
assert.ok(t.key.startsWith('uo.'), `${t.key} is namespaced`)
assert.ok(TRIGGER_IDS.has(t.triggerId), `${t.key} binds a declared trigger`)
}
})
test('an email body has a subject and an in-app body has none', () => {
for (const t of seeds.TEMPLATES) {
if (t.channel === 'email') assert.ok(t.subject, `${t.key} has a subject`)
else assert.equal(t.subject, null, `${t.key} leaves the email column NULL`)
}
})
test('no body names a brand, a colour or a logo (§4.6.1 property 2)', () => {
// One prebuilt image mails as any shard. An in-universe body is UO-specific
// and must still be shard-agnostic.
const json = JSON.stringify(seeds.TEMPLATES)
for (const forbidden of ['#', 'UOMysticmoon', 'http://', 'https://']) {
assert.equal(json.includes(forbidden), false, `no body contains "${forbidden}"`)
}
})
// ── The property the render sweep needed ───────────────────────────────────
// Every LABEL — the fragments a sentence is built AROUND, as opposed to the
// trailing ones that may legitimately be empty. A frame that exercises each.
const LABELLED = [
['uo.house.idoc_warning', ['houseLabel', 'stageLabel'],
{ kind: 'house.decay', serial: '0x40012345', to: 'GREATLY', from: 'FAIRLY', ownerAcct: 'darrow' }],
['uo.house.collapsed', ['houseLabel'],
{ kind: 'house.decay', serial: '0x40012345', to: 'COLLAPSED', ownerAcct: 'darrow' }],
['uo.vendor.sale', ['shopLabel', 'itemLine'],
{ kind: 'vendor.sale', vendorSerial: '0x1', itemType: 'Iron Ingot', price: 100, ownerAcct: 'darrow' }],
['uo.points.rank_changed', ['boardLabel', 'standingLine'],
{ kind: 'points.board', system: 'Virtue', top: [{ rank: 1, serial: '0x9', name: 'Darrow' }] }],
// `autoPickWhen` is a label in the same sense: "Attend before {{autoPickWhen}}"
// has a hole in it without one. It is `required: false` like the others and
// guaranteed by the mapper's own guard — `uo.election.opened` is not emitted at
// all unless the frame carried `autoPickAt`.
['uo.election.opened', ['phaseLabel', 'autoPickWhen'],
{ kind: 'city.update', city: 'Britain', electionPhase: 'nominate', autoPickAt: '2026-09-04T00:00:00Z' }],
['uo.house.refreshed', ['houseLabel'],
{ kind: 'house.decay', serial: '0x40012345', to: 'LIKENEW', from: 'GREATLY', ownerAcct: 'darrow' }],
]
test('every label a body builds a sentence around is supplied by the mapper', () => {
for (const [triggerId, labels, frame] of LABELLED) {
// A first frame is never a transition, so the upsert kinds need a prior one.
engagement.mapShardEvent(
{ ...frame, top: frame.top && [{ rank: 1, serial: '0x0', name: 'Mireille' }], electionPhase: frame.electionPhase && 'none' },
tracker,
)
const targets = engagement.mapShardEvent(frame, tracker)
const target = targets.find((t) => t.triggerId === triggerId)
assert.ok(target, `${triggerId} fired`)
for (const label of labels) {
assert.ok(
target.data[label] !== undefined && target.data[label] !== '',
`${triggerId} supplies ${label} — a body builds a sentence around it`,
)
}
}
})
test('a label is supplied even when every optional field is absent', () => {
// The case the render sweep modelled: a v4 overlay, a house with no name and
// no region. `houseLabel` falls back to the seal number, which is worse prose
// and better than "Be it known that , recorded to thy name".
const target = engagement.mapShardEvent(
{ kind: 'house.decay', serial: '0x40012345', to: 'IDOC', ownerAcct: 'darrow' },
tracker,
)[0]
assert.match(target.data.houseLabel, /0x40012345/)
assert.equal(target.data.stageLabel, 'in imminent danger of collapse')
// The detail line names only what the frame carried — "Recorded at: ." is the
// shape this avoids. The stage is always there, so the line is too; a house
// with no coordinates simply does not get the "Recorded at" half.
assert.equal(target.data.whereLine, 'Stage entered: IDOC.')
})
test('a detail line names only the parts the frame actually carried', () => {
engagement.mapShardEvent({ kind: 'vendor.listing', serial: '0x1', ownerAcct: 'd', fees: { exempt: true } }, tracker)
const at = new Date(Date.now() + 3600_000).toISOString()
const target = engagement.mapShardEvent(
{ kind: 'vendor.listing', serial: '0x1', ownerAcct: 'd', shopName: 'The Anvil', fees: { dismissalAt: at, funds: 1200 } },
tracker,
)[0]
assert.equal(target.triggerId, 'uo.vendor.expiring')
assert.match(target.data.ledgerLine, /On hand: 1200 gold/)
assert.equal(target.data.ledgerLine.includes('Charged each period'), false)
})
// ── Trailing fragments ─────────────────────────────────────────────────────
test('a trailing fragment leads with its own space, or is absent entirely', () => {
// `{{slainBy}}.` must close as "has fallen." with no fragment and
// "has fallen at the hands of a lich lord." with one. A fragment that forgot
// its leading space produces "has fallenat the hands of" and nothing would
// notice.
const withKiller = engagement.mapShardEvent(
{ kind: 'player.death', who: { name: 'Darrow', acct: 'darrow' }, killer: { name: 'a lich lord' } },
tracker,
)[0]
assert.equal(withKiller.data.slainBy, ' at the hands of a lich lord')
const without = engagement.mapShardEvent(
{ kind: 'player.death', who: { name: 'Darrow', acct: 'darrow' } },
tracker,
)[0]
assert.equal(without.data.slainBy, undefined)
})
test('every declared fragment carries an example that shows its own shape', () => {
// The `example` is what the template editor previews and test-sends with, so a
// trailing fragment whose example omits the leading space teaches an author the
// wrong thing about where to put one.
const TRAILING = ['slainBy', 'atPlace', 'inSuccessionTo', 'candidateNote', 'damagerNote']
for (const t of TRIGGERS) {
for (const v of t.variables.filter((x) => TRAILING.includes(x.name))) {
assert.ok(v.example.startsWith(' '), `${t.id}.${v.name} example leads with its space`)
}
}
})
// ── The group key ──────────────────────────────────────────────────────────
test('one rule group, and appending to it later would reach fresh installs only', () => {
// A group is seeded ONCE under its own settings guard, which is 11a's seed-key
// finding as a mechanism. This assertion exists so that adding a twenty-sixth
// rule has to edit a test whose name says what appending costs.
// TWO groups since protocol 6, and the second one is this test's whole point
// made concrete: `uo.champ.boss_killed` could not be appended to `triggers-v1`,
// because a deployment that has already stamped that key would never have
// received it. A new rule gets a new key.
assert.equal(seeds.RULE_GROUPS.length, 2)
assert.equal(seeds.RULE_GROUPS[0].key, 'triggers-v1')
assert.equal(seeds.RULE_GROUPS[0].rules.length, 26)
assert.equal(seeds.RULE_GROUPS[1].key, 'champ-boss-killed-v1')
assert.deepEqual(seeds.RULE_GROUPS[1].rules.map((r) => r.trigger_id), ['uo.champ.boss_killed'])
// No rule belongs to two groups, and between them they are the whole set.
const grouped = seeds.RULE_GROUPS.flatMap((g) => g.rules.map((r) => r.trigger_id))
assert.equal(new Set(grouped).size, grouped.length)
assert.deepEqual([...grouped].sort(), seeds.RULES.map((r) => r.trigger_id).sort())
})

View File

@@ -53,6 +53,81 @@ test('registers exactly what module.json declares', () => {
assert.deepStrictEqual(api.record.extensions.map((e) => e.slot), manifest.extensions)
assert.deepStrictEqual(api.record.legs.map((l) => l.leg), ['towncrier'])
// The event contract (MODULE_API 1.10.0, EVENTS_PLAN.md Phase 9). Asserted
// here rather than only in the actions' own suite because registration is the
// half that can silently not happen: a declaration file nothing calls is a
// deployment whose event authors simply never see the verbs, with no error
// anywhere.
assert.deepStrictEqual(
api.record.eventActions.map((a) => a.id).sort(),
[
'uo.boss.spawn',
'uo.broadcast',
'uo.creature.spawn',
'uo.decor.place',
'uo.gate.open',
'uo.item.grant',
'uo.news.post',
'uo.npc.place',
'uo.participation.collect',
'uo.participation.open',
'uo.towncrier.post',
'uo.world.save',
],
)
// Phase 12a's five are all the MODULE's dimensions, never core's (org lead,
// 2026-09-07): core meters whatever a module declares and knows nothing about
// Ultima Online. Asserted as an ordered list because the order is the order
// an author meets them in a cap meter.
assert.deepStrictEqual(api.record.eventBudgets.map((b) => b.id), [
'uo.broadcasts',
'uo.creatures',
'uo.bosses',
'uo.npcs',
'uo.decor',
'uo.gate.minutes',
'uo.rewards',
])
// Phase 11b. One key, because ServUO has almost no others: of the 158 non-Bridge
// `Config.Get` call sites in `Scripts/`, roughly eight are read live, and a lease
// on any of the rest applies cleanly and does nothing.
// Phase 12b adds five TARGETED leases beside it -- a key that names a capability
// over many things, with the target supplied per step. Four spawner properties
// (`MaxCount`, not the `Amount` EVENTS_PLAN.md named: there is no such property
// on ServUO 57.4) and the seasonal status, which is a three-value enum over eight
// events rather than the nine-value one section G described.
assert.deepStrictEqual(api.record.eventLeases.map((l) => l.id), [
'uo.playercaps.skillcap',
'uo.spawner.maxcount',
'uo.spawner.mindelay',
'uo.spawner.maxdelay',
'uo.spawner.running',
'uo.seasonal.status',
])
// Only the targeted ones declare a target, and every one of them names a source:
// a target field with no list behind it is the free-text box the option-source
// contract exists to replace.
for (const lease of api.record.eventLeases) {
if (lease.id === 'uo.playercaps.skillcap') {
assert.strictEqual(lease.target, undefined, 'a config lease has no target')
continue
}
assert.ok(lease.target && lease.target.label, `${lease.id} has no target label`)
assert.ok(lease.target.source, `${lease.id} has no target source`)
}
assert.deepStrictEqual(
api.record.eventOptionSources.map((s) => s.id).sort(),
[
'uo.options.creatures',
'uo.options.decor',
'uo.options.items',
'uo.options.landmarks',
'uo.options.regions',
'uo.options.seasonal',
'uo.options.spawners',
],
)
assert.ok(api.record.streams.length > 0)
assert.strictEqual(typeof api.record.hooks.onBoot, 'function')
assert.strictEqual(typeof api.record.hooks.onShutdown, 'function')
@@ -85,6 +160,35 @@ test('every registered stream is namespaced or grandfathered', () => {
}
})
test('registers a Team provider with all three methods', () => {
// Core requires all three: a provider that could list Teams but not their
// members would leave core holding Teams it can never populate, which is not
// the same as a call that fails. Asserted here so a refactor that drops one
// fails in this suite rather than at load on an operator's install.
const api = fakeApi()
register(fakeCtx(), api)
const provider = api.record.teamProvider
assert.ok(provider, 'a UO guild is a Team; something has to answer for them')
for (const method of ['getTeams', 'getTeamMembers', 'getTeamLeaders']) {
assert.strictEqual(typeof provider[method], 'function', `${method} is missing`)
}
})
test('registration does not call the provider, or touch the database', async () => {
// register() runs while core's app.js is still being required, with the pool
// pointed at a dead port — routeManifest.js and swagger.js both depend on that.
// Registration is a CLAIM; core does not ask anything until it reconciles,
// which is after onBoot.
const ctx = fakeCtx()
let queried = false
const frozen = Object.freeze({ ...ctx, db: Object.freeze({ query: async () => { queried = true; return [] } }) })
const api = fakeApi()
register(frozen, api)
assert.equal(queried, false, 'a query at registration time would hang the manifest and the spec build')
})
test('takes a frozen ctx and does not try to write to it', () => {
const ctx = fakeCtx()
assert.ok(Object.isFrozen(ctx))

View File

@@ -0,0 +1,148 @@
// `/guild` — the chat command registered through `api.registerSlashCommands`
// (TEAMS.md §7.1, MODULE_API 1.6.0).
//
// The properties worth pinning are all about the ANSWER being the same answer
// the website gives, because that is the whole risk of a second surface: the
// audience rungs are re-resolved here rather than assumed, the shard's own
// offline guard is honoured, and the link prompt appears only when linking would
// actually change what the caller is told.
const { test, afterEach } = require('node:test')
const assert = require('node:assert/strict')
const command = require('../commands/guild.command')
const db = require('../model/teamProvider/teamProvider.db')
const provider = require('../model/teamProvider/teamProvider.model')
const visibility = require('../utils/shardVisibility')
const originals = {
getConfig: visibility.getConfig,
viewerLevel: visibility.viewerLevel,
boardIsCurrent: provider.boardIsCurrent,
listGuilds: db.listGuilds,
listGuildMembers: db.listGuildMembers,
}
afterEach(() => {
visibility.getConfig = originals.getConfig
visibility.viewerLevel = originals.viewerLevel
provider.boardIsCurrent = originals.boardIsCurrent
db.listGuilds = originals.listGuilds
db.listGuildMembers = originals.listGuildMembers
})
const GUILDS = [
{ id: 7, name: 'Knights of the Codex', abbr: 'KOC', alliance: 'The Accord', members: 12, online: 3, leader_name: 'Dain' },
{ id: 9, name: 'Knights Hospitaller', abbr: 'KH', alliance: null, members: 4, online: 0, leader_name: null },
]
const MEMBERS = [
{ serial: 1, name: 'Dain', rank: 4, web_id: '31', linked_user_id: null },
{ serial: 2, name: 'Elowen', rank: 4, web_id: null, linked_user_id: 44 },
{ serial: 3, name: 'Wat', rank: 2, web_id: null, linked_user_id: null },
]
function stub({ audience = 'anonymous', enabled = true, level = 'anonymous', current = true } = {}) {
visibility.getConfig = async () => ({ guilds: { enabled, audience } })
visibility.viewerLevel = async () => level
provider.boardIsCurrent = async () => (current ? { ok: true } : { ok: false, reason: 'socket down' })
db.listGuilds = async () => GUILDS
db.listGuildMembers = async () => MEMBERS
}
const anonymous = { platform: 'discord', platformUserId: '1', userId: null, role: null, isLinked: false, isStaff: false }
const linked = { platform: 'discord', platformUserId: '2', userId: 31, role: 'player', isLinked: true, isStaff: false }
test('the definition stays inside the option schema §7.1.1 allows', () => {
assert.equal(command.name, 'guild')
assert.equal(command.access, 'everyone')
for (const option of command.options) {
assert.ok(['string', 'integer', 'boolean', 'user'].includes(option.type))
assert.ok(option.description.length <= 100)
}
})
test('the guilds feature being off withholds everything, staff included', async () => {
stub({ enabled: false, level: 'admin' })
const res = await command.handler({ options: {}, actor: { ...linked, role: 'admin', isStaff: true } })
assert.match(res.text, /does not publish guild information/)
assert.equal(res.ephemeral, true)
})
// The reason this command is not a thin wrapper over a public route: a rung
// below the feature's audience must be refused HERE, or a shard that gates
// guilds to staff would publish them to a Discord channel.
test('a caller below the feature audience is refused', async () => {
stub({ audience: 'staff', level: 'anonymous' })
const res = await command.handler({ options: {}, actor: anonymous })
assert.match(res.text, /not shown to your account/)
assert.equal(res.ephemeral, true)
})
test('an unlinked caller is invited to link — but only when linking would change the answer', async () => {
stub({ audience: 'player', level: 'anonymous' })
const gated = await command.handler({ options: {}, actor: anonymous })
assert.match(gated.notice, /Link your account/)
// Public guilds: there is nothing more to see, so there is nothing to prompt.
stub({ audience: 'anonymous', level: 'anonymous' })
const open = await command.handler({ options: {}, actor: anonymous })
assert.equal(open.notice, null)
// Gated to staff: linking reaches `player` and stops there, so the invitation
// would be an instruction to do something that changes nothing. Found on the
// live rig, where a staff-gated shard still offered it.
stub({ audience: 'staff', level: 'anonymous' })
const unreachable = await command.handler({ options: {}, actor: anonymous })
assert.match(unreachable.text, /not shown to your account/)
assert.equal(unreachable.notice, null)
})
test('a stale board answers offline rather than reporting what it still holds', async () => {
stub({ current: false })
const res = await command.handler({ options: {}, actor: anonymous })
assert.match(res.text, /not connected right now/)
})
test('no argument lists the largest guilds', async () => {
stub()
const res = await command.handler({ options: {}, actor: anonymous })
assert.equal(res.title, 'Guilds on this shard')
assert.equal(res.fields.length, 2)
assert.match(res.fields[0].name, /Knights of the Codex/)
assert.match(res.fields[0].value, /12 members · 3 online/)
})
test('a name resolves by abbreviation, then exactly, then by unique prefix', async () => {
stub()
const byAbbr = await command.handler({ options: { name: 'koc' }, actor: anonymous })
assert.match(byAbbr.title, /Knights of the Codex/)
const exact = await command.handler({ options: { name: 'Knights Hospitaller' }, actor: anonymous })
assert.match(exact.title, /Hospitaller/)
// "knights" hits both, and answering with either would be worse than asking.
const ambiguous = await command.handler({ options: { name: 'knights' }, actor: anonymous })
assert.match(ambiguous.text, /Several guilds match/)
assert.equal(ambiguous.ephemeral, true)
})
test('a miss is an answer, not a failure', async () => {
stub()
const res = await command.handler({ options: { name: 'nobody' }, actor: anonymous })
assert.match(res.text, /No guild matches/)
})
// `linked` counts BOTH sources the roster uses — the shard's asserted web id and
// the link table — because that is what "linked" means everywhere else here.
test('the detail carries the counts, the leaders and a link to the module page', async () => {
stub({ level: 'player' })
const res = await command.handler({ options: { name: 'KOC' }, actor: linked })
const field = (name) => res.fields.find((f) => f.name === name).value
assert.equal(field('Members'), '12')
assert.equal(field('Online'), '3')
assert.equal(field('Linked accounts'), '2')
assert.equal(field('Leaders'), 'Dain, Elowen')
assert.match(res.url, /\/uo\/guilds\/7$/)
assert.equal(res.notice, null)
})

View File

@@ -121,7 +121,11 @@ test('every table this fragment declares is prefixed shard_ or uo_link_', () =>
// ── The settings rows this module owns ──────────────────────────────────────
const SETTINGS_KEYS = ['game_account_signup', 'uo_link_protocol_3_migrated']
const SETTINGS_KEYS = [
'game_account_signup',
'uo_link_protocol_3_migrated',
'uo_link_protocol_4_migrated',
]
test('both settings seeds are INSERT IGNORE, so a replay never resets a value', () => {
for (const key of SETTINGS_KEYS) {
@@ -131,6 +135,95 @@ test('both settings seeds are INSERT IGNORE, so a replay never resets a value',
}
})
// ── The protocol pin ────────────────────────────────────────────────────────
//
// Two declaration sites and one constant have to agree, and for a while they did
// not: the protocol-4 cutover moved `link`, the overlay and this module's ingest,
// and left both pins here at 3. A fresh install then spoke 3 to a protocol-4
// sidecar, which 409s every REST call — an install that reads nothing from its
// shard, with the cause only in the log. These tests are the guard.
// The protocol this build speaks, read from the model rather than written here.
//
// Hardcoding the number in this test is what the protocol-4 bug looked like from the
// other side: the emitters moved, one declaration site did not, and every site agreed
// with itself. Reading DEFAULT_PROTOCOL makes the assertion "the three declarations
// AGREE" rather than "they all say 4", so a bump that misses one of them fails here
// instead of on an operator's install.
const { DEFAULT_PROTOCOL } = require('../model/uoLinkConfig/uoLinkConfig.model')
test('the column default pins the protocol this build speaks', () => {
assert.ok(Number.isInteger(DEFAULT_PROTOCOL) && DEFAULT_PROTOCOL > 0, 'no protocol pin exported')
const create = statements.find((s) => /CREATE TABLE.*uo_link_config/is.test(s))
assert.ok(create, 'uo_link_config is gone')
assert.match(
create,
new RegExp('protocol +INT +NOT NULL DEFAULT ' + DEFAULT_PROTOCOL + '(?![0-9])', 'i'),
'the CREATE TABLE default must name the protocol this build speaks',
)
// The last MODIFY wins on replay, so it is the one that decides an existing
// database's default.
const modifies = statements.filter((s) =>
/^ALTER TABLE\s+uo_link_config\s+MODIFY COLUMN protocol/i.test(s),
)
assert.ok(modifies.length > 0, 'the default-fixing MODIFY is gone')
assert.match(
modifies[modifies.length - 1],
new RegExp('DEFAULT ' + DEFAULT_PROTOCOL + '(?![0-9])', 'i'),
)
})
// The one-shot migration for the CURRENT protocol, whatever it is. Same argument as
// above: these three assertions used to be written once per version by hand, so the
// version that mattered — the newest — was the one with no test until someone
// remembered to copy the block.
test('the current protocol has a one-shot migration, correctly ordered and guarded', () => {
const marker = `uo_link_protocol_${DEFAULT_PROTOCOL}_migrated`
const update = statements.findIndex(
(s) => /^UPDATE\s+uo_link_config/i.test(s) && s.includes(marker),
)
const insert = statements.findIndex((s) => /^INSERT/i.test(s) && s.includes(`'${marker}'`))
assert.ok(update >= 0, `no migration to protocol ${DEFAULT_PROTOCOL}`)
assert.ok(insert >= 0, `no one-shot marker for protocol ${DEFAULT_PROTOCOL}`)
assert.ok(insert > update, 'the marker is written before the UPDATE reads it')
// `protocol < N`, never `= N-1`: an install that missed an earlier migration has to
// be carried the whole way rather than one step.
assert.match(
statements[update],
new RegExp('protocol *< *' + DEFAULT_PROTOCOL + '(?![0-9])'),
)
})
test('the protocol-4 marker is written AFTER the update that reads it', () => {
const update = statements.findIndex(
(s) => /^UPDATE\s+uo_link_config/i.test(s) && s.includes('uo_link_protocol_4_migrated'),
)
const marker = statements.findIndex(
(s) => /^INSERT/i.test(s) && s.includes("'uo_link_protocol_4_migrated'"),
)
assert.ok(update >= 0, 'the protocol-4 migration is gone')
assert.ok(marker >= 0, 'the one-shot marker is gone')
assert.ok(marker > update, 'the marker is written before the UPDATE reads it')
})
test('the protocol-4 one-shot carries an install forward from any older pin', () => {
const update = statements.find(
(s) => /^UPDATE\s+uo_link_config/i.test(s) && s.includes('uo_link_protocol_4_migrated'),
)
assert.match(
update,
/protocol\s*<\s*4/,
'must be `protocol < 4`, not `= 3`: an install that never took the protocol-3 ' +
'migration has to be carried the whole way rather than one step',
)
})
test('the protocol-3 marker is written AFTER the update that reads it', () => {
const update = statements.findIndex(
(s) => /^UPDATE\s+uo_link_config/i.test(s) && s.includes('uo_link_protocol_3_migrated'),

View File

@@ -0,0 +1,726 @@
// ── The wire-kind → engagement-trigger mapper (ENGAGEMENT.md Phase 11) ─────
//
// Two halves, tested separately for the reason the file splits them: `mapShardEvent`
// is pure given a tracker and needs no database, and `fromShardEvent` is the half
// that resolves an account into a person and therefore does.
//
// What is asserted here is deliberately not "each field is copied". It is the
// three things a rule cannot express and a plain mapping would get wrong —
// transitions, thresholds, and who an event is ABOUT — plus the four places §8.6
// or the protocol docs say the obvious implementation is the wrong one.
const { test, beforeEach } = require('node:test')
const assert = require('node:assert/strict')
const engagement = require('../utils/shardEngagement')
const { TRIGGERS, TRIGGER_IDS } = require('../config/shardTriggers')
const { PATHS } = require('../config/clientPaths')
let tracker
beforeEach(() => { tracker = engagement.createTracker() })
const map = (event) => engagement.mapShardEvent(event, tracker)
const ids = (event) => map(event).map((t) => t.triggerId)
const one = (event) => {
const out = map(event)
assert.equal(out.length, 1, `expected exactly one target, got ${out.length}`)
return out[0]
}
// ── The catalogue itself ───────────────────────────────────────────────────
test('the declared set is the one ENGAGEMENT.md §8.6 commits to, carve-outs included', () => {
// 27 since protocol 6: `uo.champ.boss_killed` joins the twenty-six §8.6 named.
// It is not one of the four carve-outs below being reinstated — it is a row the
// catalogue could not have, because until protocol 6 the wire had no kind for a
// boss defeat and the inference from `champ.update` was not good enough to mail.
assert.equal(TRIGGERS.length, 27)
// The four rows that do NOT ship, each with its reason recorded in §8.6. This
// assertion is the guard on the carve-outs: adding one back is a decision, and
// a decision should have to edit a test that says so.
for (const carved of [
'uo.market.item_listed', // a saved SEARCH; no per-user query store exists
'uo.guild.joined', // core's team.member.joined already fires for it
'uo.link.requested', // no addressable recipient, and a ~5-minute TTL
]) {
assert.equal(TRIGGER_IDS.has(carved), false, `${carved} is carved out`)
}
// Every id is this module's, which is what `namespaced()` enforces at
// registration — asserted here too so the failure names the id rather than
// arriving as a boot error.
for (const t of TRIGGERS) assert.ok(t.id.startsWith('uo.'), `${t.id} is namespaced`)
})
test('every variable carries an example, because a template is previewed with it', () => {
for (const t of TRIGGERS) {
for (const v of t.variables) {
assert.ok(v.example !== undefined && v.example !== '', `${t.id}.${v.name} has an example`)
assert.ok(v.description, `${t.id}.${v.name} has a description`)
}
// A subjectKey that is not one of the trigger's own variables is refused at
// registration; catching it here names the trigger instead of the boot.
if (t.subjectKey) {
assert.ok(
t.variables.some((v) => v.name === t.subjectKey),
`${t.id} subjectKey "${t.subjectKey}" is one of its variables`,
)
}
}
})
test('a url variable is site-RELATIVE — an absolute one ends up in an href', () => {
for (const t of TRIGGERS) {
for (const v of t.variables.filter((x) => x.type === 'url')) {
assert.ok(v.example.startsWith('/'), `${t.id}.${v.name} example is rooted`)
// Not protocol-relative: `//evil.test/x` passes an "is it rooted" check.
assert.ok(!v.example.startsWith('//'), `${t.id}.${v.name} is not protocol-relative`)
}
}
})
test('a url example names a route this module actually mounts', () => {
// Phase 11b's live walk. Every `url` example read `/shard/…` — module.json's
// `mounts` — and the client router prefixes a module's routes with its **ID**
// (`registry.registerRoutes`), so every one of them was a 404. It matters twice
// over: the example is what the template editor previews and test-sends with,
// and `clientPaths.js` is now the single place both it and the bodies read.
const known = new Set(Object.values(PATHS))
for (const t of TRIGGERS) {
for (const v of t.variables.filter((x) => x.type === 'url')) {
// A parameterised path (`/uo/guilds/1042`) is legal; its PARENT must be known.
const parent = v.example.replace(/\/[^/]+$/, '')
assert.ok(
known.has(v.example) || known.has(parent),
`${t.id}.${v.name} example "${v.example}" is not a route this module mounts`,
)
}
}
})
test('every url variable a body can interpolate is actually SUPPLIED', () => {
// The defect this exists for is invisible in the source and invisible in a
// fixture: a declared-but-never-populated optional interpolates to the empty
// string, so the letter renders perfectly and its call-to-action button has no
// href. Nine of the sixteen in-universe bodies shipped that way.
//
// Driven off the DECLARATIONS rather than a hand list, so the next url variable
// added is covered the day it is declared.
const frames = {
'uo.house.idoc_warning': DECAY,
'uo.house.refreshed': { ...DECAY, from: 'Greatly', to: 'LikeNew' },
'uo.vendor.expiring': listing(FEES(20)),
'uo.guild.left': { kind: 'guild.leave', id: 1042, name: 'The Silver Hand', who: '0x77' },
// Two frames each: an upsert kind is never a transition on FIRST sight, so
// the tracker has to see a baseline before the change means anything.
'uo.governor.elected': [city(), city({ governor: { serial: '0x1FB', name: 'Darrow', acct: 'seed_002' } })],
'uo.governor.appointed': [city(), city({ governor: { serial: '0x1FB', name: 'Darrow', acct: 'seed_002' } })],
'uo.election.opened': [city(), city({ electionPhase: 'nominate', autoPickAt: inHours(48), candidates: 2 })],
'uo.champ.started': [champ({ active: false }), champ({ active: true })],
'uo.champ.boss_up': [champ({ bossUp: false }), champ({ bossUp: true })],
// Protocol 6. A single frame, unlike its two neighbours: a defeat is an
// EVENT on the wire rather than a change spotted between two snapshots, which
// is the whole reason the kind was worth a protocol bump.
'uo.champ.boss_killed': bossKilled(),
'uo.server.up': { kind: 'server.hello', shard: 'Rig' },
'uo.server.down': { kind: 'server.shutdown' },
'uo.page.new': { kind: 'page.new', type: 'Bug', sender: { name: 'Darrow' }, message: 'stuck' },
'uo.economy.milestone': [supply(50_000_000), supply(300_000_000)],
}
for (const t of TRIGGERS) {
const urls = t.variables.filter((v) => v.type === 'url')
if (!urls.length) continue
const frame = frames[t.id]
assert.ok(frame, `${t.id} declares a url variable and this test has no frame for it`)
const fresh = engagement.createTracker()
let target = null
for (const f of Array.isArray(frame) ? frame : [frame]) {
const hit = engagement.mapShardEvent(f, fresh).find((x) => x.triggerId === t.id)
if (hit) target = hit
}
assert.ok(target, `${t.id} did not fire for its frame`)
for (const v of urls) {
assert.ok(target.data[v.name], `${t.id}.${v.name} is declared but never supplied`)
assert.ok(String(target.data[v.name]).startsWith('/'), `${t.id}.${v.name} is site-relative`)
}
}
})
// The declaration that the whole ceiling lattice exists for.
test('uo.cheat.detected ceilings at staff and NEVER at owner', () => {
const cheat = TRIGGERS.find((t) => t.id === 'uo.cheat.detected')
assert.equal(cheat.ceiling, 'staff')
assert.equal(cheat.audience, 'staff')
// The three operator-facing ones sit a rung lower still: `staff` means admin,
// editor AND moderator, so a digest of what moderators did must not ceiling there.
for (const id of ['uo.audit.staff_action', 'uo.economy.milestone', 'uo.world.saved']) {
assert.equal(TRIGGERS.find((t) => t.id === id).ceiling, 'admin', `${id} ceilings at admin`)
}
})
// ── Houses ─────────────────────────────────────────────────────────────────
const DECAY = {
kind: 'house.decay',
serial: '0x400142F9',
from: 'Fairly',
to: 'Greatly',
name: 'Millrace',
ownerAcct: 'seed_002',
region: 'Britain',
map: 'Felucca',
x: 1480,
y: 1600,
lastRefreshed: '2026-08-25T17:21:14Z',
}
test('a late decay stage warns the owner; an early one says nothing', () => {
const t = one(DECAY)
assert.equal(t.triggerId, 'uo.house.idoc_warning')
assert.equal(t.ownerAccount, 'seed_002')
assert.equal(t.data.stage, 'Greatly')
assert.equal(t.data.location, 'Felucca 1480, 1600 (Britain)')
// An EARLY stage says nothing — a house drifting from Slightly to Somewhat is
// not news, and mailing it would make the warning worthless.
assert.deepEqual(ids({ ...DECAY, to: 'Slightly' }), [])
})
test('a refresh is its own trigger, and it is what cancels the warning', () => {
// Phase 11b decision 11. Until this branch existed a refresh reached the engine
// as SILENCE, so `uo.house.idoc_warning`'s 900-second delay had nothing to be
// cancelled by and was simply a late mail (§4.2a). Nothing on the wire changed:
// the decay sweep has always emitted this transition.
const t = one({ ...DECAY, from: 'Greatly', to: 'LikeNew' })
assert.equal(t.triggerId, 'uo.house.refreshed')
assert.equal(t.ownerAccount, 'seed_002')
// The SAME subject as the warning it cancels — `outboxDb.cancel` matches on
// (rule, subject_key), so a different one would cancel nothing.
assert.equal(t.data.houseSerial, one(DECAY).data.houseSerial)
assert.equal(t.data.previousStage, 'Greatly')
// A TRAILING fragment: its own leading space, and empty rather than reading
// "It stood in decay." when the previous stage has no word of its own.
assert.equal(t.data.fromLine, ' It stood greatly worn.')
assert.equal(one({ ...DECAY, from: 'Somewhat', to: 'LikeNew' }).data.fromLine, undefined)
})
test('the v5 schedule rides along when present and is simply absent when not', () => {
const withSchedule = one({
...DECAY,
schedule: {
dynamicDecay: true,
nextStage: '2026-09-01T20:33:15Z',
estimatedCollapse: '2026-09-06T20:33:15Z',
},
})
assert.equal(withSchedule.data.nextStage, '2026-09-01T20:33:15Z')
assert.equal(withSchedule.data.estimatedCollapse, '2026-09-06T20:33:15Z')
// **A dynamic-decay shard omits `estimatedCollapse` at every stage before
// IDOC, and a v4 overlay omits the whole block.** `docs/link/v5.md` is explicit
// that absence means "not knowable", never "not yet read" — so the mapper must
// pass the absence through rather than computing a fallback, which would
// republish exactly the guess the shard refused to make.
const dynamic = one({ ...DECAY, schedule: { dynamicDecay: true, nextStage: '2026-09-01T20:33:15Z' } })
assert.equal(dynamic.data.nextStage, '2026-09-01T20:33:15Z')
assert.equal('estimatedCollapse' in dynamic.data, false)
const v4 = one(DECAY)
assert.equal('nextStage' in v4.data, false)
assert.equal('estimatedCollapse' in v4.data, false)
})
test('Collapsed is its own trigger, not a louder warning', () => {
const t = one({ ...DECAY, to: 'Collapsed' })
assert.equal(t.triggerId, 'uo.house.collapsed')
assert.equal(t.ownerAccount, 'seed_002')
})
test('house.remove carries only a serial, so the owner is looked up later', () => {
const t = one({ kind: 'house.remove', serial: '0x400142F9' })
assert.equal(t.triggerId, 'uo.house.collapsed')
assert.equal(t.ownerAccount, undefined)
assert.equal(t.houseSerial, '0x400142F9')
})
// ── Vendors: the threshold, and the two ways there is nothing to warn about ──
const listing = (fees) => ({
kind: 'vendor.listing',
serial: '0x40001234',
shopName: "Darrow's Bargains",
ownerAcct: 'darrow_acct',
location: { map: 'Trammel', x: 1421, y: 1699, region: 'Britain' },
...(fees === undefined ? {} : { fees }),
})
const inHours = (h) => new Date(Date.now() + h * 3_600_000).toISOString()
const FEES = (h) => ({
exempt: false,
newVendorSystem: true,
chargePerPeriod: 10548,
funds: 8204,
payIntervalSec: 86400,
periodsRemaining: 1,
dismissalAt: inHours(h),
})
test('a vendor entering the warning window fires ONCE, not on every sweep frame', () => {
// `vendor.listing` is re-emitted on any price change, so without the crossing
// check a vendor inside the window mails its owner every time somebody
// reprices a longsword.
// 20.5 rather than 20, because `hoursRemaining` FLOORS a live clock: at a whole
// number the answer is 20 or 19 depending on whether a millisecond has passed
// since the fixture was built, and this assertion was flaking on exactly that.
const first = one(listing(FEES(20.5)))
assert.equal(first.triggerId, 'uo.vendor.expiring')
assert.equal(first.ownerAccount, 'darrow_acct')
assert.equal(first.data.hoursRemaining, 20)
assert.deepEqual(ids(listing(FEES(19))), [])
assert.deepEqual(ids(listing(FEES(18))), [])
})
test('a deposit that leaves the window re-arms the warning', () => {
assert.deepEqual(ids(listing(FEES(20))), ['uo.vendor.expiring'])
assert.deepEqual(ids(listing(FEES(400))), []) // paid up — out of the window
assert.deepEqual(ids(listing(FEES(10))), ['uo.vendor.expiring']) // and back in
})
test('exempt and absent fees are both "nothing to warn about", not "no money"', () => {
// A commission vendor has no PayTimer and is NEVER dismissed for fees.
// Conflating that with a distant date is how a vendor that cannot expire ends
// up in an expiry warning (docs/link/v5.md).
assert.deepEqual(ids(listing({ exempt: true })), [])
// A pre-v5 overlay sends no `fees` block at all.
assert.deepEqual(ids(listing(undefined)), [])
})
test('a vendor already past its dismissal tick reports 0 hours, never a negative', () => {
const t = one(listing(FEES(-3)))
assert.equal(t.data.hoursRemaining, 0)
})
test('an unowned listing is nobody to notify', () => {
const { ownerAcct, ...anonymous } = listing(FEES(10))
assert.deepEqual(ids(anonymous), [])
})
// ── Logins: the inversion protocol 5 exists to fix ─────────────────────────
test('only a FAILED login warns — a successful one produces nothing', () => {
const failed = one({ kind: 'account.login.result', acct: 'seed_000', ip: '203.0.113.9', accepted: false, reason: 'BadPass' })
assert.equal(failed.triggerId, 'uo.account.login_failed')
assert.equal(failed.data.reason, 'BadPass')
assert.deepEqual(ids({ kind: 'account.login.result', acct: 'seed_000', accepted: true }), [])
})
test('the pre-decision attempt kind is not mapped at all', () => {
// `account.login.attempt` fires from a sink that runs BEFORE the auth decision
// and whose args default `Accepted = true`, so a rule on it would have mailed a
// security alert on every successful login. That is why v5 added a second kind
// and why this one must stay unmapped.
assert.deepEqual(ids({ kind: 'account.login.attempt', acct: 'seed_000', ip: '203.0.113.9' }), [])
})
// ── Transitions ────────────────────────────────────────────────────────────
const champ = (over) => ({ kind: 'champ.update', serial: '0x40012345', name: 'Abyss', category: 'champion', map: 'Felucca', x: 5187, y: 570, ...over })
// Protocol 6. The spawn serial matches `champ`'s, so the pair can be walked as
// one altar's story: the boss goes up, then it comes down.
const bossKilled = (over) => ({
kind: 'champ.boss.killed',
serial: '0x40012345',
bossSerial: '0x901', category: 'champion', boss: 'Semidar', bossType: 'Semidar',
map: 'Felucca', x: 5187, y: 570, region: 'Destard',
killer: { serial: '0x55', name: 'Aldric', acct: 'seed_002', player: true },
damagers: [
{ serial: '0x55', name: 'Aldric', acct: 'seed_002', player: true, damage: 900 },
{ serial: '0x56', name: 'Bran', acct: 'seed_003', player: true, damage: 120 },
],
...over,
})
test('a first sighting is never a transition — a reconnect is not twenty spawns starting', () => {
assert.deepEqual(ids(champ({ active: true })), [])
assert.deepEqual(ids(champ({ active: true })), []) // still no change
assert.deepEqual(ids(champ({ active: false })), [])
assert.deepEqual(ids(champ({ active: true })), ['uo.champ.started'])
})
test('the boss is its own transition, tracked separately from active', () => {
map(champ({ active: true, bossUp: false }))
assert.deepEqual(ids(champ({ active: true, bossUp: true })), ['uo.champ.boss_up'])
assert.deepEqual(ids(champ({ active: true, bossUp: true })), [])
})
test('champ.remove forgets the spawn, so its next appearance is a first sighting', () => {
map(champ({ active: false }))
map({ kind: 'champ.remove', serial: '0x40012345' })
assert.deepEqual(ids(champ({ active: true })), [])
})
// ── champ.boss.killed (Protocol 6) ─────────────────────────────────────────
test('a defeat fires on the frame itself, with no baseline to compare against', () => {
// Unlike its two neighbours above. `champ.update` is a SNAPSHOT, so a first
// sighting can never be a transition; a defeat is an event, so a first sighting
// is exactly the thing being reported.
const hit = one(bossKilled())
assert.equal(hit.triggerId, 'uo.champ.boss_killed')
assert.equal(hit.data.bossName, 'Semidar')
assert.equal(hit.data.killerName, 'Aldric')
assert.equal(hit.data.damagerCount, 2)
assert.equal(hit.data.damagerNote, ' 2 players fought it.')
assert.equal(hit.data.location, 'Felucca 5187, 570 (Destard)')
})
test('the subject is the SPAWN, so boss_up and boss_killed share one cooldown subject', () => {
map(champ({ active: true, bossUp: false }))
const up = one(champ({ active: true, bossUp: true }))
const down = one(bossKilled())
assert.equal(up.triggerId, 'uo.champ.boss_up')
assert.equal(down.data.spawnSerial, up.data.spawnSerial)
})
test('a defeat the shard could not attribute to an altar stands on the boss itself', () => {
// The sweep learns which altar a champion belongs to; a boss that popped and
// died between two sweeps arrives with no `serial`. A subject that exists once
// is all a cooldown needs, so the boss's own serial stands in rather than the
// firing being dropped.
const hit = one(bossKilled({ serial: undefined }))
assert.equal(hit.data.spawnSerial, '0x901')
})
test('a defeat clears the tracker, so the next boss on that altar is a transition again', () => {
map(champ({ active: true, bossUp: false }))
map(champ({ active: true, bossUp: true })) // fires boss_up
map(bossKilled())
// Without the tracker reset this would emit nothing: the tracker would still
// believe a boss is up, so the next one would not look like a change.
assert.deepEqual(ids(champ({ active: true, bossUp: true })), ['uo.champ.boss_up'])
})
test('the damage TABLE never becomes trigger data, only its size', () => {
// `damagers` is `staff` in the visibility config. A trigger variable is
// interpolated into mail an operator may address to every subscriber, so a
// damager name reaching `data` would undo that field rule one layer up.
const hit = one(bossKilled())
const rendered = JSON.stringify(hit.data)
assert.equal(rendered.includes('Bran'), false, 'no damager name reaches the data')
assert.equal(rendered.includes('seed_003'), false, 'no damager account reaches the data')
assert.equal(hit.data.damagers, undefined)
})
test('an unattributed kill renders no damager sentence rather than an empty one', () => {
const hit = one(bossKilled({ damagers: [] }))
assert.equal(hit.data.damagerCount, undefined)
assert.equal(hit.data.damagerNote, undefined)
})
const city = (over) => ({ kind: 'city.update', city: 'Britain', electionPhase: 'none', ...over })
test('a governor change is a transition, and never on first sight', () => {
assert.deepEqual(ids(city({ governor: { serial: '0x1', name: 'Mireille' } })), [])
const t = one(city({ governor: { serial: '0x2', name: 'Darrow' } }))
assert.equal(t.triggerId, 'uo.governor.elected')
assert.equal(t.data.governorName, 'Darrow')
assert.deepEqual(ids(city({ governor: { serial: '0x2', name: 'Darrow' } })), [])
})
test('an ELECTED governor with a linked account also gets a letter', () => {
// Phase 11b, decision 10. §8.6 says `uo.points.rank_changed` cannot address a
// person because `top[]` names a serial — and the same reasoning was silently
// assumed to cover the governor. It does not: `BridgeJson.Actor()` writes
// `acct` on every actor object, so the winner is addressable with no protocol
// change. This test is the record of that, and of the decision that the
// announcement and the letter are TWO triggers.
map(city({ governor: { serial: '0x1', name: 'Mireille', acct: 'mireille' } }))
const out = map(city({ governor: { serial: '0x2', name: 'Darrow', acct: 'darrow' } }))
assert.deepEqual(out.map((t) => t.triggerId), ['uo.governor.elected', 'uo.governor.appointed'])
const letter = out[1]
assert.equal(letter.ownerAccount, 'darrow')
assert.equal(letter.data.city, 'Britain')
assert.equal(letter.data.governorName, 'Darrow')
// The bulletin carries no owner — it is the town's, not the governor's.
assert.equal(out[0].ownerAccount, undefined)
})
test('an UNLINKED governor still gets the town its announcement', () => {
// Nobody to write to is an ordinary outcome, not an error — most game accounts
// on most shards have never been linked — and it must not cost the city its
// proclamation.
map(city({ governor: { serial: '0x1', name: 'Mireille' } }))
assert.deepEqual(
ids(city({ governor: { serial: '0x2', name: 'Darrow' } })),
['uo.governor.elected'],
)
})
test('an election opening needs its deadline, or it does not fire', () => {
map(city({ electionPhase: 'none' }))
// **A "vote now" mail with nothing to act by is worse than none**, and
// `autoPickAt` is declared required, so a phase change without one is dropped
// here rather than refused by `emit` later.
assert.deepEqual(ids(city({ electionPhase: 'vote' })), [])
const fresh = engagement.createTracker()
engagement.mapShardEvent(city({ electionPhase: 'none' }), fresh)
const out = engagement.mapShardEvent(
city({ electionPhase: 'vote', autoPickAt: '2026-09-04T00:00:00Z', candidates: 3 }),
fresh,
)
assert.deepEqual(out.map((t) => t.triggerId), ['uo.election.opened'])
assert.equal(out[0].data.autoPickAt, '2026-09-04T00:00:00Z')
})
// ── The shard's own up/down, which is the cooldown table's stress test ─────
test('a sidecar reconnect is not a restart — server.hello only fires on a real change', () => {
// `server.hello` is sent on EVERY sidecar reconnect, not only on a shard
// restart, which is exactly the flapping this trigger must not amplify.
assert.deepEqual(ids({ kind: 'server.hello', shard: 'UOMysticmoon', bootId: 'a' }), ['uo.server.up'])
assert.deepEqual(ids({ kind: 'server.hello', shard: 'UOMysticmoon', bootId: 'a' }), [])
assert.deepEqual(ids({ kind: 'server.hello', shard: 'UOMysticmoon', bootId: 'b' }), [])
})
test('down fires once per outage, and a crash is told apart from a clean stop', () => {
map({ kind: 'server.hello', shard: 'UOMysticmoon' })
const down = one({ kind: 'server.shutdown' })
assert.equal(down.triggerId, 'uo.server.down')
assert.equal(down.data.clean, true)
assert.deepEqual(ids({ kind: 'server.crashed' }), []) // already down
map({ kind: 'server.hello' })
assert.equal(one({ kind: 'server.crashed' }).data.clean, false)
})
// ── Thresholds ─────────────────────────────────────────────────────────────
const supply = (gold, accounts = 50) => ({ kind: 'economy.supply', gold, accounts })
test('an economy milestone fires on a crossing, in both directions, never on first sight', () => {
// A sidecar reconnect on a mature shard must not announce a line it crossed
// months ago.
assert.deepEqual(ids(supply(900_000_000)), [])
const up = one(supply(1_200_000_000))
assert.equal(up.triggerId, 'uo.economy.milestone')
assert.equal(up.data.direction, 'up')
assert.equal(up.data.threshold, 1_000_000_000)
assert.deepEqual(ids(supply(1_300_000_000)), []) // same band
const down = one(supply(800_000_000))
assert.equal(down.data.direction, 'down')
assert.equal(down.data.threshold, 1_000_000_000) // the line it fell back through
})
// ── Leaderboards ───────────────────────────────────────────────────────────
const board = (serial, name) => ({
kind: 'points.board',
system: 'QueensLoyalty',
nameString: "Queen's Loyalty",
top: [{ rank: 1, serial, name, points: 29500 }, { rank: 2, serial: '0xFF', name: 'Mireille', points: 21000 }],
})
test('a leaderboard change names the new leader and nobody personally', () => {
assert.deepEqual(ids(board('0x1A2B', 'Darrow')), [])
const t = one(board('0x1A2C', 'Bran'))
assert.equal(t.triggerId, 'uo.points.rank_changed')
assert.equal(t.data.leaderName, 'Bran')
// The personal half is carved out: `top[]` names a mobile SERIAL and links are
// keyed by ACCOUNT, so there is deliberately no owner on this target.
assert.equal(t.ownerAccount, undefined)
assert.deepEqual(ids(board('0x1A2C', 'Bran')), [])
})
// ── Milestones ─────────────────────────────────────────────────────────────
test('only a capped skill is a milestone', () => {
const who = { serial: '0x1', name: 'Zara Crowe', acct: 'seed_000' }
assert.deepEqual(ids({ kind: 'skill.gain', who, skill: 'Blacksmithy', base: 99.8, cap: 100 }), [])
const t = one({ kind: 'skill.gain', who, skill: 'Blacksmithy', base: 100, cap: 100 })
assert.equal(t.triggerId, 'uo.skill.capped')
assert.equal(t.ownerAccount, 'seed_000')
// A mobile with no account is nobody's character.
assert.deepEqual(ids({ kind: 'skill.gain', who: { serial: '0x2', name: 'A Guard' }, base: 100, cap: 100 }), [])
})
test('both deaths address the victim, never the killer', () => {
const victim = { serial: '0x1', name: 'Zara Crowe', acct: 'seed_000' }
const murderer = { serial: '0x2', name: 'Darrow', acct: 'seed_001' }
const death = one({ kind: 'player.death', who: victim, killer: { name: 'an ogre lord' } })
assert.equal(death.ownerAccount, 'seed_000')
assert.equal(death.data.killerName, 'an ogre lord')
const murder = one({ kind: 'player.murdered', victim, murderer })
assert.equal(murder.triggerId, 'uo.character.murdered')
assert.equal(murder.ownerAccount, 'seed_000')
assert.equal(murder.data.murdererName, 'Darrow')
})
// ── Guilds ─────────────────────────────────────────────────────────────────
test('a guild leave and a disband are members-shaped; a join is not mapped at all', () => {
const left = one({ kind: 'guild.leave', id: 1042, name: 'The Silver Hand', who: '0x77' })
assert.equal(left.triggerId, 'uo.guild.left')
assert.equal(left.guildId, 1042)
assert.equal(left.memberSerial, '0x77')
assert.equal(one({ kind: 'guild.remove', id: 1042 }).triggerId, 'uo.guild.disbanded')
// Core's `team.member.joined` already fires for this, on every roster
// reconcile, because a UO guild IS a Team and this module is the provider.
// A second trigger would be two mails for one join (§8.6).
assert.deepEqual(ids({ kind: 'guild.join', id: 1042, who: { serial: '0x77', name: 'Bran' } }), [])
})
// ── Staff and operator ─────────────────────────────────────────────────────
test('the staff-facing pair carry no account of the person they are about, except where it is the point', () => {
const page = one({ kind: 'page.new', type: 'Stuck', sender: { name: 'Zara Crowe', acct: 'seed_000' }, message: 'help', map: 'Trammel', x: 1, y: 2 })
assert.equal(page.triggerId, 'uo.page.new')
assert.equal(page.ownerAccount, undefined) // it is a STAFF audience, not the player's
const cheat = one({ kind: 'cheat.fastwalk', who: { name: 'Zara Crowe', acct: 'seed_000' }, ip: '203.0.113.9' })
assert.equal(cheat.triggerId, 'uo.cheat.detected')
assert.equal(cheat.ownerAccount, undefined) // never addressed to the player detected
assert.equal(cheat.data.account, 'seed_000') // but staff are told which account
})
test('the three audit kinds fold into one operator trigger', () => {
assert.deepEqual(ids({ kind: 'audit.set', staff: 'Mireille', prop: 'Str', old: 100, new: 125, target: 'Zara' }), ['uo.audit.staff_action'])
assert.deepEqual(ids({ kind: 'audit.command', staff: 'Mireille', command: '[go', args: 'britain' }), ['uo.audit.staff_action'])
const admin = one({ kind: 'admin.audit', origin: 'web', action: 'ban', actor: 'web:9931', target: 'seed_000', reason: 'macroing' })
assert.equal(admin.data.action, 'ban')
assert.equal(admin.data.origin, 'web')
})
test('world.save.after reports what it wrote', () => {
const t = one({ kind: 'world.save.after', items: 1482301, mobiles: 41022 })
assert.equal(t.triggerId, 'uo.world.saved')
assert.equal(t.data.items, 1482301)
// `before` is a boundary, not news.
assert.deepEqual(ids({ kind: 'world.save.before' }), [])
})
// ── The guard ──────────────────────────────────────────────────────────────
test('an unmapped kind and a malformed frame both produce nothing', () => {
assert.deepEqual(ids({ kind: 'char.vitals', serial: '0x1' }), [])
assert.deepEqual(ids({ kind: 'region.enter' }), [])
assert.deepEqual(engagement.mapShardEvent(null, tracker), [])
assert.deepEqual(engagement.mapShardEvent({}, tracker), [])
assert.deepEqual(engagement.mapShardEvent({ kind: 42 }, tracker), [])
})
// ── Resolution: the half that reaches the database ─────────────────────────
// A link row shaped the way `shardLinks.model.getByAccount` actually returns
// one, taken FROM that model rather than written out here: the model's `toSafe`
// camel-cases the row, and a hand-written fake using the column names is a fake
// that will agree with a resolver reading the column names. Stubbing the db
// layer and letting the real `toSafe` run is what makes the shape non-negotiable.
const shardLinksDb = require('../model/shardLinks/shardLinks.db')
const shardLinksModel = require('../model/shardLinks/shardLinks.model')
function linkRow(account, userId) {
const realGet = shardLinksDb.getByAccount
shardLinksDb.getByAccount = async () => ({
account, user_id: userId, char_name: 'Zara Crowe', linked_at: new Date(0),
})
try {
return shardLinksModel.getByAccount(account)
} finally {
shardLinksDb.getByAccount = realGet
}
}
function deps(over = {}) {
const emitted = []
return {
emitted,
emit: (triggerId, envelope) => emitted.push({ triggerId, envelope }),
tracker,
shardLinks: {
// Shaped by the REAL model's `toSafe`, not by the column names. A fake that
// returns `user_id` agrees with a resolver that reads `user_id`, and the
// pair passes while every owner-audienced trigger reaches nobody on a live
// shard — which is exactly what happened. `linkRow` below is the guard.
getByAccount: async (acct) => (acct === 'seed_002' ? linkRow(acct, 7) : null),
userIdsForAccounts: async (accounts) => (accounts.includes('seed_002') ? [7, 9] : []),
...over.shardLinks,
},
shardState: {
listHouses: async () => [{ serial: '0x400142F9', ownerAcct: 'seed_002', name: 'Millrace', region: 'Britain' }],
listGuilds: async () => [{ id: 1042, name: 'The Silver Hand', abbr: 'TSH' }],
listGuildMembers: async () => [{ serial: '0x77', name: 'Bran' }],
listGuildMemberAccounts: async () => ['seed_002'],
...over.shardState,
},
}
}
test('an owner-keyed event resolves the game account to a website user', async () => {
const d = deps()
await engagement.fromShardEvent(DECAY, d)
assert.equal(d.emitted.length, 1)
assert.equal(d.emitted[0].triggerId, 'uo.house.idoc_warning')
assert.equal(d.emitted[0].envelope.ownerUserId, 7)
})
test('an UNLINKED owner is nobody to notify, and that is not an error', async () => {
// The common case on every shard: most game accounts have never been linked.
const d = deps()
await engagement.fromShardEvent({ ...DECAY, ownerAcct: 'nobody' }, d)
assert.deepEqual(d.emitted, [])
})
test('house.remove fills the owner and the name in from the registry mirror', async () => {
const d = deps()
await engagement.fromShardEvent({ kind: 'house.remove', serial: '0x400142F9' }, d)
assert.equal(d.emitted.length, 1)
assert.equal(d.emitted[0].envelope.ownerUserId, 7)
assert.equal(d.emitted[0].envelope.data.houseName, 'Millrace')
})
test('a guild event carries its own access-checked recipient set, not an ownerUserId', async () => {
// §5.1a: "the members of THIS guild" is a different answer every firing, so a
// saved segment cannot express it and the set travels on the envelope
// (Phase 6, decision 2 — the mechanism the Team fan-out was built on).
const d = deps()
await engagement.fromShardEvent({ kind: 'guild.leave', id: 1042, name: 'The Silver Hand', who: '0x77' }, d)
assert.equal(d.emitted.length, 1)
assert.deepEqual(d.emitted[0].envelope.recipientUserIds, [7, 9])
assert.equal(d.emitted[0].envelope.ownerUserId, undefined)
// The two names the frames do not carry come from the mirrors.
assert.equal(d.emitted[0].envelope.data.memberName, 'Bran')
})
test('guild.remove names the guild from the board, because the frame carries only an id', async () => {
const d = deps()
await engagement.fromShardEvent({ kind: 'guild.remove', id: 1042 }, d)
assert.equal(d.emitted[0].envelope.data.guildName, 'The Silver Hand')
assert.equal(d.emitted[0].envelope.data.abbreviation, 'TSH')
})
test('a guild whose members have all unlinked reaches nobody rather than everybody', async () => {
const d = deps({ shardLinks: { userIdsForAccounts: async () => [] } })
await engagement.fromShardEvent({ kind: 'guild.leave', id: 1042, who: '0x77' }, d)
assert.deepEqual(d.emitted, [])
})
test('a subscribers-shaped event needs no resolution at all', async () => {
const d = deps()
engagement.mapShardEvent(champ({ active: false }), tracker) // establish the transition
await engagement.fromShardEvent(champ({ active: true }), d)
assert.equal(d.emitted.length, 1)
assert.equal(d.emitted[0].envelope.ownerUserId, undefined)
assert.equal(d.emitted[0].envelope.recipientUserIds, undefined)
})
test('a failing lookup costs that one target and never the ingest feed', async () => {
const d = deps({ shardLinks: { getByAccount: async () => { throw new Error('db is down') } } })
await assert.doesNotReject(() => engagement.fromShardEvent(DECAY, d))
assert.deepEqual(d.emitted, [])
})

View File

@@ -0,0 +1,126 @@
// A shard restart makes the event resource ledger a claim about a world that no
// longer exists (EVENTS.md §F, EVENTS_PLAN.md Phases 8 and 9).
//
// Core cannot notice that on its own — it has no concept of the game being up —
// so the module says when, and `server.hello` carrying a *changed* `bootId` is
// the only signal that distinguishes a shard restart from a sidecar reconnect.
// Getting that wrong in either direction is a real failure: never asking leaves
// core believing a ledger of things that are gone, and asking on every reconnect
// makes core orphan rows that are perfectly alive.
const { test, beforeEach } = require('node:test')
const assert = require('node:assert/strict')
const shardIngest = require('../utils/shardIngest')
function makeDeps() {
const order = []
const noop = async () => {}
return {
order,
shardEvents: { append: noop },
shardState: { clearOnline: async () => { order.push('clearOnline') }, upsertOnline: noop, setOffline: noop },
shardLinks: {},
shardMarket: {},
uoLinkConfig: { recordStatus: async (row) => { order.push(`recordStatus:${row.bootId}`) } },
settings: { getInstanceName: async () => 'Rig' },
broadcast: () => {},
pushDispatch: () => {},
engagement: () => {},
eventsReconcile: () => { order.push('reconcile') },
log: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} },
}
}
const hello = (bootId) => ({ kind: 'server.hello', t: '2026-09-04T10:00:00Z', shard: 'Rig', bootId })
beforeEach(() => shardIngest.reset())
test('the first hello of a process is not a restart', async () => {
// The website has just come up and the shard has not moved. Everything in the
// ledger is still in force, and asking would be core spending a round trip per
// module to be told so.
const deps = makeDeps()
await shardIngest.ingest(hello('boot-1'), deps)
assert.ok(!deps.order.includes('reconcile'))
})
test('a sidecar reconnect is not a restart either', async () => {
// `server.hello` is sent on EVERY reconnect, and the sidecar dropping its
// socket changes nothing in the game. Reconciling here would orphan every live
// row — the ledger would still be right and core would stop believing it.
const deps = makeDeps()
await shardIngest.ingest(hello('boot-1'), deps)
await shardIngest.ingest(hello('boot-1'), deps)
assert.ok(!deps.order.includes('reconcile'))
})
test('a changed bootId asks every module to reconcile its ledger', async () => {
const deps = makeDeps()
await shardIngest.ingest(hello('boot-1'), deps)
await shardIngest.ingest(hello('boot-2'), deps)
assert.equal(deps.order.filter((s) => s === 'reconcile').length, 1)
})
test('the reconcile happens AFTER the new bootId is recorded', async () => {
// The ordering is load-bearing rather than tidy. Every action decides what is
// still in force by comparing its stamp against the CURRENT boot id, which it
// reads back out of the row `recordStatus` writes. Asking first would compare
// every resource against the boot that has just ended — and every one of them
// would look live, which is the exact opposite of what a restart means.
const deps = makeDeps()
await shardIngest.ingest(hello('boot-1'), deps)
await shardIngest.ingest(hello('boot-2'), deps)
const recordedAt = deps.order.lastIndexOf('recordStatus:boot-2')
const askedAt = deps.order.indexOf('reconcile')
assert.ok(recordedAt >= 0 && askedAt >= 0)
assert.ok(askedAt > recordedAt, 'reconcile must not run before the new boot id is stored')
})
test('a hello with no bootId at all changes nothing', async () => {
// An older plugin, or a frame that lost the field. Not knowing which boot this
// is cannot be allowed to read as "a new one".
const deps = makeDeps()
await shardIngest.ingest(hello('boot-1'), deps)
await shardIngest.ingest({ kind: 'server.hello', t: '2026-09-04T10:00:00Z', shard: 'Rig' }, deps)
assert.ok(!deps.order.includes('reconcile'))
})
test('a backfill replay never reconciles, however many boots it walks through', async () => {
// **The defect the live rig found, and nothing else could.** A WS reconnect
// replays the last several `server.hello` frames in order — this rig saw three,
// each with a different `bootId` — so every replayed frame looks like a
// restart. Acting on the intermediate ones would compare a resource stamped
// with the CURRENT boot against a boot that ended hours ago and mark it
// `orphaned`: a live crier line core will never take down again, lost to
// nothing worse than the website reconnecting.
const deps = makeDeps()
await shardIngest.ingest(hello('boot-1'), deps)
for (const boot of ['boot-2', 'boot-3', 'boot-4']) {
await shardIngest.ingest(hello(boot), { ...deps, fromBackfill: true })
}
assert.ok(!deps.order.includes('reconcile'))
// The replay still moves the tracked boot on, so the NEXT live hello is
// measured against where the replay left off rather than against boot-1.
assert.ok(deps.order.includes('recordStatus:boot-4'))
})
test('a live hello after a replay is still a restart', async () => {
// The gate is about the frame, not about the module going quiet: skipping the
// replay must not make the next genuine restart invisible.
const deps = makeDeps()
await shardIngest.ingest(hello('boot-1'), deps)
await shardIngest.ingest(hello('boot-2'), { ...deps, fromBackfill: true })
await shardIngest.ingest(hello('boot-3'), deps)
assert.equal(deps.order.filter((s) => s === 'reconcile').length, 1)
})
test('a reconcile that throws does not take the ingest down with it', async () => {
// Fire-and-forget by the contract, and the feed must survive one bad module:
// `ingest()` never throws, because a single event may not kill the socket.
const deps = makeDeps()
deps.eventsReconcile = () => { throw new Error('registry exploded') }
await shardIngest.ingest(hello('boot-1'), deps)
await assert.doesNotReject(() => shardIngest.ingest(hello('boot-2'), deps))
})

View File

@@ -0,0 +1,75 @@
// Protocol 4 membership routing: guild.roster (board state, possibly chunked) and
// guild.leave (a real-time departure, logged like its guild.join counterpart).
const { test, beforeEach } = require('node:test')
const assert = require('node:assert/strict')
const shardIngest = require('../utils/shardIngest')
function makeDeps() {
const calls = { roster: [], memberRemove: [], appended: [], broadcast: [] }
const noop = async () => {}
return {
calls,
shardEvents: { append: async (row) => { calls.appended.push(row); return true } },
shardState: {
upsertGuildRoster: async (ev) => { calls.roster.push(ev) },
removeGuildMember: async (ev) => { calls.memberRemove.push(ev) },
upsertGuild: noop, removeGuild: noop,
clearOnline: noop, upsertOnline: noop, setOffline: noop,
addEconomySample: noop,
},
shardLinks: { removeByAccount: noop },
uoLinkConfig: { recordStatus: noop },
broadcast: (ev) => { calls.broadcast.push(ev) },
pushDispatch: async () => {},
log: { warn() {}, info() {}, error() {} },
}
}
beforeEach(() => shardIngest.reset())
test('guild.roster routes to upsertGuildRoster and is NOT logged', async () => {
// It is board state like guild.update, and the one fat frame on the wire —
// logging it would put a full membership snapshot in shard_events on every
// membership change.
const deps = makeDeps()
const r = await shardIngest.ingest(
{ kind: 'guild.roster', id: 7, seq: 0, more: false, total: 2,
members: [{ serial: '0x1', name: 'Ada' }, { serial: '0x2', name: 'Bo' }], t: 1 },
deps,
)
assert.equal(deps.calls.roster.length, 1)
assert.equal(deps.calls.roster[0].id, 7)
assert.equal(deps.calls.roster[0].members.length, 2)
assert.equal(r.logged, false)
})
test('every frame of a chunked roster reaches the model, seq intact', async () => {
// The sidecar reassembles for its own board, but the live feed and the /history
// backfill both carry individual frames — so the model must see each one with its
// seq, which is what tells it whether to clear the guild first.
const deps = makeDeps()
for (const [seq, more, serial] of [[0, true, '0x1'], [1, true, '0x2'], [2, false, '0x3']]) {
await shardIngest.ingest(
{ kind: 'guild.roster', id: 7, seq, more, total: 3, members: [{ serial, name: serial }], t: 1 },
deps,
)
}
assert.deepEqual(deps.calls.roster.map((e) => e.seq), [0, 1, 2])
assert.deepEqual(deps.calls.roster.map((e) => e.more), [true, true, false])
})
test('guild.leave is logged and broadcast, like guild.join', async () => {
const deps = makeDeps()
const r = await shardIngest.ingest(
{ kind: 'guild.leave', id: 7, name: 'The Cartographers', who: '0x2', t: 2 }, deps)
assert.equal(r.logged, true)
assert.equal(deps.calls.appended.length, 1)
assert.equal(deps.calls.appended[0].kind, 'guild.leave')
assert.equal(deps.calls.broadcast.length, 1)
assert.deepEqual(deps.calls.memberRemove.map((e) => e.who), ['0x2'])
})

View File

@@ -261,3 +261,91 @@ test('the cliloc resolver is the path shapeItems resolves through', async () =>
const found = await clilocs.resolveMany([1023721])
assert.equal(found.get(1023721), 'quarter staff')
})
// ── Protocol 5: owner account and fee state ────────────────────────────────
const V5_FEES = {
exempt: false,
newVendorSystem: true,
chargePerPeriod: 148,
funds: 2960,
holdGold: 2960,
bankAccount: 0,
payIntervalSec: 86400,
nextPayAt: '2026-09-01T00:00:00.000Z',
periodsRemaining: 20,
dismissalAt: '2026-09-21T00:00:00.000Z',
}
test('flattenFrame lifts ownerAcct, the field that makes a shop resolvable to a person', () => {
// ownerName has been on the frame since v3, but a character name joins to nothing:
// shard_account_links is keyed by the game ACCOUNT.
const row = market.flattenFrame({ ...FRAME, ownerAcct: 'darrow_acct', fees: V5_FEES })
assert.equal(row.ownerAcct, 'darrow_acct')
assert.equal(row.ownerName, 'Darrow', 'the character name is still carried too')
})
test('flattenFrame normalises the fee block, dates included', () => {
const row = market.flattenFrame({ ...FRAME, fees: V5_FEES })
assert.equal(row.feesExempt, false)
assert.equal(row.chargePerPeriod, 148)
assert.equal(row.funds, 2960)
assert.equal(row.payIntervalSec, 86400)
assert.equal(row.periodsRemaining, 20)
assert.ok(row.nextPayAt instanceof Date)
assert.equal(row.dismissalAt.toISOString(), '2026-09-21T00:00:00.000Z')
})
// The shard resolved dismissalAt against ServUO's two vendor systems, whose charge,
// funds and pay interval all differ. Re-deriving it here would be a second
// implementation of a rule that lives in PlayerVendor.PayTimer.
test('flattenFrame trusts the shard dismissal date instead of recomputing it', () => {
const row = market.flattenFrame({
...FRAME,
fees: { ...V5_FEES, dismissalAt: '2026-12-25T00:00:00.000Z' },
})
assert.equal(row.dismissalAt.toISOString(), '2026-12-25T00:00:00.000Z')
})
// A commission vendor has no pay timer and is never dismissed for fees. That is a
// different thing from having a long time left, and a surface rendering "never" has
// to be able to tell them apart.
test('an exempt vendor reports exempt with no schedule at all', () => {
const row = market.flattenFrame({ ...FRAME, fees: { exempt: true } })
assert.equal(row.feesExempt, true)
assert.equal(row.dismissalAt, null)
assert.equal(row.periodsRemaining, null)
assert.equal(row.chargePerPeriod, null)
})
// A pre-v5 overlay omits `fees` entirely, and a shard can be rolled back to one.
// Nulls have to mean "this shard has not told me", never "this vendor is broke" —
// the difference between silence and a false alarm in a rule that mails an owner.
test('a pre-v5 frame yields nulls, not zeroes', () => {
const row = market.flattenFrame(FRAME)
assert.equal(row.feesExempt, false)
for (const key of ['chargePerPeriod', 'funds', 'payIntervalSec', 'periodsRemaining']) {
assert.equal(row[key], null, `${key} must be null, not 0`)
}
assert.equal(row.nextPayAt, null)
assert.equal(row.dismissalAt, null)
assert.equal(row.ownerAcct, null)
})
test('an unparseable fee date is dropped rather than stored as an Invalid Date', () => {
const row = market.flattenFrame({
...FRAME,
fees: { ...V5_FEES, dismissalAt: 'next tuesday', nextPayAt: null },
})
assert.equal(row.dismissalAt, null)
assert.equal(row.nextPayAt, null)
assert.equal(row.funds, 2960, 'one bad field must not discard the rest of the block')
})
test('a malformed fees value is treated as absent, not as a crash', () => {
for (const fees of ['', 0, 'nope', []]) {
const row = market.flattenFrame({ ...FRAME, fees })
assert.equal(row.feesExempt, false)
assert.equal(row.dismissalAt, null)
}
})

View File

@@ -251,3 +251,74 @@ test('listGovernorHistory coerces started/ended timestamps to numbers and clamps
assert.equal(typeof out[0].startedAt, 'number')
assert.equal(out[0].endedAt, null) // an open term stays null, not coerced to 0
})
// ── Protocol 5: the decay schedule ─────────────────────────────────────────
test('upsertHouse flattens the nested schedule into its four columns', async () => {
await shardState.upsertHouse({
serial: 1,
stage: 'IDOC',
schedule: {
dynamicDecay: true,
nextStage: '2026-09-02T04:00:00.000Z',
decayPeriodSec: 432000,
estimatedCollapse: '2026-09-02T04:00:00.000Z',
},
})
const [, fields] = calls.upsertHouse[0]
assert.equal(fields.dynamic_decay, 1)
assert.equal(fields.decay_period_sec, 432000)
assert.ok(fields.next_stage instanceof Date)
assert.equal(fields.estimated_collapse.toISOString(), '2026-09-02T04:00:00.000Z')
})
// The whole point of the field: under dynamic decay ServUO draws each stage's
// duration at random on entry, so the shard omits estimatedCollapse everywhere but
// IDOC. A stored null has to mean "not knowable", which it cannot if a partial
// schedule silently keeps the previous value.
test('a schedule without a collapse time stores null, it does not keep the old one', async () => {
await shardState.upsertHouse({
serial: 1,
stage: 'Greatly',
schedule: { dynamicDecay: true, nextStage: '2026-09-01T00:00:00.000Z', decayPeriodSec: 432000 },
})
const [, fields] = calls.upsertHouse[0]
assert.equal(fields.estimated_collapse, null)
assert.ok('estimated_collapse' in fields, 'must be WRITTEN as null, not omitted')
})
// A pre-v5 overlay sends no schedule at all, and a shard can be rolled back to one.
// Every column is still written, so a dismissal date nobody is maintaining cannot
// be left standing.
test('a frame with no schedule nulls all four columns rather than omitting them', async () => {
await shardState.upsertHouse({ serial: 1, stage: 'Fairly' })
const [, fields] = calls.upsertHouse[0]
for (const col of ['next_stage', 'estimated_collapse', 'decay_period_sec', 'dynamic_decay']) {
assert.ok(col in fields, `${col} must be written`)
assert.equal(fields[col], null)
}
})
test('an unparseable schedule date is dropped, not stored as an Invalid Date', async () => {
await shardState.upsertHouse({
serial: 1,
stage: 'IDOC',
schedule: { nextStage: 'soon-ish', estimatedCollapse: '' },
})
const [, fields] = calls.upsertHouse[0]
assert.equal(fields.next_stage, null)
assert.equal(fields.estimated_collapse, null)
})
// house.update writes owner_name from its own sweep. If house.decay coalesced a
// missing ownerName to null, every decay transition on a pre-v5 shard would erase
// a name the registry had already resolved.
test('house.decay never erases an owner_name it was not given', async () => {
await shardState.upsertHouse({ serial: 1, stage: 'IDOC', ownerAcct: 'cadmus' })
const [, fields] = calls.upsertHouse[0]
assert.ok(!('owner_name' in fields), 'owner_name must not be written when absent')
await shardState.upsertHouse({ serial: 1, stage: 'IDOC', ownerName: 'Cadmus' })
const [, withName] = calls.upsertHouse[1]
assert.equal(withName.owner_name, 'Cadmus')
})

View File

@@ -95,6 +95,58 @@ test('an unknown viewer level cannot see a gated kind or a locked field', async
assert.equal('webId' in out.leader, false)
})
// ── Protocol 6: the champion defeat ──────────────────────────────────
const KILL = {
kind: 'champ.boss.killed',
serial: '0x40012345',
boss: 'Semidar',
killer: { serial: '0x55', name: 'Aldric', acct: 'seed_002', player: true },
damagers: [
{ serial: '0x55', name: 'Aldric', acct: 'seed_002', webId: '7', player: true, damage: 900 },
{ serial: '0x56', name: 'Bran', acct: 'seed_003', player: true, damage: 120 },
],
}
test('the kill is public and its damage table is not', () => {
const config = visibility.compileDefaults()
// The whole shape of this addition in one assertion: a champion falling is
// content the public board is FOR, and a ranked roll of who was strong enough
// to fell it is a performance record nobody published on purpose.
assert.equal(visibility.kindVisibleTo('champ.boss.killed', 'anonymous', config), true)
for (const level of ['anonymous', 'logged_in', 'player']) {
const out = visibility.projectFeature('champs', KILL, level, config)
assert.equal(out.boss, 'Semidar', `${level} sees which boss fell`)
assert.equal('damagers' in out, false, `${level} must not see the damage table`)
}
assert.equal(visibility.projectFeature('champs', KILL, 'staff', config).damagers.length, 2)
})
test('the killer rides the frame the way mob.killed already publishes one', () => {
// Deliberately NOT a configurable field. It is one actor, announced in-game to
// everyone present, and the same disclosure the public activity feed has made
// through `mob.killed` since before this framework existed.
const config = visibility.compileDefaults()
const out = visibility.projectFeature('champs', KILL, 'anonymous', config)
assert.equal(out.killer.name, 'Aldric')
assert.equal('acct' in out.killer, false, 'rule 1 still applies inside it')
})
test('an admin who lowers the damager rule still cannot see an account inside it', () => {
// Rule 1 beats a field rule wherever the two meet, and a damager entry is an
// actor object like any other. An admin who opens the table to everyone has
// published character names, which is what they chose; they have not published
// account names, which is not theirs to choose.
const config = visibility.compileDefaults()
config.champs.fields = { ...config.champs.fields, damagers: 'anonymous' }
const out = visibility.projectFeature('champs', KILL, 'anonymous', config)
assert.equal(out.damagers.length, 2)
assert.equal(out.damagers[0].name, 'Aldric')
assert.equal(out.damagers[0].damage, 900)
assert.equal('acct' in out.damagers[0], false)
assert.equal('webId' in out.damagers[0], false)
})
// ── Rule 1: locked fields ──────────────────────────────────────────────────
test('acct and webId are stripped below admin regardless of feature config', () => {
@@ -116,6 +168,52 @@ test('acct and webId are stripped below admin regardless of feature config', ()
assert.equal(asAdmin.leader.webId, '42')
})
test('acct and webId are stripped from every member of a guild roster (Protocol 4)', () => {
// A roster is the first frame where the locked fields appear inside an ARRAY of
// actors rather than one nested actor. The walker recurses into arrays, so this
// should already hold — this test is here because it is the difference between a
// public Guilds page listing character names and one publishing 150 account names.
const config = visibility.compileDefaults()
const frame = {
kind: 'guild.roster',
id: 7,
total: 3,
seq: 0,
more: false,
members: [
{ serial: '0x1', name: 'Ada', acct: 'ada_acct', webId: '11', player: true },
{ serial: '0x2', name: 'Bo', acct: 'bo_acct', player: true },
{ serial: '0x3', name: 'Cy', player: true }, // a mobile with no account at all
],
}
for (const level of ['anonymous', 'logged_in', 'player', 'staff']) {
const out = visibility.projectFeature('guilds', frame, level, config)
assert.equal(out.members.length, 3, `${level} still sees every member`)
assert.deepEqual(out.members.map((m) => m.name), ['Ada', 'Bo', 'Cy'])
for (const m of out.members) {
assert.equal('acct' in m, false, `${level} must not see a member's acct`)
assert.equal('webId' in m, false, `${level} must not see a member's webId`)
}
}
const asAdmin = visibility.projectFeature('guilds', frame, 'admin', config)
assert.equal(asAdmin.members[0].acct, 'ada_acct')
assert.equal(asAdmin.members[0].webId, '11')
})
test('guild.roster and guild.leave are mapped, so neither falls closed to admin-only', () => {
// Rule 2 fails an unmapped kind closed. That is the right default, but for these
// two it would silently keep the public Guilds page from ever seeing a roster.
const config = visibility.compileDefaults()
for (const kind of ['guild.roster', 'guild.leave']) {
assert.equal(
visibility.kindVisibleTo(kind, 'anonymous', config), true,
`${kind} should reach an anonymous viewer under the default guilds config`,
)
}
})
test('a stored rule trying to loosen a locked field is ignored', async () => {
withRows([
{ feature: 'guilds', enabled: true, audience: 'anonymous', stream: true, fieldRules: { acct: 'anonymous', webId: 'anonymous' } },
@@ -308,10 +406,27 @@ const PRE_V3_PUBLIC_KINDS = [
// pointedly not among them (its feature ships with stream off).
const V3_ADDED_PUBLIC_KINDS = ['world.ruleset', 'points.board']
test('derived PUBLIC_KINDS is exactly the pre-v3 allowlist plus the v3 additions', () => {
// v4 adds guild membership. Both ride the existing `guilds` feature, which is
// already anonymous, so they join the public set — carrying character names and
// serials, never acct/webId, which the locked-field rules strip by suffix even
// inside the roster's member array (see the roster test above).
const V4_ADDED_PUBLIC_KINDS = ['guild.roster', 'guild.leave']
// v6 adds the champion defeat. It rides the existing `champs` feature, which is
// already anonymous, so the KIND is public — while the `damagers` table on it is
// `staff` by field rule. That split is the point: a shard announces that its
// champion fell without publishing a roll of who was strong enough to fell it.
const V6_ADDED_PUBLIC_KINDS = ['champ.boss.killed']
test('derived PUBLIC_KINDS is exactly the pre-v3 allowlist plus the v3, v4 and v6 additions', () => {
assert.deepEqual(
[...visibility.PUBLIC_KINDS].sort(),
[...PRE_V3_PUBLIC_KINDS, ...V3_ADDED_PUBLIC_KINDS].sort(),
[
...PRE_V3_PUBLIC_KINDS,
...V3_ADDED_PUBLIC_KINDS,
...V4_ADDED_PUBLIC_KINDS,
...V6_ADDED_PUBLIC_KINDS,
].sort(),
)
})
@@ -424,3 +539,109 @@ test('a link lookup failure downgrades rather than escalating', async () => {
visibility.forgetUser(6)
assert.equal(await visibility.viewerLevel({ user: { id: 6, role: 'player' } }), 'logged_in')
})
// ── Protocol 5 ─────────────────────────────────────────────────────────────
//
// Two new nested field groups and one new kind. All three exist as visibility
// questions before they exist as features, which is the order this framework's
// rule 2 is designed to force: a v5 field that nobody classified would either
// leak (if it fell open) or be silently invisible (if it fell closed and nobody
// noticed). These tests pin the three answers that were actually chosen.
test('a vendor fee block is admin-only, and it is the whole block', async () => {
const config = await visibility.getConfig()
// The frame as BridgeMarket emits it: the shop's public parts, plus the money.
const frame = {
serial: '0x40001234',
shopName: "Darrow's Bargains",
ownerName: 'Darrow',
location: { map: 'Trammel', x: 1421, y: 1699, region: 'Britain' },
fees: {
exempt: false,
chargePerPeriod: 148,
funds: 2960,
periodsRemaining: 20,
dismissalAt: '2026-09-20T00:00:00.0000000Z',
},
}
for (const level of ['anonymous', 'logged_in', 'player', 'staff']) {
const out = visibility.projectFeature('market', frame, level, config)
assert.equal('fees' in out, false, `fees reached ${level}`)
// The rest of the shop is untouched — this is a field rule, not a feature one.
assert.equal(out.shopName, "Darrow's Bargains", `${level} lost the shop name`)
assert.equal(out.location.region, 'Britain', `${level} lost the location`)
}
const asAdmin = visibility.projectFeature('market', frame, 'admin', config)
assert.equal(asAdmin.fees.funds, 2960)
assert.equal(asAdmin.fees.dismissalAt, '2026-09-20T00:00:00.0000000Z')
})
// The nesting is the point, not a style choice: projectValue matches literal JSON
// keys, so seven flat fee keys would be seven rules an admin has to keep in step
// and a v6 field would default to visible. One nested key cannot drift.
test('the fee rule is one nested key, so a new fee field inherits the gate', async () => {
const config = await visibility.getConfig()
const frame = { serial: '0x1', fees: { exempt: false, somethingAddedLater: 'secret' } }
const out = visibility.projectFeature('market', frame, 'staff', config)
assert.equal('fees' in out, false, 'a field added inside fees must not fall out of the gate')
})
// The opposite call, and it is deliberate: the decay countdown is the public IDOC
// page's entire content, and a house at IDOC is already announced in game.
test('the decay schedule is anonymous by default but remains configurable', async () => {
const frame = {
serial: '0x1',
to: 'IDOC',
name: 'Marble Tower',
schedule: {
dynamicDecay: true,
nextStage: '2026-09-02T04:00:00.0000000Z',
decayPeriodSec: 432000,
estimatedCollapse: '2026-09-02T04:00:00.0000000Z',
},
}
const config = await visibility.getConfig()
const anon = visibility.projectFeature('houses', frame, 'anonymous', config)
assert.equal(anon.schedule.estimatedCollapse, '2026-09-02T04:00:00.0000000Z')
// A shard that considers a precise collapse time an unfair advantage can raise it,
// and raising the one nested rule takes the whole schedule with it.
withRows([
{
feature: 'houses',
enabled: true,
audience: 'anonymous',
stream: true,
fieldRules: { schedule: 'staff' },
},
])
const tightened = await visibility.getConfig()
assert.equal('schedule' in visibility.projectFeature('houses', frame, 'player', tightened), false)
assert.equal(
visibility.projectFeature('houses', frame, 'staff', tightened).schedule.decayPeriodSec,
432000,
)
// Tightening the schedule must not have disturbed the owner rules beside it.
assert.equal(visibility.projectFeature('houses', frame, 'anonymous', tightened).name, 'Marble Tower')
})
// Rule 2, exercised on the kind it was added for. account.login.result says whether
// a password was accepted and from which IP; it is admin-only by OMISSION, and the
// omission is the decision. If someone maps it to a feature to "make it visible",
// this fails and says why.
test('account.login.result is admin-only, like the attempt it completes', async () => {
const config = await visibility.getConfig()
assert.equal(
visibility.KIND_FEATURE.has('account.login.result'),
false,
'mapping this kind to a feature would let an admin widen an IP + auth verdict below admin',
)
for (const level of ['anonymous', 'logged_in', 'player', 'staff']) {
assert.equal(visibility.kindVisibleTo('account.login.result', level, config), false)
}
assert.equal(visibility.kindVisibleTo('account.login.result', 'admin', config), true)
assert.equal(visibility.PUBLIC_KINDS.has('account.login.result'), false)
})

View File

@@ -14,6 +14,7 @@ const {
buildFacetIndex,
resolveFacetName,
slugify,
parseDecoration,
decodeEntities,
} = require('../utils/spawnAtlasParse')
@@ -144,9 +145,14 @@ test('parsePoints: reads the kept fields and drops the rest', () => {
assert.equal(covetous.minDelay, 300)
assert.equal(covetous.maxDelay, 600)
assert.deepEqual(covetous.types, [{ type: 'Lizardman', max: 3 }])
// Dropped fields must not survive into the artifact — this is what keeps it
// under 1 MB.
assert.equal(covetous.uniqueId, undefined)
// **The UniqueId is KEPT from Phase 12b**, having been dropped since the atlas
// shipped. It is `XmlSpawner.UniqueId` — carried in the spawn files and on the
// live spawner — so it is the only name for one particular spawner that exists
// off the shard, and a property lease targets by it. A serial cannot do that
// job: serials are assigned when the world is built and nothing here knows one.
assert.equal(covetous.uniqueId, '001a34e5-0efa-46de-9c93-b6a163d96370')
// The rest of the dropped fields still are. Triggering, refractory windows,
// proximity and sounds are what the site has no use for.
assert.equal(covetous.proximityTriggerSound, undefined)
})
@@ -599,3 +605,49 @@ test('parsePoints: DelayInSec decides the unit, and both come out in seconds', (
assert.equal(seconds.minDelay, 5)
assert.equal(seconds.maxDelay, 10)
})
// ── parseDecoration (Phase 12a) ───────────────────────────────
test('parseDecoration: reads the type off each header and ignores the placements', () => {
const rows = parseDecoration(`# switch
Static 0x108F
5552 1864 11
5399 1875 17
# crate
LargeCrate 0x0E3C
5408 607 45
`)
assert.deepEqual(rows, [
{ type: 'Static', itemId: 0x108f },
{ type: 'LargeCrate', itemId: 0x0e3c },
])
})
test('parseDecoration: a parenthesised property list is not part of the type', () => {
// These are the shard's own decoration details — which way a door faces, what
// hue a banner is — and an event author is choosing neither. Only the class
// name is, because that is what the plugin constructs from.
assert.deepEqual(parseDecoration('AnkhNorth 0x0004 (Hue=0x47E)'), [
{ type: 'AnkhNorth', itemId: 4 },
])
assert.deepEqual(parseDecoration('ArmsAndWeaponsPrimer 0x0FEF (Name=a life of travel)'), [
{ type: 'ArmsAndWeaponsPrimer', itemId: 0x0fef },
])
})
test('parseDecoration: a negative z on a placement line is not mistaken for a type', () => {
// The real trap in this format: a coordinate line starts with a digit OR a
// minus, so "not a comment" is not the test. A z of -12 is ordinary in every
// dungeon file in the tree.
assert.deepEqual(parseDecoration(`Static 0x07A4
5558 1826 -12
-5 -5 -5
`), [{ type: 'Static', itemId: 0x07a4 }])
})
test('parseDecoration: empty, comment-only and absent input all yield nothing', () => {
assert.deepEqual(parseDecoration(''), [])
assert.deepEqual(parseDecoration(null), [])
assert.deepEqual(parseDecoration('# nothing but a comment\n\n'), [])
})

View File

@@ -39,11 +39,27 @@ function writeTree(root, { facets = ['Sosaria'], includeChampions = true } = {})
fs.mkdirSync(path.join(root, 'Data', 'Locations'), { recursive: true })
fs.mkdirSync(path.join(root, 'Config'), { recursive: true })
// Decoration, NESTED, because the real tree nests two deep in places
// (`Magincia/Trammel`, `Stygian Abyss/Ter Mur`) and a flat read would index a
// fraction of it while looking like it worked.
fs.mkdirSync(path.join(root, 'Data', 'Decoration', 'Deep', 'Deeper'), { recursive: true })
fs.writeFileSync(
path.join(root, 'Data', 'Decoration', 'top.cfg'),
'# a brazier\nBrazier 0x0E31\n100 100 0\n200 200 -5\n\nStatic 0x108F\n300 300 0\n',
'utf8',
)
fs.writeFileSync(
path.join(root, 'Data', 'Decoration', 'Deep', 'Deeper', 'nested.cfg'),
'Brazier 0x0E31\n400 400 0\nLargeCrate 0x0E3C\n500 500 0\n',
'utf8',
)
for (const facet of facets) {
fs.writeFileSync(
path.join(root, 'Spawns', `${facet}.xml`),
`<Spawns>
<Points><Name>${facet}A</Name><Map>${facet}</Map><X>1100</X><Y>1100</Y>
<Points><Name>${facet}A</Name><UniqueId>uid-${facet}-A</UniqueId>
<Map>${facet}</Map><X>1100</X><Y>1100</Y>
<MaxCount>3</MaxCount><IsRunning>True</IsRunning>
<Objects2>Lizardman:MX=3:SB=0:OBJ=Orc:MX=1:SB=0</Objects2></Points>
<Points><Name>${facet}B</Name><Map>${facet}</Map><X>9000</X><Y>9000</Y>
@@ -97,6 +113,29 @@ function tempTree(options) {
// ── buildAtlas against a custom-facet tree ─────────────────────────────────
test('buildAtlas: a point keeps the UniqueId a property lease targets', () => {
// The field is asserted on the AGGREGATOR's output, not the parser's, which is
// the whole point of this test. `parsePoints` produced it from Phase 12b
// onwards and `PARSER_VERSION`'s own note said a point kept it, while the
// mapping in `buildAtlas` rebuilt each point from an explicit field list that
// omitted it — so `shard_spawn_points.unique_id` was NULL on every row, and
// `listSpawners`, whose WHERE is `unique_id IS NOT NULL`, answered empty. That
// left `uo.options.spawners` an empty dropdown and every Phase 12b
// object-property lease unauthorable. Found by the Phase 16b released-artefact
// walk, against a real tree whose files carry ~6,400 of these.
//
// The fixture above had no <UniqueId> at all until this test, which is exactly
// why a green suite said nothing about it.
const root = tempTree({ facets: ['Sosaria'] })
const atlas = buildAtlas(root)
const named = atlas.points.find((p) => p.name === 'SosariaA')
assert.equal(named.uniqueId, 'uid-Sosaria-A')
// And a point whose file names none is absent rather than empty-string, so the
// DB layer's `unique_id IS NOT NULL AND <> ''` reads it the same way either way.
const unnamed = atlas.points.find((p) => p.name === 'SosariaB')
assert.ok(!unnamed.uniqueId)
})
test('buildAtlas: works entirely on facets that do not exist in stock UO', () => {
const root = tempTree({ facets: ['Sosaria', 'Underdark'] })
const atlas = buildAtlas(root)
@@ -397,3 +436,81 @@ test('refresh: an explicit path overrides the configured one', async () => {
assert.equal(result.status, 'imported')
assert.deepEqual(result.addedFacets, ['Override'])
})
// ── The decoration index (Phase 12a) ────────────────────────
test('decoration is read recursively and rolled up per type', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'atlas-decor-'))
try {
writeTree(root)
const atlas = buildAtlas(root)
// Sorted by type, and `uses` counts every header line across the whole tree
// — the nested file's Brazier is the second use of the same type, not a
// second type.
assert.deepEqual(atlas.decor, [
{ type: 'Brazier', itemId: 0x0e31, uses: 2 },
{ type: 'LargeCrate', itemId: 0x0e3c, uses: 1 },
{ type: 'Static', itemId: 0x108f, uses: 1 },
])
assert.equal(atlas.meta.counts.decor, 3)
// Every decoration file is fingerprinted like every other source, so an
// operator editing one is a tree change the boot path notices.
const labels = Object.keys(atlas.meta.source).filter((l) => l.startsWith('Data/Decoration/'))
assert.deepEqual(labels.sort(), ['Data/Decoration/Deep/Deeper/nested.cfg', 'Data/Decoration/top.cfg'])
} finally {
fs.rmSync(root, { recursive: true, force: true })
}
})
test('two spellings of one decoration type fold into one row', () => {
// The Phase 16 acceptance walk's blocking finding. Stock ServUO 57.4's own
// `Data/Decoration/` names four types under two casings each —
// CheckerBoard/Checkerboard, ChessBoard/Chessboard, MetalChest/Metalchest,
// SpinningWheelEastAddon/SpinningwheelEastAddon — and in every pair exactly one
// is a real class; the other is a mis-cased line the shard's own loader resolves
// anyway.
//
// A case-SENSITIVE Map keeps both. `shard_decor_types.type` is a PRIMARY KEY
// under MariaDB's default `..._ai_ci` collation, which folds case, so the second
// row raised `1062 Duplicate entry` and took the WHOLE atlas import transaction
// down with it. The blast radius is not decoration: with no atlas, EVERY option
// source answers empty and no world verb can be authored at all.
//
// Asserted on the count as well as the row, because the failure mode was two
// rows that a database — not this function — would later refuse.
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'atlas-decorcase-'))
try {
writeTree(root)
fs.writeFileSync(
path.join(root, 'Data', 'Decoration', 'miscased.cfg'),
'checkerboard 0x0FA6\n600 600 0\nCheckerBoard 0x0FA6\n700 700 0\n',
)
const atlas = buildAtlas(root)
const boards = atlas.decor.filter((d) => d.type.toLowerCase() === 'checkerboard')
assert.equal(boards.length, 1, 'two casings of one type must not be two rows')
// First spelling seen wins, exactly as the first item id does. Which one
// survives is cosmetic — the shard resolves either.
assert.equal(boards[0].type, 'checkerboard')
assert.equal(boards[0].uses, 2, 'both lines still count as uses of the one type')
} finally {
fs.rmSync(root, { recursive: true, force: true })
}
})
test('a tree with no decoration at all still builds', () => {
// Optional, like the champion file. A shard that has stripped its decoration
// has a perfectly good atlas; the decoration verb simply has nothing to offer.
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'atlas-nodecor-'))
try {
writeTree(root)
fs.rmSync(path.join(root, 'Data', 'Decoration'), { recursive: true, force: true })
const atlas = buildAtlas(root)
assert.deepEqual(atlas.decor, [])
assert.equal(atlas.meta.counts.decor, 0)
} finally {
fs.rmSync(root, { recursive: true, force: true })
}
})

View File

@@ -0,0 +1,406 @@
// module-uo's Team provider (docs/website/TEAMS.md §2.3, MODULE_API.md 1.6.0).
//
// The tests that matter here are the REFUSALS. Core's contract is that module
// unavailability becomes staleness and never emptiness, and this module is the
// only thing that can honour it — an empty array from here is read as an
// authoritative "there are none", and core makes destructive decisions from an
// authoritative answer. Every state where this module cannot honestly claim to
// know is asserted below, because each one is a plausible place for someone to
// later "simplify" the guard away and get a plausible-looking empty list.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const { test, beforeEach, afterEach } = require('node:test')
const assert = require('node:assert/strict')
const core = require('../core')
// The provider reaches the database through core, which is initialised with a ctx
// in production. A minimal one is enough here — the db layer is stubbed anyway.
core.init({
db: { query: async () => [] },
log: () => ({ error() {}, warn() {}, info() {}, debug() {} }),
moduleId: 'uo',
})
const db = require('../model/teamProvider/teamProvider.db')
const uoLinkConfig = require('../model/uoLinkConfig/uoLinkConfig.model')
const uoLinkSocket = require('../utils/uoLinkSocket')
const clilocs = require('../model/shardClilocs/shardClilocs.model')
const visibility = require('../utils/shardVisibility')
const provider = require('../model/teamProvider/teamProvider.model')
const saved = []
function patch(mod, name, fn) {
saved.push([mod, name, mod[name]])
mod[name] = fn
}
// The healthy default: configured, enabled, connected. Each test then breaks only
// the thing it is about.
function healthy() {
patch(uoLinkConfig, 'getSafe', async () => ({ baseUrl: 'http://127.0.0.1:7787', enabled: true }))
patch(uoLinkSocket, 'getState', () => ({ connected: true, running: true }))
// An operator who has never run the client extraction — the default. The standard
// rank names must still resolve from the fallback table.
patch(clilocs, 'resolveMany', async () => new Map())
patch(db, 'listGuildLeaders', async () => [])
}
const guild = (extra = {}) => ({
id: 1, name: 'The Silver Hand', abbr: 'TSH', alliance: null,
members: 2, online: 1, leader_serial: '0x1', leader_name: 'Aldric', leader_acct: 'aldric', ...extra,
})
const member = (extra = {}) => ({
serial: '0x1', name: 'Aldric', acct: 'aldric', web_id: null, is_player: 1,
rank: 1, rank_cliloc: 1062962, rank_name: null,
linked_user_id: null, is_online: 0, ...extra,
})
beforeEach(healthy)
afterEach(() => {
while (saved.length) {
const [mod, name, fn] = saved.pop()
mod[name] = fn
}
})
// ── The refusals ───────────────────────────────────────────────────────────
test('no uo-link configured refuses, on all three methods', async () => {
patch(uoLinkConfig, 'getSafe', async () => ({ baseUrl: null, enabled: false }))
patch(db, 'listGuilds', async () => { throw new Error('must not be read') })
for (const answer of [await provider.getTeams(), await provider.getTeamMembers('1'), await provider.getTeamLeaders('1')]) {
assert.equal(answer.ok, false)
assert.match(answer.reason, /no uo-link configured/)
assert.equal(answer.teams, undefined)
assert.equal(answer.members, undefined)
}
})
test('a disabled integration refuses rather than reporting a frozen board', async () => {
patch(uoLinkConfig, 'getSafe', async () => ({ baseUrl: 'http://x', enabled: false }))
const answer = await provider.getTeams()
assert.equal(answer.ok, false)
assert.match(answer.reason, /disabled/)
})
test('a disconnected socket refuses, even though the board is still there', async () => {
// The tempting mistake, stated as a test: the board is durable and survives an
// outage, so serving it looks harmless. Core cannot tell a board five minutes
// stale from one five days stale, and it archives Teams and departs members
// from a complete answer.
patch(uoLinkSocket, 'getState', () => ({ connected: false, running: true }))
patch(db, 'listGuilds', async () => [guild()])
const answer = await provider.getTeams()
assert.equal(answer.ok, false)
assert.match(answer.reason, /not connected/)
assert.equal(answer.teams, undefined, 'a stale board must not arrive as authoritative')
})
test('a database error refuses instead of throwing at core', async () => {
patch(db, 'listGuilds', async () => { throw new Error('table gone') })
const answer = await provider.getTeams()
assert.equal(answer.ok, false)
assert.match(answer.reason, /table gone/)
})
test('a guild absent from the board refuses rather than reporting an empty roster', async () => {
patch(db, 'findGuild', async () => [])
const members = await provider.getTeamMembers('99')
assert.equal(members.ok, false)
assert.match(members.reason, /not on the board/)
const leaders = await provider.getTeamLeaders('99')
assert.equal(leaders.ok, false)
})
test('a roster that has not arrived yet refuses — the board count is what tells us', async () => {
// Protocol 4's roster arrives on its own frames, separately from the
// guild.update that creates the board row, so there is a real window where a
// 155-member guild has no roster rows. Reporting that as an empty roster would
// depart every member.
patch(db, 'findGuild', async () => [guild({ members: 155 })])
patch(db, 'listGuildMembers', async () => [])
const answer = await provider.getTeamMembers('1')
assert.equal(answer.ok, false)
assert.match(answer.reason, /has not arrived yet/)
assert.match(answer.reason, /155/, 'the count is in the message, because it is the evidence')
})
test('a guild the board says is genuinely empty reports an empty roster', async () => {
// The other side of the same coin: when the board itself says zero, an empty
// roster is the truth and withholding it would freeze a disbanding guild's
// membership forever.
patch(db, 'findGuild', async () => [guild({ members: 0 })])
patch(db, 'listGuildMembers', async () => [])
const answer = await provider.getTeamMembers('1')
assert.equal(answer.ok, true)
assert.deepEqual(answer.members, [])
})
// ── The good answers ───────────────────────────────────────────────────────
test('a guild becomes a Team keyed on its persistent ServUO id', async () => {
// The id survives a rename, which is what lets core apply its rename rule
// instead of seeing an unrelated new guild.
patch(db, 'listGuilds', async () => [guild()])
const answer = await provider.getTeams()
assert.equal(answer.ok, true)
assert.equal(answer.complete, true)
assert.deepEqual(answer.teams, [
{ externalId: '1', name: 'The Silver Hand', abbr: 'TSH', meta: null },
])
})
test('an alliance rides along as opaque meta', async () => {
patch(db, 'listGuilds', async () => [guild({ alliance: 'The Concord' })])
const { teams } = await provider.getTeams()
assert.deepEqual(teams[0].meta, { alliance: 'The Concord' })
})
test('the external id is a string, so core never compares a number to one', async () => {
patch(db, 'listGuilds', async () => [guild({ id: 42 })])
const { teams } = await provider.getTeams()
assert.equal(teams[0].externalId, '42')
})
test('a roster maps to the member shape core expects', async () => {
patch(db, 'findGuild', async () => [guild()])
patch(db, 'listGuildMembers', async () => [
member({ serial: '0x1', name: 'Aldric', rank: 4, rank_cliloc: 1062959, is_online: 1 }),
member({ serial: '0x2', name: 'Bree', acct: null, rank: 1, is_online: 0 }),
])
const { members } = await provider.getTeamMembers('1')
assert.equal(members.length, 2)
assert.equal(members[0].memberKey, '0x1')
assert.equal(members[0].displayName, 'Aldric')
assert.equal(members[0].online, true)
assert.equal(members[0].leader, true, 'rank 4 is Leader')
assert.equal(members[1].leader, false)
assert.equal(members[1].online, false)
})
// ── Rank (the Protocol 4 amendment) ────────────────────────────────────────
test('several members can be leaders at once', async () => {
// The whole reason the wire grew a per-member rank: the board carries one
// leader_serial, so before this only a single leader could ever be reported.
patch(db, 'findGuild', async () => [guild()])
patch(db, 'listGuildMembers', async () => [
member({ serial: '0x1', rank: 4 }),
member({ serial: '0x2', rank: 4 }),
member({ serial: '0x3', rank: 3 }),
])
const { members } = await provider.getTeamMembers('1')
assert.deepEqual(members.filter((m) => m.leader).map((m) => m.memberKey), ['0x1', '0x2'])
})
test('getTeamLeaders returns everyone at rank 4, not just the boards one', async () => {
patch(db, 'findGuild', async () => [guild({ leader_serial: '0x1' })])
patch(db, 'listGuildLeaders', async () => [{ serial: '0x1' }, { serial: '0x2' }])
assert.deepEqual((await provider.getTeamLeaders('1')).leaders, ['0x1', '0x2'])
})
test('the boards leader is kept even when no roster row has rank yet', async () => {
// A shard whose roster has not been re-emitted since the amendment has no ranks
// stored. The founder-leader comes from a different frame and must not be lost
// by moving to ranks.
patch(db, 'findGuild', async () => [guild({ leader_serial: '0x9' })])
patch(db, 'listGuildLeaders', async () => [])
assert.deepEqual((await provider.getTeamLeaders('1')).leaders, ['0x9'])
})
test('the boards leader is not duplicated when they also hold rank 4', async () => {
patch(db, 'findGuild', async () => [guild({ leader_serial: '0x1' })])
patch(db, 'listGuildLeaders', async () => [{ serial: '0x1' }, { serial: '0x2' }])
const { leaders } = await provider.getTeamLeaders('1')
assert.equal(new Set(leaders).size, leaders.length)
})
test('a NULL rank is not a leader — "not known" is not "leads this guild"', async () => {
// The shard withholds the rank for a staff account, because ServUO's GuildRank
// getter reports Leader for anyone at GameMaster or above whatever their real
// rank. Reading the absence as leadership would republish exactly that lie.
patch(db, 'findGuild', async () => [guild()])
patch(db, 'listGuildMembers', async () => [member({ serial: '0x1', rank: null, rank_cliloc: null })])
const { members } = await provider.getTeamMembers('1')
assert.equal(members[0].leader, false)
assert.equal(members[0].rankLabel, null)
})
test('a standard rank resolves to its name without a cliloc table', async () => {
// The operator may never have run the client extraction, and a roster should
// still read "Warlord" rather than nothing.
patch(db, 'findGuild', async () => [guild()])
patch(db, 'listGuildMembers', async () => [
member({ serial: '0x1', rank: 4, rank_cliloc: 1062959 }),
member({ serial: '0x2', rank: 3, rank_cliloc: 1062960 }),
member({ serial: '0x3', rank: 0, rank_cliloc: 1062963 }),
])
const { members } = await provider.getTeamMembers('1')
assert.deepEqual(members.map((m) => m.rankLabel), ['Leader', 'Warlord', 'Ronin'])
})
test('the operators cliloc table wins over the built-in names', async () => {
// A localised or edited client should name the ranks, not this module's English
// fallback.
patch(clilocs, 'resolveMany', async () => new Map([[1062960, 'Kriegsherr']]))
patch(db, 'findGuild', async () => [guild()])
patch(db, 'listGuildMembers', async () => [member({ serial: '0x1', rank: 3, rank_cliloc: 1062960 })])
assert.equal((await provider.getTeamMembers('1')).members[0].rankLabel, 'Kriegsherr')
})
test('a custom ranks literal name beats both', async () => {
// A shard that replaced RankDefinition.Ranks sends a string instead of a cliloc,
// and its own naming has to survive.
patch(clilocs, 'resolveMany', async () => new Map([[1062960, 'Warlord']]))
patch(db, 'findGuild', async () => [guild()])
patch(db, 'listGuildMembers', async () => [
member({ serial: '0x1', rank: 3, rank_cliloc: 1062960, rank_name: 'Sword-Captain' }),
])
assert.equal((await provider.getTeamMembers('1')).members[0].rankLabel, 'Sword-Captain')
})
test('a failing cliloc lookup falls back rather than failing the roster', async () => {
patch(clilocs, 'resolveMany', async () => { throw new Error('cliloc table missing') })
patch(db, 'findGuild', async () => [guild()])
patch(db, 'listGuildMembers', async () => [member({ serial: '0x1', rank: 3, rank_cliloc: 1062960 })])
const answer = await provider.getTeamMembers('1')
assert.equal(answer.ok, true, 'a label is decoration; losing it must not lose the roster')
assert.equal(answer.members[0].rankLabel, 'Warlord')
})
test('an unknown cliloc leaves the label null rather than inventing one', async () => {
patch(db, 'findGuild', async () => [guild()])
patch(db, 'listGuildMembers', async () => [member({ serial: '0x1', rank: 2, rank_cliloc: 9999999 })])
assert.equal((await provider.getTeamMembers('1')).members[0].rankLabel, null)
})
test('a member with no account at all is fine and unlinked', async () => {
// §2.3 of the protocol spec: acct is genuinely optional — a PlayerMobile can
// have no Account, and the local test world contains such mobiles.
patch(db, 'findGuild', async () => [guild()])
patch(db, 'listGuildMembers', async () => [member({ acct: null, web_id: null, linked_user_id: null })])
const { members } = await provider.getTeamMembers('1')
assert.equal(members[0].userId, null)
})
test('userId comes from the rosters web_id first, then the link table', async () => {
patch(db, 'findGuild', async () => [guild()])
patch(db, 'listGuildMembers', async () => [
member({ serial: '0xA', web_id: '7', linked_user_id: 99 }), // roster wins
member({ serial: '0xB', web_id: null, linked_user_id: 12 }), // fallback
member({ serial: '0xC', web_id: '0', linked_user_id: null }), // neither
])
const { members } = await provider.getTeamMembers('1')
assert.equal(members[0].userId, 7, 'what the shard itself asserted at roster time')
assert.equal(members[1].userId, 12, 'the fallback for a row that predates the link')
assert.equal(members[2].userId, null)
})
test('web_id arrives as a string from the wire and is coerced', async () => {
patch(db, 'findGuild', async () => [guild()])
patch(db, 'listGuildMembers', async () => [member({ web_id: '42' })])
const { members } = await provider.getTeamMembers('1')
assert.equal(members[0].userId, 42)
assert.equal(typeof members[0].userId, 'number')
})
test('a guild with no leader anywhere reports none rather than guessing', async () => {
patch(db, 'findGuild', async () => [guild({ leader_serial: null })])
patch(db, 'listGuildLeaders', async () => [])
const answer = await provider.getTeamLeaders('1')
assert.equal(answer.ok, true)
assert.deepEqual(answer.leaders, [])
})
test('an empty board is an authoritative empty list — the shard really has no guilds', async () => {
// Distinct from every refusal above: the socket is connected and the board is
// readable, so "no guilds" is a fact. Core still quarantines it before acting.
patch(db, 'listGuilds', async () => [])
const answer = await provider.getTeams()
assert.equal(answer.ok, true)
assert.deepEqual(answer.teams, [])
})
// ── projectRoster (TEAMS.md §3.3) ──────────────────────────────────────────
//
// The refusal semantics INVERT here and that is the point of these tests. For
// the three methods above, a refusal means "change nothing" and an empty array
// would be destructive. For this one, core fails CLOSED — a refusal withholds the
// roster — so the dangerous answer is the opposite: returning every key because
// the config could not be read would publish a roster an operator gated to staff.
const rows = [{ member_key: '0x1' }, { member_key: '0x2' }]
function guilds(feature) {
patch(visibility, 'getConfig', async () => ({ guilds: feature }))
}
test('a viewer at or above the audience sees every row', async () => {
guilds({ enabled: true, audience: 'anonymous' })
const answer = await provider.projectRoster('1', rows, null)
assert.equal(answer.ok, true)
assert.deepEqual(answer.members, ['0x1', '0x2'])
})
test('a viewer below the audience sees none — authoritatively, not as a refusal', async () => {
// `ok: true` with an empty list is the correct answer here: this module KNOWS
// the viewer may see nothing. Core renders an empty roster rather than an
// error, which is what a gated shard is supposed to look like.
guilds({ enabled: true, audience: 'staff' })
const answer = await provider.projectRoster('1', rows, { userId: 7, role: 'player' })
assert.equal(answer.ok, true)
assert.deepEqual(answer.members, [])
})
test('an admin clears every audience', async () => {
guilds({ enabled: true, audience: 'admin' })
const answer = await provider.projectRoster('1', rows, { userId: 1, role: 'admin' })
assert.deepEqual(answer.members, ['0x1', '0x2'])
})
test('a disabled guilds feature hides the roster from everyone, staff included', async () => {
// The switch means "this shard does not publish guild data", not "publish it
// quietly to staff".
guilds({ enabled: false, audience: 'anonymous' })
const answer = await provider.projectRoster('1', rows, { userId: 1, role: 'admin' })
assert.equal(answer.ok, true)
assert.deepEqual(answer.members, [])
})
test('an unreadable visibility config REFUSES rather than publishing', async () => {
// The inversion, stated. Core reads this as "withhold", which is the only safe
// reading of "I could not work out who is allowed to look".
patch(visibility, 'getConfig', async () => { throw new Error('pool down') })
const answer = await provider.projectRoster('1', rows, null)
assert.equal(answer.ok, false)
assert.match(answer.reason, /visibility could not be resolved/)
})
test('an absent viewer is anonymous, not an error', async () => {
guilds({ enabled: true, audience: 'logged_in' })
const answer = await provider.projectRoster('1', rows, null)
assert.equal(answer.ok, true)
assert.deepEqual(answer.members, [], 'anonymous does not meet logged_in')
})
test('rows with no member key are dropped rather than answered as blanks', async () => {
guilds({ enabled: true, audience: 'anonymous' })
const answer = await provider.projectRoster('1', [{ member_key: '0x1' }, { member_key: null }], null)
assert.deepEqual(answer.members, ['0x1'])
})

View File

@@ -0,0 +1,957 @@
// module-uo's event verbs, wave 1 (EVENTS_PLAN.md Phase 9).
//
// The declarations are data plus three `perform()`s, so most of this suite is
// about the *shapes* core will check and the failure paths a live rig cannot be
// made to produce on demand — a sidecar that answers 409, a shard that restarts
// between two steps, a crier line one character over the cap.
//
// **The first test is the one the whole phase rests on.** Every other property
// here — "a broadcast is sent once", "a failed post is retried" — is a claim
// about what the MODULE decided, and the module only gets to decide when its
// client answers before core's dispatch deadline. Assert the relationship, not
// the numbers, or the day someone tunes one of them the suite stays green while
// the behaviour inverts.
const { test, beforeEach, afterEach } = require('node:test')
const assert = require('node:assert/strict')
const uoLinkClient = require('../utils/uoLinkClient')
const uoLinkConfig = require('../model/uoLinkConfig/uoLinkConfig.model')
const shardAtlas = require('../model/shardAtlas/shardAtlas.model')
require('./_setup')
const actions = require('../config/uoEventActions')
const byId = (id) => actions.ACTIONS.find((a) => a.id === id)
let calls
const saved = {}
beforeEach(() => {
calls = {
broadcast: [], crier: [], crierDel: [], news: [], newsDel: [],
spawn: [], despawn: [], owned: [],
}
for (const name of [
'adminBroadcast', 'postTownCrier', 'deleteTownCrier', 'postNews', 'deleteNews',
'spawnWorld', 'ownedWorld', 'despawnWorld',
]) {
saved[name] = uoLinkClient[name]
}
saved.getSafe = uoLinkConfig.getSafe
saved.listRegions = shardAtlas.listRegions
saved.listLandmarks = shardAtlas.listLandmarks
saved.searchCreatures = shardAtlas.searchCreatures
saved.listDecorTypes = shardAtlas.listDecorTypes
saved.getDecorType = shardAtlas.getDecorType
uoLinkClient.adminBroadcast = async (b) => { calls.broadcast.push(b); return { ok: true, status: 200 } }
uoLinkClient.postTownCrier = async (b) => { calls.crier.push(b); return { ok: true, status: 200 } }
uoLinkClient.deleteTownCrier = async (id) => { calls.crierDel.push(id); return { ok: true, status: 200 } }
uoLinkClient.postNews = async (b) => { calls.news.push(b); return { ok: true, status: 200 } }
uoLinkClient.deleteNews = async (id) => { calls.newsDel.push(id); return { ok: true, status: 200 } }
// Phase 12a. Two serials back by default, so a spawn produces a resource list
// longer than one and the per-serial ledger shape is what the suite exercises.
uoLinkClient.spawnWorld = async (b) => {
calls.spawn.push(b)
const n = b.count || 1
return {
ok: true,
status: 200,
data: { serials: Array.from({ length: n }, (_, i) => `0x4000000${i}`) },
}
}
uoLinkClient.ownedWorld = async (b) => {
calls.owned.push(b)
return { ok: true, status: 200, data: { owned: [{ serial: '0x40000000', what: 'creature' }] } }
}
uoLinkClient.despawnWorld = async (b) => {
calls.despawn.push(b)
return { ok: true, status: 200, data: { removed: b.serials || [], gone: [], refused: [] } }
}
uoLinkConfig.getSafe = async () => ({ bootId: 'boot-1' })
// Phase 11b. `uo.participation.open` resolves its `place` param against the
// atlas, so the dry-run sweep below reaches this rather than the database.
// Two landmarks, because Phase 12a's gate verb resolves a SECOND place: its
// destination. One would make the dry-run sweep below pass for the wrong
// reason, by never exercising the leg that can name a different point.
shardAtlas.listLandmarks = async () => [
{ facet: 'Felucca', name: 'Britain', x: 1496, y: 1628, z: 10 },
{ facet: 'Felucca', name: 'Yew', x: 542, y: 982, z: 0 },
]
shardAtlas.listDecorTypes = async () => [{ type: 'Brazier', itemId: 0x0E31, uses: 42 }]
shardAtlas.getDecorType = async (type) =>
type === 'Brazier' ? { type: 'Brazier', itemId: 0x0E31, uses: 42 } : null
})
afterEach(() => {
for (const name of ['adminBroadcast', 'postTownCrier', 'deleteTownCrier', 'postNews', 'deleteNews']) {
uoLinkClient[name] = saved[name]
}
uoLinkConfig.getSafe = saved.getSafe
shardAtlas.listRegions = saved.listRegions
shardAtlas.listLandmarks = saved.listLandmarks
shardAtlas.searchCreatures = saved.searchCreatures
shardAtlas.listDecorTypes = saved.listDecorTypes
shardAtlas.getDecorType = saved.getDecorType
for (const name of ['spawnWorld', 'ownedWorld', 'despawnWorld']) {
uoLinkClient[name] = saved[name]
}
})
// ── The rule everything else depends on ────────────────────────────────────
test('every action outlives the sidecar client, so the module classifies its own failures', () => {
// `dispatch.classify()` answers `retry` for a budget timeout unconditionally
// and never asks the action. If core's deadline can fire before the client
// gives up, `retry: false` below is unreachable and a broadcast is retried.
for (const action of actions.ACTIONS) {
assert.ok(
action.budgetMs > uoLinkClient.TIMEOUT_MS,
`${action.id} budgetMs (${action.budgetMs}) must exceed uoLinkClient.TIMEOUT_MS (${uoLinkClient.TIMEOUT_MS})`,
)
}
})
// ── The declarations, against the checks core will run ─────────────────────
test('the declarations satisfy the shape core validates them with', () => {
const RISKS = ['notify', 'inspect', 'change', 'irreversible']
const REVERSIBLE = ['none', 'self', 'ledger', 'override']
const PARAM_TYPES = ['string', 'int', 'float', 'boolean', 'datetime', 'url']
for (const a of actions.ACTIONS) {
assert.ok(a.id.startsWith('uo.'), `${a.id} must be namespaced to this module`)
assert.ok(a.label && a.description, `${a.id} needs a label and a description`)
assert.ok(RISKS.includes(a.risk), `${a.id} has an unknown risk class`)
assert.ok(REVERSIBLE.includes(a.reversible), `${a.id} has an unknown reversible class`)
assert.equal(typeof a.perform, 'function')
// `revert` is required iff ledger, and forbidden otherwise — a revert on a
// non-ledgering action is an undo core will never call.
assert.equal(
typeof a.revert === 'function',
a.reversible === 'ledger',
`${a.id} revert() must be present exactly when reversible is 'ledger'`,
)
// `reconcile` is optional, but only meaningful where something is ledgered.
if (a.reconcile !== undefined) {
assert.equal(typeof a.reconcile, 'function')
assert.ok(a.reversible === 'ledger' || a.reversible === 'override', `${a.id} reconciles but ledgers nothing`)
}
if (a.cost !== undefined) assert.equal(typeof a.cost, 'function')
const names = new Set()
for (const p of a.params) {
assert.ok(!names.has(p.name), `${a.id} declares ${p.name} twice`)
names.add(p.name)
assert.ok(PARAM_TYPES.includes(p.type), `${a.id}.${p.name} has an unsupported type "${p.type}"`)
// Required on every param including the optional ones: it is the authoring
// placeholder, and an unattended world write typed into a blank box is how
// a typo gets scheduled.
assert.ok(
p.example !== undefined && p.example !== null && p.example !== '',
`${a.id}.${p.name} needs an example`,
)
assert.ok(p.description, `${a.id}.${p.name} needs a description`)
}
}
})
test('every dimension a cost names is one this module declares', () => {
const declared = new Set(actions.BUDGETS.map((b) => b.id))
// Phase 12a's six and Phase 12b's seventh are all the MODULE's (org lead,
// 2026-09-07): core meters what a module declares and holds no UO knowledge, so
// a `uo.` dimension core knew about would be a leak of this game into the engine.
//
// `uo.rewards` counts ITEMS rather than grants: a step giving 500 gold to forty
// people and one giving a candle to forty people are not the same imposition, and
// a count of grants would price them identically.
assert.deepEqual(
[...declared],
[
'uo.broadcasts',
'uo.creatures',
'uo.bosses',
'uo.npcs',
'uo.decor',
'uo.gate.minutes',
'uo.rewards',
],
)
for (const b of actions.BUDGETS) {
assert.ok(b.id.startsWith('uo.'), 'a budget dimension must be namespaced')
assert.ok(b.label && b.unit, 'a dimension is rendered as a label and a unit beside a number')
}
// Every dimension a cost names must be one the module declared, or core is
// asked to bound something nothing defines.
const cost = byId('uo.broadcast').cost({})
assert.deepEqual(cost, { 'uo.broadcasts': 1 })
for (const id of Object.keys(cost)) assert.ok(declared.has(id), `${id} is spent but never declared`)
// The keyed verbs deliberately spend nothing: a repeat REPLACES under the same
// id, so there is no runaway for a cap to bound.
assert.equal(byId('uo.towncrier.post').cost, undefined)
assert.equal(byId('uo.news.post').cost, undefined)
// Phase 12a. Asserted across EVERY action rather than one at a time, because
// the failure this catches is a typo in one dimension name out of six, which
// core answers by refusing the whole registration at load.
for (const action of actions.ACTIONS) {
if (typeof action.cost !== 'function') continue
const params = {}
for (const p of action.params) params[p.name] = p.example
for (const id of Object.keys(action.cost(params))) {
assert.ok(declared.has(id), `${action.id} spends "${id}", which nothing declares`)
}
}
// A gate is priced in MINUTES, not in gates. One standing all day and twelve
// standing five minutes each are not the same imposition on a world, and a
// count would price them identically.
assert.deepEqual(byId('uo.gate.open').cost({ durationMinutes: 120 }), { 'uo.gate.minutes': 120 })
assert.deepEqual(byId('uo.creature.spawn').cost({ count: 8 }), { 'uo.creatures': 8 })
})
// ── uo.broadcast: retried, because protocol 6 made that safe ───────────────
test('a broadcast is retried on a transient failure and never on a permanent one', async () => {
const broadcast = byId('uo.broadcast')
// Wave 1 asserted the opposite of this — every failure terminal, including the
// two that are plainly transient — because nothing on the wire could stop a
// retry announcing to everyone twice. Protocol 6 puts an idempotency key on the
// command and the shard refuses the repeat, so the trade that test recorded is
// no longer one that has to be made.
//
// 425 is the new status in this list: `bridge.busy`, the shard saying a command
// under this key is still in flight. Transient by construction.
const TRANSIENT = new Set([0, 425, 503, 504])
for (const status of [0, 400, 401, 403, 409, 425, 503, 504]) {
uoLinkClient.adminBroadcast = async () => ({ ok: false, status, error: `status ${status}` })
const result = await broadcast.perform({ runId: 7, params: { text: 'hear ye' }, verify: false })
assert.equal(result.ok, false)
assert.equal(result.retry, TRANSIENT.has(status), `a ${status} retries iff it is transient`)
}
})
test('every write carries the step idempotency key, unchanged', async () => {
// The key is what makes the retry above safe, so a verb that dropped it would
// silently restore the wave-1 hazard while every other assertion still passed.
// Asserted per verb rather than once, because each builds its own body.
const KEY = 'a'.repeat(40)
const seen = {}
uoLinkClient.adminBroadcast = async (body) => { seen.broadcast = body; return { ok: true } }
uoLinkClient.postTownCrier = async (body) => { seen.crier = body; return { ok: true } }
uoLinkClient.postNews = async (body) => { seen.news = body; return { ok: true } }
await byId('uo.broadcast').perform({
runId: 7, idempotencyKey: KEY, params: { text: 'hear ye' }, verify: false,
})
await byId('uo.towncrier.post').perform({
runId: 7, idempotencyKey: KEY, params: { lines: 'hear ye' }, verify: false,
})
await byId('uo.news.post').perform({
runId: 7, idempotencyKey: KEY, params: { title: 'A thing', body: 'happened' }, verify: false,
})
assert.equal(seen.broadcast.idempotencyKey, KEY)
assert.equal(seen.crier.idempotencyKey, KEY)
assert.equal(seen.news.idempotencyKey, KEY)
// The two keyed verbs post under an id DERIVED from the key. Both travel: the
// id is what makes a repeat replace, the key is what stops it re-announcing.
assert.equal(seen.crier.id, `evt-${KEY}`)
assert.equal(seen.news.id, `evt-${KEY}`)
})
test("the shard's own words reach the run log, not just a status code", async () => {
// **The rig found this.** The sidecar refuses a broadcast with
// `{"reason":"admin write plane disabled"}` and `legError` looks for
// `data.message`, so the run console read "sidecar responded 403" for a cause
// the shard had already explained in a sentence. A staff member clicking a
// button knows what they switched off; an event that ran at four in the morning
// leaves the run log as the only place anyone will learn why.
uoLinkClient.adminBroadcast = async () => ({
ok: false,
status: 403,
data: { kind: 'admin.error', reason: 'admin write plane disabled' },
error: 'sidecar responded 403',
})
const result = await byId('uo.broadcast').perform({ runId: 1, params: { text: 'hear ye' }, verify: false })
assert.match(result.error, /admin write plane disabled/)
// And NOT the double-announce clause: a 403 will not succeed on any attempt, so
// pointing an operator at a policy decision misdirects them away from the
// switch they actually have to flip.
assert.doesNotMatch(result.error, /announce twice/)
assert.equal(result.retry, false)
})
test('a permanent refusal of a keyed verb is not retried either', async () => {
// Same distinction on the other side: the keyed verbs DO retry a transient, and
// must not burn three attempts on a refusal that cannot change.
uoLinkClient.postTownCrier = async () => ({ ok: false, status: 403, data: { reason: 'admin write plane disabled' } })
const result = await byId('uo.towncrier.post').perform({
runId: 1, idempotencyKey: 'k'.repeat(40), params: { lines: 'hear ye' }, verify: false,
})
assert.equal(result.retry, false)
assert.match(result.error, /admin write plane disabled/)
})
test('a broadcast names its run in the shard audit, not a staff member', async () => {
await byId('uo.broadcast').perform({ runId: 42, params: { text: 'hear ye', hue: 1153 }, verify: false })
assert.equal(calls.broadcast.length, 1)
assert.equal(calls.broadcast[0].actor, 'event:42')
assert.equal(calls.broadcast[0].hue, 1153)
})
test('an over-long broadcast is refused by the DRY RUN, before anything is sent', async () => {
const broadcast = byId('uo.broadcast')
const text = 'x'.repeat(actions.MAX_BROADCAST_LEN + 1)
const dry = await broadcast.perform({ runId: 1, params: { text }, verify: true })
assert.equal(dry.ok, false)
assert.equal(dry.retry, false)
assert.match(dry.error, new RegExp(String(actions.MAX_BROADCAST_LEN)))
const live = await broadcast.perform({ runId: 1, params: { text }, verify: false })
assert.equal(live.ok, false)
assert.deepEqual(calls.broadcast, [], 'nothing may reach the shard once the cap is breached')
})
test('a dry run sends nothing at all', async () => {
for (const action of actions.ACTIONS) {
const params = {}
for (const p of action.params) if (p.required) params[p.name] = p.example
const result = await action.perform({ runId: 1, stepId: 1, idempotencyKey: 'k'.repeat(40), params, verify: true })
assert.equal(result.ok, true, `${action.id} refused its own example params`)
assert.equal(result.resources, undefined, `${action.id} reported a resource it never created`)
}
assert.deepEqual(
[calls.broadcast.length, calls.crier.length, calls.news.length],
[0, 0, 0],
'a dry run reached the shard',
)
})
// ── The keyed verbs: one id, stable across a retry ─────────────────────────
test('the crier and the news gump post under a run-stable id a retry replaces', async () => {
const key = 'a1b2c3'.padEnd(40, '0')
await byId('uo.towncrier.post').perform({ runId: 3, idempotencyKey: key, params: { lines: 'hear ye' }, verify: false })
await byId('uo.towncrier.post').perform({ runId: 3, idempotencyKey: key, params: { lines: 'hear ye' }, verify: false })
assert.equal(calls.crier.length, 2)
assert.equal(calls.crier[0].id, calls.crier[1].id, 'a retry must replace, not stack')
assert.equal(calls.crier[0].id, `evt-${key}`)
// The sidecar's own cap on the id column.
assert.ok(calls.crier[0].id.length <= 64)
})
test('an event article cannot collide with a website post in the news gump', async () => {
// `newsGump.js` posts site articles under the bare post id and re-pushes that
// whole set on every reconnect. An event article numbered into the same space
// would silently be a collision with a post, in whichever direction wrote last.
await byId('uo.news.post').perform({
runId: 9,
idempotencyKey: 'f'.repeat(40),
params: { title: 'The Fair', body: 'Merchants gather.' },
verify: false,
})
assert.equal(calls.news.length, 1)
assert.doesNotMatch(calls.news[0].id, /^\d+$/, 'an event article must not be numbered like a post')
assert.match(calls.news[0].id, /^evt-/)
assert.match(calls.news[0].body, /<CENTER>The Fair<\/CENTER>/)
assert.equal(calls.news[0].announce, true, 'announce defaults on, as the gump does')
})
test('the keyed verbs DO retry, because a repeat replaces', async () => {
for (const [id, stub] of [['uo.towncrier.post', 'postTownCrier'], ['uo.news.post', 'postNews']]) {
const params = { lines: 'hear ye', title: 'The Fair', body: 'Merchants gather.' }
// The announce leg's own classification of this transport, reused rather
// than re-decided: a config or data problem is terminal, the rest transient.
for (const [status, retry] of [[400, false], [401, false], [403, false], [409, false], [503, true], [504, true], [0, true]]) {
uoLinkClient[stub] = async () => ({ ok: false, status, error: `status ${status}` })
const result = await byId(id).perform({ runId: 1, idempotencyKey: 'k'.repeat(40), params, verify: false })
assert.equal(result.ok, false)
assert.equal(result.retry, retry, `${id} misclassified a ${status}`)
}
}
})
test('a crier post is refused before it is sent when it is not eight short lines', async () => {
const crier = byId('uo.towncrier.post')
const cases = [
['', /empty/],
[' \n ', /empty/],
[Array.from({ length: actions.MAX_CRIER_LINES + 1 }, (_, i) => `line ${i}`).join('\n'), /criers carry/],
['x'.repeat(actions.MAX_CRIER_LINE_LEN + 1), /capped at/],
]
for (const [lines, expected] of cases) {
const result = await crier.perform({ runId: 1, idempotencyKey: 'k'.repeat(40), params: { lines }, verify: false })
assert.equal(result.ok, false)
assert.equal(result.retry, false, 'a badly shaped message is just as badly shaped next minute')
assert.match(result.error, expected)
}
assert.deepEqual(calls.crier, [])
})
test('blank lines are dropped rather than counted against the cap', () => {
// A textarea an operator has pressed enter in twice still holds two lines.
const parsed = actions.crierLines('hear ye\n\n \nseek the herald\n')
assert.equal(parsed.ok, true)
assert.deepEqual(parsed.lines, ['hear ye', 'seek the herald'])
})
test('a crier duration is taken in minutes and bounded at the sidecar cap', async () => {
const crier = byId('uo.towncrier.post')
const base = { runId: 1, idempotencyKey: 'k'.repeat(40), verify: false }
await crier.perform({ ...base, params: { lines: 'hear ye', durationMinutes: 90 } })
assert.equal(calls.crier[0].durationSec, 5400)
await crier.perform({ ...base, params: { lines: 'hear ye', durationMinutes: 60 * 48 } })
assert.equal(calls.crier[1].durationSec, 86400, 'a duration past the sidecar cap is clamped, not refused')
// Left out entirely, so the sidecar applies its own default rather than the
// module inventing one.
await crier.perform({ ...base, params: { lines: 'hear ye' } })
assert.equal(calls.crier[2].durationSec, undefined)
const bad = await crier.perform({ ...base, params: { lines: 'hear ye', durationMinutes: 'soon' } })
assert.equal(bad.ok, false)
assert.equal(bad.retry, false)
})
// ── Giving it back ─────────────────────────────────────────────────────────
test('a resource that is already gone is a successful revert', async () => {
// §L: "gone, and that is fine". A crier line whose duration ran out is a 404,
// and it is the outcome teardown wanted.
uoLinkClient.deleteTownCrier = async () => ({ ok: false, status: 404 })
uoLinkClient.deleteNews = async () => ({ ok: false, status: 404 })
for (const id of ['uo.towncrier.post', 'uo.news.post']) {
const result = await byId(id).revert({ runId: 1, resources: [{ kind: 'x', ref: 'evt-1' }] })
assert.equal(result.ok, true)
assert.ok(!result.failed || !result.failed.length)
}
})
test('a revert names the resources that did not come back', async () => {
uoLinkClient.deleteTownCrier = async (id) => {
calls.crierDel.push(id)
return id === 'evt-bad' ? { ok: false, status: 503 } : { ok: true, status: 200 }
}
const result = await byId('uo.towncrier.post').revert({
runId: 1,
resources: [{ ref: 'evt-ok' }, { ref: 'evt-bad' }],
})
// `ok: true` with a `failed` list, not `ok: false`: the group was worked, and
// one member of it is outstanding. Core keeps the row and tries it again.
assert.equal(result.ok, true)
assert.deepEqual(result.failed, ['evt-bad'])
assert.deepEqual(calls.crierDel, ['evt-ok', 'evt-bad'], 'one failure must not stop the group')
})
// ── reconcile: the boot stamp ──────────────────────────────────────────────
test('a resource stamped with the current boot is still in force', async () => {
const resources = [
{ kind: 'towncrier', ref: 'evt-a', payload: { bootId: 'boot-1' } },
{ kind: 'towncrier', ref: 'evt-b', payload: { bootId: 'boot-0' } },
]
const result = await actions.reconcileByBootId({ resources })
assert.equal(result.ok, true)
// Only the row from the boot that is still running. Core orphans the other —
// which is the honest sentence: it vanished while nobody was looking, rather
// than core having put it back.
assert.deepEqual(result.inForce, ['evt-a'])
})
test('a resource with no stamp is reported in force, because "I do not know" is not "it is gone"', async () => {
const result = await actions.reconcileByBootId({
resources: [{ ref: 'evt-old', payload: null }, { ref: 'evt-older', payload: {} }],
})
assert.deepEqual(result.inForce, ['evt-old', 'evt-older'])
})
test('with no shard boot to compare against, reconcile declines rather than orphaning everything', async () => {
uoLinkConfig.getSafe = async () => ({ bootId: null })
const result = await actions.reconcileByBootId({ resources: [{ ref: 'evt-a', payload: { bootId: 'boot-1' } }] })
// Core treats anything that is not an explicit answer as unanswered and leaves
// the ledger alone. An `ok: true, inForce: []` here would abandon every live row
// on a website that came up before its sidecar did.
assert.equal(result.ok, false)
})
test('a write with an unreadable config still happens, and simply carries no stamp', async () => {
uoLinkConfig.getSafe = async () => { throw new Error('pool is down') }
const result = await byId('uo.towncrier.post').perform({
runId: 1,
idempotencyKey: 'k'.repeat(40),
params: { lines: 'hear ye' },
verify: false,
})
assert.equal(result.ok, true, 'a config read must not fail a world write')
assert.equal(result.resources[0].payload.bootId, null)
})
// ── Option sources ─────────────────────────────────────────────────────────
const source = (id) => actions.OPTION_SOURCES.find((s) => s.id === id)
test('every option source is namespaced and answers', () => {
for (const s of actions.OPTION_SOURCES) {
assert.ok(s.id.startsWith('uo.options.'), `${s.id} must be namespaced`)
assert.ok(s.label && s.description)
assert.equal(typeof s.resolve, 'function')
}
})
test('a place is named by its facet, because two facets both have a Britain', async () => {
shardAtlas.listRegions = async () => [
{ facet: 'Felucca', name: 'Britain' },
{ facet: 'Trammel', name: 'Britain' },
]
const options = await source('uo.options.regions').resolve()
assert.equal(new Set(options.map((o) => o.value)).size, 2, 'two different places must not share a value')
assert.deepEqual(options[0], { value: 'Felucca/Britain', label: 'Britain', group: 'Felucca' })
})
test('a landmark groups by the atlas grouping where it has one, the facet otherwise', async () => {
shardAtlas.listLandmarks = async () => [
{ facet: 'Felucca', name: 'Despise', group: 'Dungeons' },
{ facet: 'Felucca', name: 'Cove', group: null },
]
const options = await source('uo.options.landmarks').resolve()
assert.deepEqual(options.map((o) => o.group), ['Dungeons', 'Felucca'])
})
test('a creature option carries the type the shard can build, not the atlas slug', async () => {
// Changed in Phase 12a, and the reason is the point of the source existing.
// Wave 1 declared it before anything consumed it and used the slug — unique,
// stable, and unusable: the shard constructs from a ServUO class name, and
// `orc-brute` is not one. The atlas's `name` IS the raw type token from the
// spawn files, so the fix was to stop discarding the half that works.
shardAtlas.searchCreatures = async ({ limit }) => {
assert.equal(limit, actions.MAX_OPTIONS, 'the source must bound what it asks the atlas for')
return { creatures: [{ slug: 'orcbrute', name: 'OrcBrute' }] }
}
assert.deepEqual(await source('uo.options.creatures').resolve(), [
{ value: 'OrcBrute', label: 'OrcBrute' },
])
})
test('decoration options come from the shard\'s own decoration files', async () => {
const options = await source('uo.options.decor').resolve()
assert.deepEqual(options, [{ value: 'Brazier', label: 'Brazier' }])
})
test('an atlas larger than the dropdown bound is truncated and said so', async () => {
const { ctx } = require('./_setup')
shardAtlas.listRegions = async () =>
Array.from({ length: actions.MAX_OPTIONS + 5 }, (_, i) => ({ facet: 'Felucca', name: `Region ${i}` }))
const options = await source('uo.options.regions').resolve()
assert.equal(options.length, actions.MAX_OPTIONS)
// Silently serving 2000 of 2005 is the defect the bound would otherwise
// introduce: an author cannot find the landmark they are looking for and
// nothing anywhere says why.
const warned = ctx.logs
.filter((l) => l.namespace === 'uo-events')
.flatMap((l) => l.log.warn.calls)
.some(([message]) => /truncated/.test(message))
assert.ok(warned, 'a truncated source must leave a log line naming itself')
})
// ── A landmark option value names ONE landmark (Phase 16b) ────────────────
test('two landmarks sharing a name are two different options, and both resolve', async () => {
// A stock 57.4 tree has 558 landmarks under 320 distinct `facet/name` pairs:
// `Trammel/Entrance` is 23 different dungeons. The source emitted `facet/name`
// and `landmarkPoint` resolved with `.find()`, so 22 of the 23 were unreachable
// — an author who picked "Entrance — Destard" got Blighted Grove, with a
// successful run and no warning. The group was already the disambiguator and it
// was shown to the eye while being left out of the value.
//
// Asserted as an INEQUALITY between two resolved points rather than against a
// literal value string, so it survives someone changing the value's format
// again as long as the two options still address two places.
shardAtlas.listLandmarks = async () => [
{ facet: 'Felucca', name: 'Entrance', group: 'Blighted Grove', x: 586, y: 1643, z: 0 },
{ facet: 'Felucca', name: 'Entrance', group: 'Destard', x: 1176, y: 2637, z: 0 },
]
const source = actions.OPTION_SOURCES.find((s) => s.id === 'uo.options.landmarks')
const options = await source.resolve({})
assert.equal(options.length, 2)
assert.equal(new Set(options.map((o) => o.value)).size, 2, 'both options must be addressable')
const points = []
for (const option of options) {
const result = await byId('uo.creature.spawn').perform({
runId: 41,
idempotencyKey: `L${option.value}`.padEnd(40, 'x'),
params: { place: option.value, creature: 'Orc', count: 1 },
verify: true,
})
assert.equal(result.ok, true, `${option.value} must resolve`)
points.push(option.value)
}
assert.notEqual(points[0], points[1])
})
test('a place published before the group was carried still resolves', async () => {
// Every event published before the fix stores `facet/name`, and a published
// version is immutable — so a parse that stopped understanding the two-part
// form would break those runs rather than correct them. It keeps the old
// first-match read, which is imprecise in exactly the way it always was.
shardAtlas.listLandmarks = async () => [
{ facet: 'Felucca', name: 'Entrance', group: 'Blighted Grove', x: 586, y: 1643, z: 0 },
{ facet: 'Felucca', name: 'Entrance', group: 'Destard', x: 1176, y: 2637, z: 0 },
// A name carrying a slash reads as three parts too; the two-part read is what
// resolves it, which is why the three-part attempt must not answer for it.
{ facet: 'Felucca', name: 'Odd/Name', group: null, x: 10, y: 20, z: 0 },
]
for (const place of ['Felucca/Entrance', 'Felucca/Odd/Name']) {
const result = await byId('uo.creature.spawn').perform({
runId: 42,
idempotencyKey: `P${place}`.padEnd(40, 'x'),
params: { place, creature: 'Orc', count: 1 },
verify: true,
})
assert.equal(result.ok, true, `${place} must still resolve`)
}
// And a three-part value whose group is gone REFUSES rather than silently
// landing somewhere else. That is the honest answer: it asked for one place.
const gone = await byId('uo.creature.spawn').perform({
runId: 42,
idempotencyKey: 'G'.repeat(40),
params: { place: 'Felucca/Renamed/Entrance', creature: 'Orc', count: 1 },
verify: true,
})
assert.equal(gone.ok, false)
assert.match(gone.error, /no landmark called/)
})
// ── The world verbs (Phase 12a) ───────────────────────────────
test('a spawn files one ledger row per serial, not one per call', async () => {
// Per serial, because a group half of which a player killed has to reconcile
// per creature. One row per call would make teardown all-or-nothing over eight
// orcs of which six are gone, which is neither true nor useful.
const result = await byId('uo.creature.spawn').perform({
runId: 7,
idempotencyKey: 'c'.repeat(40),
params: { place: 'Felucca/Britain', creature: 'Orc', count: 3 },
verify: false,
})
assert.equal(result.ok, true)
assert.equal(result.resources.length, 3)
for (const resource of result.resources) {
assert.equal(resource.kind, actions.OWNED_KIND)
assert.equal(resource.payload.runId, '7')
assert.equal(resource.payload.what, 'creature')
assert.equal(resource.payload.type, 'Orc')
}
// The place is resolved to a point HERE, so the shard is never handed a
// facet/name it would have to know how to read.
assert.equal(calls.spawn.length, 1)
assert.deepEqual(
{ map: calls.spawn[0].map, x: calls.spawn[0].x, y: calls.spawn[0].y },
{ map: 'Felucca', x: 1496, y: 1628 },
)
})
test('a boss is a creature plus multipliers, and is refused above the ceiling', async () => {
const boss = byId('uo.boss.spawn')
const params = {
place: 'Felucca/Britain',
creature: 'OrcCaptain',
name: 'Gruk the Unbroken',
hitsMultiplier: 3,
damageMultiplier: 1.5,
}
assert.equal((await boss.perform({ runId: 7, idempotencyKey: 'b'.repeat(40), params, verify: false })).ok, true)
assert.equal(calls.spawn[0].what, 'boss')
assert.equal(calls.spawn[0].hitsMultiplier, 3)
assert.equal(calls.spawn[0].damageMultiplier, 1.5)
// Absent, not zero: a multiplier nobody set must not arrive as a number the
// shard would then apply.
assert.equal(calls.spawn[0].statMultiplier, undefined)
const tooMuch = await boss.perform({
runId: 7,
idempotencyKey: 'b'.repeat(40),
params: { ...params, hitsMultiplier: actions.MAX_BOSS_MULTIPLIER + 1 },
verify: false,
})
assert.equal(tooMuch.ok, false)
assert.equal(tooMuch.retry, false, 'a ceiling will not move on a retry')
assert.equal(calls.spawn.length, 1, 'nothing may reach the shard once it is refused here')
// Named, because an unnamed boss is just a hard orc — and because the name is
// what an operator reads in the ledger afterwards.
const unnamed = await boss.perform({
runId: 7,
idempotencyKey: 'b'.repeat(40),
params: { ...params, name: ' ' },
verify: false,
})
assert.equal(unnamed.ok, false)
})
test('an oracle\'s dialogue is parsed from one textarea, and a bad row is named', async () => {
const parsed = actions.oracleLines('fire, flame = It burns beneath the keep.\n gate = At dusk. ')
assert.deepEqual(parsed, {
ok: true,
rows: [
{ keywords: 'fire,flame', text: 'It burns beneath the keep.' },
{ keywords: 'gate', text: 'At dusk.' },
],
})
// Split on the FIRST `=`, so an answer may contain one.
assert.deepEqual(actions.oracleLines('sum = 2 = 2 is four').rows, [
{ keywords: 'sum', text: '2 = 2 is four' },
])
assert.equal(actions.oracleLines('just some prose').ok, false)
assert.equal(actions.oracleLines('fire =').ok, false, 'a keyword with nothing to say is a mistake')
assert.equal(actions.oracleLines('= something').ok, false, 'something to say with no keyword is too')
const tooMany = actions.oracleLines(
Array.from({ length: actions.MAX_ORACLE_LINES + 1 }, (_, i) => `w${i} = t${i}`).join('\n'),
)
assert.equal(tooMany.ok, false)
})
test('an oracle with nothing to say is refused before it is stood up', async () => {
// `required: true` on the greeting catches an ABSENT field, at the edge, and
// this catches the one holding nothing but spaces — which reaches `perform`
// looking exactly like a filled-in form.
const result = await byId('uo.npc.place').perform({
runId: 7,
idempotencyKey: 'n'.repeat(40),
params: { place: 'Felucca/Britain', name: 'Marisa', greeting: ' ' },
verify: false,
})
assert.equal(result.ok, false)
assert.equal(result.retry, false)
assert.match(result.error, /silence/)
assert.deepEqual(calls.spawn, [])
})
test('a keyword line reaches the shard as keywords and text, and nothing executable', async () => {
// The whole argument for not building this on `XmlSpawner2.XmlDialog`, which
// implements exactly this vocabulary and one field more: an `Action` string
// that runs commands. What crosses here is what an oracle SAYS.
const result = await byId('uo.npc.place').perform({
runId: 7,
idempotencyKey: 'n'.repeat(40),
params: {
place: 'Felucca/Britain',
name: 'Marisa',
greeting: 'You have questions.',
lines: 'fire, flame = It burns beneath the keep.',
sex: 'female',
},
verify: false,
})
assert.equal(result.ok, true)
assert.deepEqual(calls.spawn[0].lines, [
{ keywords: 'fire,flame', text: 'It burns beneath the keep.' },
])
assert.equal(calls.spawn[0].sex, 'female')
for (const key of Object.keys(calls.spawn[0])) {
assert.notEqual(key, 'action', 'nothing executable may cross to the shard')
}
})
test('a gate crosses as a DURATION, and names both ends as points', async () => {
const result = await byId('uo.gate.open').perform({
runId: 7,
idempotencyKey: 'g'.repeat(40),
params: { place: 'Felucca/Britain', destination: 'Felucca/Yew', durationMinutes: 120 },
verify: false,
})
assert.equal(result.ok, true)
const sent = calls.spawn[0]
// A duration, never an absolute time: an absolute deadline computed here and
// honoured there is measured against two clocks, and a shard ten minutes fast
// would collect the gate the instant it opened.
assert.equal(sent.holdMs, 120 * 60_000)
assert.equal(sent.untilMs, undefined, 'an absolute deadline must not cross')
assert.deepEqual(sent.target, { map: 'Felucca', x: 542, y: 982 })
const tooLong = await byId('uo.gate.open').perform({
runId: 7,
idempotencyKey: 'g'.repeat(40),
params: {
place: 'Felucca/Britain',
destination: 'Felucca/Yew',
durationMinutes: actions.MAX_GATE_MINUTES + 1,
},
verify: false,
})
assert.equal(tooLong.ok, false)
assert.equal(tooLong.retry, false)
})
test('teardown reports a refused serial as failed, and a killed creature as done', async () => {
const resources = [
{ kind: 'world', ref: '0x40000000', payload: {} },
{ kind: 'world', ref: '0x40000001', payload: {} },
]
// `gone` is not a failure. A creature a player killed is the point of having
// spawned it, and §L already says "gone, and that is fine" is a successful
// revert — so a run does not end `incomplete` because its event worked.
uoLinkClient.despawnWorld = async () => ({
ok: true,
status: 200,
data: { removed: ['0x40000000'], gone: ['0x40000001'], refused: [] },
})
assert.deepEqual(await actions.revertOwned({ runId: 7, resources }), { ok: true })
// `refused` IS. The shard denies this run ever owned it, so nothing will ever
// delete it through this path: the row must land unresolved with a reason
// rather than be quietly marked reverted.
uoLinkClient.despawnWorld = async () => ({
ok: true,
status: 200,
data: { removed: ['0x40000000'], gone: [], refused: ['0x40000001'] },
})
assert.deepEqual(await actions.revertOwned({ runId: 7, resources }), {
ok: true,
failed: ['0x40000001'],
})
// An unreachable shard has not said anything about anything.
uoLinkClient.despawnWorld = async () => ({ ok: false, status: 503, data: null })
assert.equal((await actions.revertOwned({ runId: 7, resources })).ok, false)
})
test('the despawn carries NO idempotency key, whatever core hands revert()', async () => {
// The Phase 16 acceptance walk's critical finding, as the test that would have
// caught it. `revertOwned` used to forward core's `idempotencyKey` onto the
// despawn — and core's key is the STEP's, the one `placeOwned` spawned under.
// The shard's at-most-once store is keyed on the key ALONE
// (`BridgeIdempotency.Intercept` does `_byKey.TryGetValue(key, …)`, with no
// reference to which command carried it), so the despawn was taken for a repeat
// and answered with the SPAWN's stored reply. `OnDespawn` never ran. Core read
// `ok` with no `refused` and marked every row `reverted` while the shard still
// held every object — teardown of all five world verbs was a no-op that
// reported success.
//
// Every other stub in this file ignores the body, which is why the suite was
// green throughout. This one asserts on the body, and it asserts ABSENCE — the
// property that matters — rather than pinning the rest of the shape.
let sent = null
uoLinkClient.despawnWorld = async (body) => {
sent = body
return { ok: true, status: 200, data: { removed: ['0x40000000'], gone: [], refused: [] } }
}
await actions.revertOwned({
runId: 7,
resources: [{ kind: 'world', ref: '0x40000000', payload: {} }],
// Core passes this on every call (MODULE_API.md), and it must not reach the wire.
idempotencyKey: 'the-step-key-the-spawn-went-out-under',
})
assert.ok(sent, 'despawnWorld was not called')
assert.equal(
Object.prototype.hasOwnProperty.call(sent, 'idempotencyKey'),
false,
'the despawn must not carry an idempotency key — the shard would replay the spawn',
)
// MODULE_API.md: revert is sometimes called with the key and an EMPTY list,
// meaning "a command went out under this key and core never learned what it
// did". No serials is the shard's own idiom for "everything this run owns",
// which is the correct sweep for exactly that case.
sent = null
await actions.revertOwned({ runId: 7, resources: [], idempotencyKey: 'lost-dispatch' })
assert.deepEqual(sent.serials, [])
assert.equal(Object.prototype.hasOwnProperty.call(sent, 'idempotencyKey'), false)
})
test('reconcile ASKS the shard, because these resources survive a restart', async () => {
// The one property that separates this from every other resource in the file.
// A crier line lives in shard memory, so a changed `bootId` IS proof it is
// gone; a spawned creature is in the world SAVE and survives the restart the
// boot stamp would report it lost by.
const resources = [
{ kind: 'world', ref: '0x40000000', payload: {} },
{ kind: 'world', ref: '0x40000001', payload: {} },
]
assert.deepEqual(await actions.reconcileOwned({ runId: 7, resources }), {
ok: true,
inForce: ['0x40000000'],
})
assert.deepEqual(calls.owned, [{ runId: '7' }])
// "I could not ask" must never be read as "it is gone": an unanswered group
// leaves every row alone rather than orphaning the lot.
uoLinkClient.ownedWorld = async () => ({ ok: false, status: 504, data: null })
assert.equal((await actions.reconcileOwned({ runId: 7, resources })).ok, false)
})
test('every world verb declares the same undo contract', async () => {
// Five declarations sharing one spread object, asserted rather than assumed:
// a verb that quietly lost its `reconcile` would leave its rows unanswered for
// the life of the run, and nothing would report it — which is exactly the hole
// Phase 11b found in `core.lease`.
for (const id of ['uo.creature.spawn', 'uo.boss.spawn', 'uo.npc.place', 'uo.gate.open', 'uo.decor.place']) {
const action = byId(id)
assert.equal(action.risk, 'change', `${id} must be a world change`)
assert.equal(action.reversible, 'ledger', `${id} owns what it made`)
assert.equal(typeof action.revert, 'function', `${id} has no undo`)
assert.equal(typeof action.reconcile, 'function', `${id} can never be asked what it still holds`)
assert.ok(action.budgetMs > 12000, `${id} must outlast the client's own timeout`)
assert.equal(typeof action.cost, 'function', `${id} is capped by nothing`)
}
})
test('decoration carries the graphic, and a type this shard never decorates with is refused', async () => {
const decor = byId('uo.decor.place')
const ok = await decor.perform({
runId: 7,
idempotencyKey: 'd'.repeat(40),
params: { place: 'Felucca/Britain', item: 'Brazier', count: 2 },
verify: false,
})
assert.equal(ok.ok, true)
assert.equal(ok.resources.length, 2)
// **The item id crosses, and it has to.** Measured on ServUO 57.4, `Static`
// accounts for 5031 decoration placements under 1992 DIFFERENT graphics,
// because for that class the graphic is the identity: a bare `new Static()`
// is never the paving stone the author picked. 131 of 313 types carry more
// than one id.
assert.equal(calls.spawn[0].type, 'Brazier')
assert.equal(calls.spawn[0].itemId, 0x0e31)
// Resolving through the atlas is also the boundary: the verb places what this
// shard's own decoration files name, which is tighter than "any item that is
// not a container" and is the rule the decision actually took.
const unknown = await decor.perform({
runId: 7,
idempotencyKey: 'd'.repeat(40),
params: { place: 'Felucca/Britain', item: 'BlackrockCrate', count: 1 },
verify: false,
})
assert.equal(unknown.ok, false)
assert.equal(unknown.retry, false)
assert.match(unknown.error, /never mention/)
assert.equal(calls.spawn.length, 1)
})

View File

@@ -0,0 +1,349 @@
// module-uo's half of protocol 7 part b (EVENTS_PLAN.md Phase 12b).
//
// What an event BORROWS — five targeted leases over two planes — and the two
// one-shots that are neither borrowed nor owned.
//
// The tests below are the places where the obvious implementation is subtly the
// wrong one and nothing would fail if it were written the other way:
//
// • every callable of a targeted lease must PASS THE TARGET ON. A read that
// dropped it would answer about the wrong spawner, and a restore that
// dropped it would write a baseline onto one
// • a target the shard can no longer read is a REFUSAL at apply time, never a
// value: taking the lease anyway records a fictional baseline and later
// writes it onto whatever next holds that id
// • a target that vanished mid-run is a SUCCESSFUL restore, not a failure —
// there is nothing to give back, and reporting it failed leaves a ledger row
// unresolved for ever over an object that is gone
// • `inForce()` reads the frame's `holds`, which is the only thing that can
// answer for a targeted key: there is no list of spawners to walk
// • a grant that reached NOBODY is a success, because an event nobody attended
// still happened — while a run the shard was never told to count is a 404
// • a non-stackable granted in quantity is refused at BOTH ends
const { test, beforeEach, afterEach } = require('node:test')
const assert = require('node:assert/strict')
const uoLinkClient = require('../utils/uoLinkClient')
const shardAtlas = require('../model/shardAtlas/shardAtlas.model')
require('./_setup')
const actions = require('../config/uoEventActions')
const byId = (id) => actions.ACTIONS.find((a) => a.id === id)
const leaseById = (id) => actions.LEASES.find((l) => l.id === id)
const STUBBED = ['getLeases', 'applyLease', 'releaseLease', 'grantItem', 'saveWorld']
let calls
let frame
const saved = {}
beforeEach(() => {
calls = { leases: [], apply: [], release: [], grant: [], save: [] }
frame = {
leases: [{ key: 'Spawner.MaxCount', kind: 'property', current: '3', held: false }],
holds: [],
}
for (const name of STUBBED) saved[name] = uoLinkClient[name]
saved.listSpawners = shardAtlas.listSpawners
uoLinkClient.getLeases = async (q) => {
calls.leases.push(q)
return { ok: true, status: 200, data: frame }
}
uoLinkClient.applyLease = async (b) => { calls.apply.push(b); return { ok: true, status: 200, data: {} } }
uoLinkClient.releaseLease = async (b) => { calls.release.push(b); return { ok: true, status: 200, data: {} } }
uoLinkClient.grantItem = async (b) => {
calls.grant.push(b)
return { ok: true, status: 200, data: { granted: 2, missed: [] } }
}
uoLinkClient.saveWorld = async (b) => { calls.save.push(b); return { ok: true, status: 200, data: {} } }
shardAtlas.listSpawners = async (opts) => {
calls.spawners = opts
return [
{ uniqueId: 'uid-1', name: 'fel orc fort', facet: 'Felucca', region: 'Britain', maxCount: 9 },
{ uniqueId: 'uid-2', name: null, facet: 'Trammel', region: null, landmark: null, maxCount: 1 },
]
}
})
afterEach(() => {
for (const name of STUBBED) uoLinkClient[name] = saved[name]
shardAtlas.listSpawners = saved.listSpawners
})
// ── The targeted leases ────────────────────────────────────────────────────
test('every callable carries the target through to the shard', async () => {
// The one thing that cannot be got wrong quietly. Core composes the ledger ref
// as `<lease id>#<target>` and hands the target back on every call; a callable
// that ignored it would read, apply to and restore whichever spawner the shard
// happened to answer about, and nothing here or there would report an error.
const lease = leaseById('uo.spawner.maxcount')
const target = '003f11b8-9bfa-4587-991e-ca263004efe6'
const read = await lease.read({ target })
assert.deepEqual(read, { ok: true, value: '3' })
assert.deepEqual(calls.leases[0], { key: 'Spawner.MaxCount', target })
await lease.apply('30', new Date(Date.now() + 600_000), { target })
assert.equal(calls.apply[0].key, 'Spawner.MaxCount')
assert.equal(calls.apply[0].target, target)
// A DURATION, not the deadline — 11b's rule, unchanged by targeting. A shard
// whose clock runs fast would restore an absolute deadline the instant it
// took it.
assert.ok(calls.apply[0].holdMs > 0 && calls.apply[0].holdMs <= 600_000)
await lease.restore('3', { expected: '30', target })
assert.deepEqual(calls.release[0], {
key: 'Spawner.MaxCount',
target,
expected: '30',
baseline: '3',
})
})
test('a target the shard cannot read refuses the lease rather than defaulting', async () => {
// The failure this guards is silent and permanent: a lease taken over a
// spawner that is not there records whatever came back as the baseline, and
// teardown then WRITES that baseline onto whatever next holds the id.
frame.leases = [{ key: 'Spawner.MaxCount', unreadable: "nothing on this shard has serial 0x99" }]
const refused = await leaseById('uo.spawner.maxcount').read({ target: '0x99' })
assert.equal(refused.ok, false)
assert.match(refused.error, /nothing on this shard has serial/)
// A row with neither a value nor a reason is refused too. The shard should
// always send one of them, and "it sent neither" must not read as zero.
frame.leases = [{ key: 'Spawner.MaxCount' }]
const empty = await leaseById('uo.spawner.maxcount').read({ target: 'uid-1' })
assert.equal(empty.ok, false)
assert.match(empty.error, /could not read/)
})
test('a target that vanished mid-run is a successful restore, not a failure', async () => {
// 12a's `gone` in the lease plane's vocabulary. Somebody deleted the spawner
// while the run held it: there is nothing to give back and nothing is owed.
// Reported as a failure it would sit in the ledger unresolved for ever, over
// an object that no longer exists — and every sweep would try again.
uoLinkClient.releaseLease = async () => ({
ok: true,
status: 200,
data: { kind: 'lease.ok', released: true, targetGone: true, reason: 'that object has been deleted' },
})
const done = await leaseById('uo.spawner.maxcount').restore('3', { expected: '30', target: 'uid-1' })
assert.deepEqual(done, { ok: true })
})
test('drift is still drift, and is still not an error', async () => {
// Unchanged from 11b and asserted again because targeting rewrote the whole
// callable: core records drift as a distinct SUCCESSFUL outcome, so an error
// here would put the row on the retry ladder and eventually report the lease
// as vanished rather than as somebody having moved it.
uoLinkClient.releaseLease = async () => ({
ok: true,
status: 200,
data: { kind: 'lease.drifted', current: '12' },
})
const drifted = await leaseById('uo.spawner.maxcount').restore('3', { expected: '30', target: 'uid-1' })
assert.deepEqual(drifted, { ok: false, drifted: true, current: '12' })
})
test('inForce reads the holds list, which is the only thing that can answer', async () => {
// A catalog walk can enumerate the KEYS but never the holds on a targeted one
// — there is no list of spawners to walk — so the frame carries every hold the
// shard has, and this is what reads it.
const lease = leaseById('uo.spawner.maxcount')
assert.deepEqual(await lease.inForce({ target: 'uid-1' }), { ok: true, held: false })
frame.holds = [{ key: 'Spawner.MaxCount', target: 'uid-1', runId: '7' }]
assert.deepEqual(await lease.inForce({ target: 'uid-1' }), { ok: true, held: true })
// ...and it is the hold on THIS target, not any hold on the key. A run holding
// one spawner must not make every other spawner look leased.
assert.deepEqual(await lease.inForce({ target: 'uid-2' }), { ok: true, held: false })
})
test('a shard that cannot answer is never read as "the lease is gone"', async () => {
// Core's posture everywhere: "I could not ask" must not be recorded as "it is
// gone", because the second orphans the row and stops teardown ever trying.
uoLinkClient.getLeases = async () => ({ ok: false, status: 503, data: null })
const answer = await leaseById('uo.spawner.maxcount').inForce({ target: 'uid-1' })
assert.equal(answer.ok, false)
})
test('the seasonal lease is a three-value enum over eight events', () => {
// §G called `SeasonalEventSystem.GetEntry(type).Status` "a nine-value enum" and
// had it backwards: `EventStatus` has three values, `EventType` has nine
// entries — and one of those nine is excluded, so it is eight.
const lease = leaseById('uo.seasonal.status')
assert.equal(lease.type, 'string')
assert.deepEqual(lease.values, ['Inactive', 'Active', 'Seasonal'])
assert.equal(actions.SEASONAL_EVENTS.length, 8)
// TreasuresOfTokuno reads its own era rather than this status, so leasing it
// would apply cleanly and change nothing — §N10's "a capability that lies",
// and the one instance no runtime probe can catch.
assert.ok(!actions.SEASONAL_EVENTS.includes('TreasuresOfTokuno'))
})
test('every targeted lease bounds what it can hold', () => {
// §F requires a range on the numeric types because, unlike a cap, a bad lease
// value is in force the moment it is applied. Restated over the five because
// they are built by a shared factory: one missing bound would be missing in a
// way no single declaration shows.
for (const lease of actions.LEASES) {
if (lease.id === 'uo.playercaps.skillcap') continue
assert.ok(lease.maxDurationMs > 0, `${lease.id} has no duration bound`)
if (lease.type === 'int' || lease.type === 'float') {
assert.ok(Number.isFinite(lease.min) && Number.isFinite(lease.max), `${lease.id} has no range`)
assert.ok(lease.min <= lease.max, `${lease.id} has min above max`)
}
if (lease.type === 'string') {
assert.ok(Array.isArray(lease.values) && lease.values.length, `${lease.id} has no value set`)
}
}
})
// ── The spawner source ─────────────────────────────────────────────────────
test('the spawner source searches, and says so', async () => {
// The first source with more entries than a dropdown holds: 6,707 spawn points
// against MAX_OPTIONS' 2,000. A flat list would drop two thirds of the world
// and say nothing about which two thirds.
const source = actions.OPTION_SOURCES.find((s) => s.id === 'uo.options.spawners')
assert.equal(source.searchable, true)
const rows = await source.resolve({ q: 'orc' })
assert.equal(calls.spawners.q, 'orc')
assert.equal(calls.spawners.limit, actions.SPAWNER_OPTIONS)
// The value is the UniqueId, because it is the only name for one particular
// spawner that exists off the shard.
assert.deepEqual(rows[0], { value: 'uid-1', label: 'fel orc fort', group: 'Britain' })
// A nameless spawner still answers, labelled by its id. It is still a spawner
// somebody may need to turn down, and dropping it would be a dropdown quietly
// missing rows again.
assert.deepEqual(rows[1], { value: 'uid-2', label: 'uid-2', group: 'Trammel' })
})
// ── The one-shots ──────────────────────────────────────────────────────────
test('a grant sends a run and never a recipient list', async () => {
// The shard has held this run's participation ledger since it opened, keyed by
// the same serials core stores as `member_key`. Sending a list would put it on
// the wire twice with a window in which the two disagree — and would have
// needed a core surface handing a module core's own participants.
const out = await byId('uo.item.grant').perform({
runId: 7,
idempotencyKey: 'k',
params: { item: 'gold', amount: 500, where: 'bank' },
})
assert.equal(out.ok, true)
assert.deepEqual(calls.grant[0], {
runId: 7,
item: 'gold',
amount: 500,
hue: undefined,
name: undefined,
where: 'bank',
idempotencyKey: 'k',
})
assert.equal(out.detail.granted, 2)
})
test('a grant that reached nobody is a success', async () => {
// An event nobody attended still happened. Reported as a failure the run would
// retry against a ledger that will be just as empty next time, and pause. The
// shard draws the distinction that matters: a run it was never told to count
// is a 404, which fails below.
uoLinkClient.grantItem = async () => ({ ok: true, status: 200, data: { granted: 0, missed: [] } })
const out = await byId('uo.item.grant').perform({
runId: 7,
idempotencyKey: 'k',
params: { item: 'gold', amount: 1 },
})
assert.equal(out.ok, true)
assert.equal(out.detail.granted, 0)
uoLinkClient.grantItem = async () => ({
ok: false,
status: 404,
data: { reason: 'run 7 has no participation ledger open on this shard' },
})
const missing = await byId('uo.item.grant').perform({
runId: 7,
idempotencyKey: 'k',
params: { item: 'gold', amount: 1 },
})
assert.equal(missing.ok, false)
// 404 is permanent: the ledger will not appear because we asked again.
assert.equal(missing.retry, false)
})
test('a non-stackable granted in quantity is refused before the wire', async () => {
// Five cloaks would be five items — five chances to overflow a backpack
// halfway through with no way to say which half landed. Refused here so the
// author sees it on the form, and refused again on the shard because this copy
// of the allowlist is the one that can be wrong.
const out = await byId('uo.item.grant').perform({
runId: 7,
idempotencyKey: 'k',
params: { item: 'cloak', amount: 3 },
})
assert.equal(out.ok, false)
assert.equal(out.retry, false)
assert.match(out.error, /does not stack/)
assert.equal(calls.grant.length, 0)
const unknown = await byId('uo.item.grant').perform({
runId: 7,
idempotencyKey: 'k',
params: { item: 'castle', amount: 1 },
})
assert.equal(unknown.ok, false)
assert.equal(unknown.retry, false)
assert.equal(calls.grant.length, 0)
})
test('a grant is retryable, and protocol 6 is the reason', async () => {
// §G called a grant un-retryable because a lost acknowledgement and a grant
// that never applied were the same event — the argument that made
// `uo.broadcast` answer `retry: false` in Phase 9. An idempotency key closes
// it: a repeat is answered by the original reply, so a retried grant cannot be
// one winner receiving two.
uoLinkClient.grantItem = async () => ({ ok: false, status: 503, data: null })
const out = await byId('uo.item.grant').perform({
runId: 7,
idempotencyKey: 'k',
params: { item: 'gold', amount: 1 },
})
assert.equal(out.ok, false)
assert.notEqual(out.retry, false)
// And the action declares itself irreversible, which is the honest class: the
// world is altered and cannot be put back.
assert.equal(byId('uo.item.grant').risk, 'irreversible')
assert.equal(byId('uo.item.grant').reversible, 'none')
})
test('a save refused for coming too soon is retried, not abandoned', async () => {
// 429 is the shard's rate limit and is the one refusal on this plane that
// waiting fixes. It is deliberately not in PERMANENT_STATUSES, so a phase
// boundary is retried rather than dropped.
assert.ok(!actions.PERMANENT_STATUSES.has(429))
uoLinkClient.saveWorld = async () => ({
ok: false,
status: 429,
data: { reason: 'this shard saves at most every 300 seconds, and the last save was 12 seconds ago' },
})
const out = await byId('uo.world.save').perform({ idempotencyKey: 'k' })
assert.equal(out.ok, false)
assert.notEqual(out.retry, false)
})
test('a save reports only that it started', async () => {
// What actually happened rides `world.save.before`/`after` on the event stream.
// Asserting anything more here would be asserting something the reply does not
// know.
const out = await byId('uo.world.save').perform({ idempotencyKey: 'k' })
assert.deepEqual(out, { ok: true, detail: { started: true } })
assert.deepEqual(calls.save[0], { idempotencyKey: 'k' })
})

View File

@@ -0,0 +1,382 @@
// module-uo's half of protocol 6 part b (EVENTS_PLAN.md Phase 11b).
//
// One lease and two participation verbs. What is worth asserting here is not that
// the calls happen — a rig proves that better — but the handful of places where
// the obvious implementation is subtly the wrong one, and where nothing would fail
// if it were written the other way:
//
// • a lease's `restore()` must turn `lease.drifted` into `{ drifted: true }`
// rather than an error, because core records drift as a distinct SUCCESSFUL
// outcome and an error would put the row on the retry ladder instead
// • `inForce()` must not be a comparison against `read()` — a changed value is
// drift, which teardown reports, and orphaning the row first destroys it
// • `apply()` must send a DURATION, not the deadline, or a shard whose clock is
// fast restores the lease the instant it takes it
// • `uo.participation.open` must NOT reconcile by boot stamp, which every other
// resource in this module does — the ledger is persisted in the world save
// precisely so that it survives the restart the stamp would report it lost by
// • a `userId` is a foreign key and a character serial is not, so an unresolved
// one is undefined rather than coerced
const { test, beforeEach, afterEach } = require('node:test')
const assert = require('node:assert/strict')
const uoLinkClient = require('../utils/uoLinkClient')
const shardAtlas = require('../model/shardAtlas/shardAtlas.model')
require('./_setup')
const actions = require('../config/uoEventActions')
const byId = (id) => actions.ACTIONS.find((a) => a.id === id)
const lease = () => actions.LEASES.find((l) => l.id === 'uo.playercaps.skillcap')
const STUBBED = [
'getLeases',
'applyLease',
'releaseLease',
'openParticipation',
'snapshotParticipation',
'closeParticipation',
]
let calls
const saved = {}
beforeEach(() => {
calls = { apply: [], release: [], open: [], snapshot: [], close: [] }
for (const name of STUBBED) saved[name] = uoLinkClient[name]
saved.listLandmarks = shardAtlas.listLandmarks
uoLinkClient.getLeases = async () => ({
ok: true,
status: 200,
data: { leases: [{ key: 'PlayerCaps.SkillCap', current: '1000', held: false }] },
})
uoLinkClient.applyLease = async (b) => { calls.apply.push(b); return { ok: true, status: 200, data: {} } }
uoLinkClient.releaseLease = async (b) => { calls.release.push(b); return { ok: true, status: 200, data: {} } }
uoLinkClient.openParticipation = async (b) => { calls.open.push(b); return { ok: true, status: 200, data: {} } }
uoLinkClient.snapshotParticipation = async (b) => {
calls.snapshot.push(b)
return { ok: true, status: 200, data: { participants: [] } }
}
uoLinkClient.closeParticipation = async (b) => { calls.close.push(b); return { ok: true, status: 200, data: {} } }
shardAtlas.listLandmarks = async () => [{ facet: 'Felucca', name: 'Britain', x: 1496, y: 1628, z: 10 }]
})
afterEach(() => {
for (const name of STUBBED) uoLinkClient[name] = saved[name]
shardAtlas.listLandmarks = saved.listLandmarks
})
// ── The lease ──────────────────────────────────────────────────────────────
test('the lease satisfies the shape core validates it with', () => {
const l = lease()
assert.ok(l.id.startsWith('uo.'), 'a lease is namespaced to its module')
assert.ok(l.label && l.description)
assert.equal(l.type, 'float')
// Required for the numeric types, and unlike a cap a bad lease value is in
// force the moment it is applied.
assert.ok(Number.isFinite(l.min) && Number.isFinite(l.max) && l.min < l.max)
assert.ok(Number.isInteger(l.maxDurationMs) && l.maxDurationMs > 0)
for (const fn of ['read', 'apply', 'restore', 'inForce']) {
assert.equal(typeof l[fn], 'function', `a lease needs ${fn}()`)
}
})
test('apply sends a DURATION, because a deadline is measured against two clocks', async () => {
const until = new Date(Date.now() + 90 * 60_000)
const answer = await lease().apply(1200, until)
assert.equal(answer.ok, true)
const sent = calls.apply[0]
// The number the shard arms its timer off. Computed here from the deadline, so
// a shard running ten minutes fast holds the lease for ninety minutes of its
// own time rather than restoring it the instant it takes it.
assert.ok(Math.abs(sent.holdMs - 90 * 60_000) < 2000, `holdMs was ${sent.holdMs}`)
// And the absolute time still rides along, for a console that wants to say when
// the hold ends in terms the operator's own clock agrees with.
assert.equal(sent.untilMs, until.getTime())
// The action hands the value on unchanged; `uoLinkClient.applyLease` is what
// renders it as TEXT, which is the wire's contract for every lease type: `1200`
// and `1200.0` are one number to a JSON parser and two different strings to a
// compare-and-set.
assert.equal(sent.value, 1200)
})
test('a deadline that has already passed is refused rather than sent as a negative hold', async () => {
const answer = await lease().apply(1200, new Date(Date.now() - 60_000))
assert.equal(answer.ok, false)
assert.match(answer.error, /already passed/)
assert.equal(calls.apply.length, 0)
})
test('drift comes back as drifted, not as an error', async () => {
// The distinction core acts on. `cleanup.js` records `drifted` as its own
// outcome — the module did exactly what it was asked and found somebody else's
// value in place — while an error would put the row on the retry ladder and
// eventually spend its attempts on a situation only a human can resolve.
uoLinkClient.releaseLease = async () => ({
ok: true,
status: 200,
data: { kind: 'lease.drifted', key: 'PlayerCaps.SkillCap', current: '1300' },
})
const answer = await lease().restore('1000', { expected: '1200' })
assert.equal(answer.ok, false)
assert.equal(answer.drifted, true)
assert.equal(answer.current, '1300')
assert.equal(answer.error, undefined)
})
test('restore sends both what it applied and what to put back', async () => {
await lease().restore('1000', { expected: '1200' })
// Core's `restore(baseline, { expected })` carries no key of its own -- teardown
// is core's own sweep rather than a step dispatch -- so neither does this.
assert.deepEqual(calls.release[0], {
key: 'PlayerCaps.SkillCap',
expected: '1200',
baseline: '1000',
})
})
test('inForce asks whether the shard still HOLDS it, not whether the value still matches', async () => {
// The reason this callable exists at all. A shard reporting a value that is not
// what the run applied is reporting DRIFT, which teardown delivers through
// `restore()` so the ledger row lands `drifted` with the current value beside
// it. Answering "not in force" here would orphan the row first and tell the
// operator the lease vanished rather than that somebody moved it.
uoLinkClient.getLeases = async () => ({
ok: true,
status: 200,
data: { leases: [{ key: 'PlayerCaps.SkillCap', current: '1300', held: true }] },
})
assert.deepEqual(await lease().inForce(), { ok: true, held: true })
// And a shard that restarted: a config lease is memory-only there by design, so
// the value is back at baseline AND the record is gone. This is the case core
// could not see before this phase.
uoLinkClient.getLeases = async () => ({
ok: true,
status: 200,
data: { leases: [{ key: 'PlayerCaps.SkillCap', current: '1000', held: false }] },
})
assert.deepEqual(await lease().inForce(), { ok: true, held: false })
})
test('a shard that cannot answer leaves the ledger alone', async () => {
uoLinkClient.getLeases = async () => ({ ok: false, status: 503, error: 'shard not connected' })
const answer = await lease().inForce()
assert.equal(answer.ok, false)
// `ok: false` is what core reads as "I could not ask", and it keeps believing
// its own ledger. Never `held: false`, which would orphan a live lease the
// first time a sidecar was slow.
assert.equal(answer.held, undefined)
assert.equal((await lease().read()).ok, false)
})
// ── Participation ──────────────────────────────────────────────────────────
test('open resolves a named place to the point the shard counts around', async () => {
const answer = await byId('uo.participation.open').perform({
runId: 42,
idempotencyKey: 'k-1',
params: { place: 'Felucca/Britain', radius: 40, durationMinutes: 120 },
})
assert.equal(answer.ok, true)
assert.deepEqual(calls.open[0], {
runId: 42,
map: 'Felucca',
x: 1496,
y: 1628,
radius: 40,
holdMs: 7_200_000,
idempotencyKey: 'k-1',
})
assert.deepEqual(answer.resources, [
{ kind: 'participation', ref: '42', payload: { runId: 42, place: 'Felucca/Britain', radius: 40 } },
])
})
test('a place the atlas does not know is a refusal an author can read, not a retry', async () => {
const answer = await byId('uo.participation.open').perform({
runId: 42,
idempotencyKey: 'k-1',
params: { place: 'Felucca/Atlantis', radius: 40 },
})
assert.equal(answer.ok, false)
assert.equal(answer.retry, false)
assert.match(answer.error, /no landmark called "Atlantis"/)
assert.equal(calls.open.length, 0)
})
test('an area outside the bound is refused before anything is sent', async () => {
for (const radius of [0, -1, actions.MAX_AREA_RADIUS + 1, 1.5]) {
const answer = await byId('uo.participation.open').perform({
runId: 42,
idempotencyKey: 'k-1',
params: { place: 'Felucca/Britain', radius },
})
assert.equal(answer.ok, false, String(radius))
assert.equal(answer.retry, false, String(radius))
}
assert.equal(calls.open.length, 0)
})
test('a dry run checks the place and the radius and opens nothing', async () => {
const good = await byId('uo.participation.open').perform({
runId: 42,
idempotencyKey: 'k-1',
params: { place: 'Felucca/Britain', radius: 40 },
verify: true,
})
assert.deepEqual(good, { ok: true })
assert.equal(calls.open.length, 0)
// And it is a real check rather than an unconditional yes: the failure an
// author most wants caught before the night of the event is a place that is not
// on this shard's map.
const bad = await byId('uo.participation.open').perform({
runId: 42,
idempotencyKey: 'k-1',
params: { place: 'Felucca/Atlantis', radius: 40 },
verify: true,
})
assert.equal(bad.ok, false)
})
test('the ledger is NOT reconciled by boot stamp, unlike everything else here', async () => {
// The phase's one genuine divergence from wave 1. `reconcileByBootId` works
// because a crier line and a news article live in shard memory, so a changed
// `bootId` IS the proof they are gone. A participation ledger is written into
// the world save specifically so that it survives a restart — reporting it lost
// on a boot change would orphan the one resource the phase persisted.
const open = byId('uo.participation.open')
assert.notEqual(open.reconcile, actions.reconcileByBootId)
// No stamp on the resource either, so nothing downstream can be tempted to
// compare one.
const answer = await open.perform({
runId: 42,
idempotencyKey: 'k-1',
params: { place: 'Felucca/Britain', radius: 40 },
})
assert.equal(answer.resources[0].payload.bootId, undefined)
// It asks instead, and only an explicit 404 takes a row out.
assert.deepEqual(await open.reconcile({ resources: [{ ref: '42' }] }), { ok: true, inForce: ['42'] })
uoLinkClient.snapshotParticipation = async () => ({ ok: false, status: 404, data: {} })
assert.deepEqual(await open.reconcile({ resources: [{ ref: '42' }] }), { ok: true, inForce: [] })
// A shard that is down has not said the ledger is gone.
uoLinkClient.snapshotParticipation = async () => ({ ok: false, status: 503, data: {} })
assert.deepEqual(await open.reconcile({ resources: [{ ref: '42' }] }), { ok: true, inForce: ['42'] })
})
test('a run the shard has already forgotten is a successful revert', async () => {
// §L: "gone, and that is fine". A shard that restarted past its grace window,
// or a second teardown attempt, must not leave a row failing forever.
uoLinkClient.closeParticipation = async () => ({ ok: false, status: 404, data: {} })
assert.deepEqual(await byId('uo.participation.open').revert({ resources: [{ ref: '42' }] }), { ok: true })
uoLinkClient.closeParticipation = async () => ({ ok: false, status: 503, data: {} })
assert.deepEqual(
await byId('uo.participation.open').revert({ resources: [{ ref: '42' }] }),
{ ok: true, failed: ['42'] },
)
})
test('collect files the tally as participants, keyed by character serial', async () => {
uoLinkClient.snapshotParticipation = async (b) => {
calls.snapshot.push(b)
return {
ok: true,
status: 200,
data: {
participants: [
{
serial: '0x400150E8',
name: 'Darrow',
acct: 'seed_001',
webId: '17',
seconds: 3600,
minutes: '60.00',
kills: 3,
score: '75.0000',
firstMs: 1788550182074,
},
// No account link: the shard reports no webId, and there is nothing to
// resolve. Most characters are this one.
{
serial: '0x1',
name: 'Nobody',
seconds: 60,
minutes: '1.00',
kills: 0,
score: '1.0000',
firstMs: 1788550182074,
},
],
},
}
}
const answer = await byId('uo.participation.collect').perform({ runId: 42, idempotencyKey: 'k-2' })
assert.equal(answer.ok, true)
assert.equal(calls.snapshot[0].idempotencyKey, 'k-2')
assert.deepEqual(answer.participants.map((p) => p.memberKey), ['0x400150E8', '0x1'])
// The one field core will not take on trust: it is a foreign key into `users`,
// so a serial passed here would either fail the insert or attribute somebody's
// attendance to a stranger.
assert.equal(answer.participants[0].userId, 17)
assert.equal(answer.participants[1].userId, undefined)
// The score is opaque to core; the components are carried so a results table
// can say why somebody scored what they did.
assert.deepEqual(answer.participants[0].meta, {
name: 'Darrow', seconds: 3600, minutes: '60.00', kills: 3,
})
})
test('a webId that is not a positive integer resolves to nothing at all', () => {
for (const bad of [null, undefined, '', 'abc', '0', '-3', '1.5', {}]) {
assert.equal(actions.webUserId(bad), undefined, JSON.stringify(bad))
}
assert.equal(actions.webUserId('17'), 17)
assert.equal(actions.webUserId(17), 17)
})
test('a busy shard is retried, because the work is happening', async () => {
// 425 is `bridge.busy`: a snapshot of this run is already walking across Core
// ticks. Transient by construction, and deliberately not in PERMANENT_STATUSES.
uoLinkClient.snapshotParticipation = async () => ({
ok: false,
status: 425,
data: { kind: 'bridge.busy', reason: 'a command under this key is in flight' },
})
const answer = await byId('uo.participation.collect').perform({ runId: 42, idempotencyKey: 'k-2' })
assert.equal(answer.ok, false)
assert.equal(answer.retry, true)
// Where the event plane simply being switched off is not: 403 is an operator's
// deliberate refusal and will still be true in sixty seconds.
uoLinkClient.snapshotParticipation = async () => ({
ok: false,
status: 403,
data: { kind: 'participation.error', reason: 'the event plane is disabled on this shard' },
})
const off = await byId('uo.participation.collect').perform({ runId: 42, idempotencyKey: 'k-2' })
assert.equal(off.retry, false)
// And the shard's own words reach the run log, because for an event that ran at
// four in the morning that log is the only place anyone will learn why.
assert.match(off.error, /event plane is disabled/)
})
test('a dry run of collect reads nothing', async () => {
assert.deepEqual(
await byId('uo.participation.collect').perform({ runId: 42, idempotencyKey: 'k-2', verify: true }),
{ ok: true },
)
assert.equal(calls.snapshot.length, 0)
})

File diff suppressed because it is too large Load Diff

View File

@@ -17,9 +17,10 @@ const shardStateModel = require('../model/shardState/shardState.model')
const shardLinksModel = require('../model/shardLinks/shardLinks.model')
const shardMarketModel = require('../model/shardMarket/shardMarket.model')
const uoLinkConfigModel = require('../model/uoLinkConfig/uoLinkConfig.model')
const { settings: settingsModel } = require('../core')
const { settings: settingsModel, events: coreEvents } = require('../core')
const broadcaster = require('./shardBroadcast')
const shardPush = require('./shardPush')
const shardEngagement = require('./shardEngagement')
const defaultLog = require('../core').logger('shard-ingest')
// Notable kinds appended to the shard_events log. High-frequency/session kinds
@@ -45,6 +46,12 @@ const LOGGED_KINDS = new Set([
'server.crashed',
// Protocol 2.0: a real-time guild join (the board itself is state, not logged).
'guild.join',
// Protocol 4: the departure counterpart to guild.join, and logged for the same
// reason — it is what a "so-and-so left" feed reads. `guild.roster` deliberately
// stays out: it is board state like guild.update, and it is the one fat frame on
// the wire (~69 bytes per member), so logging it would bloat shard_events with
// a full membership snapshot on every membership change.
'guild.leave',
// Protocol 2.0 provisioning audit (admin channel only — not in PUBLIC_KINDS).
'account.audit',
'account.unlinked',
@@ -56,6 +63,11 @@ const LOGGED_KINDS = new Set([
const state = { bootId: null }
function reset() {
state.bootId = null
// The engagement mapper's transition/threshold tracker is per-process state of
// exactly the same kind as `bootId`, so it is reset by the same call. A test
// that reset one and not the other would see a champion spawn that started in
// the previous test.
shardEngagement.reset()
}
// Should this event be written to the append-only log?
@@ -91,11 +103,12 @@ async function resolveShardName(shard, deps) {
// Apply the state-change side effect for a kind (if any). Returns a promise.
async function applyStateChange(event, deps) {
const { shardState, uoLinkConfig, log } = deps
const { shardState, uoLinkConfig, eventsReconcile, fromBackfill, log } = deps
switch (event.kind) {
case 'server.hello': {
const incoming = event.bootId || null
if (incoming && state.bootId && incoming !== state.bootId) {
const restarted = Boolean(incoming && state.bootId && incoming !== state.bootId)
if (restarted) {
log.warn('shard restarted (bootId changed) — clearing online roster', {
from: state.bootId,
to: incoming,
@@ -104,6 +117,31 @@ async function applyStateChange(event, deps) {
}
if (incoming) state.bootId = incoming
await uoLinkConfig.recordStatus({ pluginConnected: true, bootId: incoming, lastEventAt: event.t })
if (restarted && !fromBackfill) {
// EVENTS.md F: core has no concept of the game being up, so the module
// says when a ledger of live shard resources has become a claim about a
// world that no longer exists. This is that moment, and a changed
// `bootId` is the only thing that distinguishes it from a sidecar
// reconnect — which changes nothing in the game and must not orphan a row.
//
// **After `recordStatus`, and that ordering is load-bearing.** Every
// action's `reconcile()` decides what is still in force by comparing its
// stamp against the CURRENT boot id, which it reads back out of this
// row. Asking first would have every resource compared against the boot
// that has just ended, and every one of them would look live.
//
// **And never on a backfill replay**, which is the same rule the
// engagement fan-out and the SSE broadcast state below and is far more
// expensive to break here. A reconnect replays the last several
// `server.hello` frames in order — this rig saw three, each with a
// different `bootId` — so every replayed frame looks like a restart, and
// the intermediate ones would compare a resource stamped with the CURRENT
// boot against a boot that ended hours ago. The row is then `orphaned`:
// a live crier line core will never take down again, lost to nothing
// worse than the website reconnecting. The website-was-down case is not
// missed by skipping these — core asks every module at its own boot.
eventsReconcile()
}
return
}
case 'server.shutdown':
@@ -163,8 +201,14 @@ async function applyStateChange(event, deps) {
name: event.name,
ownerSerial: event.ownerSerial,
ownerAcct: event.ownerAcct,
// Protocol 5. `ownerName` used to arrive only on house.update, so a house
// that had decayed but never been swept into the registry named an account
// and no character. It rides house.decay now, which is the frame the IDOC
// page is actually built from.
ownerName: event.ownerName,
builtOn: event.builtOn,
lastRefreshed: event.lastRefreshed,
schedule: event.schedule,
})
return
case 'champ.update':
@@ -187,6 +231,16 @@ async function applyStateChange(event, deps) {
case 'guild.remove':
await shardState.removeGuild(event.id)
return
// Protocol 4: membership. A roster arrives in one frame for any realistic
// guild and in several for one over the shard's cap — upsertGuildRoster
// handles both. guild.leave is advisory; the next roster would converge
// anyway, but applying it shows the departure at once.
case 'guild.roster':
await shardState.upsertGuildRoster(event)
return
case 'guild.leave':
await shardState.removeGuildMember(event)
return
case 'city.update':
// Upserts the board AND captures term history (idempotent).
await shardState.upsertGovernor(event)
@@ -263,6 +317,16 @@ function resolveDeps(deps) {
settings: deps.settings || settingsModel,
broadcast: deps.broadcast || broadcaster.broadcast,
pushDispatch: deps.pushDispatch || shardPush.fromShardEvent,
engagement: deps.engagement || shardEngagement.fromShardEvent,
// MODULE_API 1.10.0 (EVENTS.md F, Phase 8). Injectable for the same reason
// every member above is: a test that asserted a shard restart triggers a
// reconcile must be able to see the call without a live event engine behind
// it.
eventsReconcile: deps.eventsReconcile || (() => coreEvents.reconcile()),
// Not injectable — it is the caller's statement about this frame rather than
// a dependency. It reaches `applyStateChange` because the reconcile below is
// the one state change that must not act on a replay; see the note there.
fromBackfill: Boolean(deps.fromBackfill),
log: deps.log || defaultLog,
}
}
@@ -278,6 +342,34 @@ async function ingest(event, deps = {}) {
let stored = false
let logged = false
// **The engagement fan-out runs BEFORE the state write, and that ordering is
// load-bearing rather than incidental** (ENGAGEMENT.md Phase 11). Three of the
// mappings read a row that `applyStateChange` is about to delete or replace:
//
// • `account.unlinked` drops the `shard_account_links` row — the row that
// turns the account into the one person who needs to be told it was
// unlinked. Resolving afterwards finds nobody, every time.
// • `house.remove` drops the house, whose stored `ownerAcct` is the only place
// the owner of a collapsed house is named (the frame carries a serial alone).
// • `guild.leave` / `guild.remove` need the roster and the board mirror to
// name who left and which guild it was.
//
// Awaited, unlike the broadcast and the push tickle below, and this is the one
// place this file waits on a notification path. It has to: the whole point is
// that the read happens first, and a fire-and-forget promise would race the
// DELETE it is trying to precede. `fromShardEvent` never throws and never opens
// a socket — it resolves ids and hands the engine an envelope, which does its
// own work off the caller's stack (`emit` is deliberately not awaited inside).
// Backfilled frames are excluded for the same reason the broadcast is: a
// reconnect replay must not re-notify anyone about events from hours ago.
if (!deps.fromBackfill) {
try {
await d.engagement(event)
} catch (err) {
d.log.warn('engagement fan-out failed', { kind: event.kind, message: err.message })
}
}
try {
await applyStateChange(event, d)
} catch (err) {

View File

@@ -83,7 +83,27 @@ const FEATURES = {
// ── Shipped before v3. Defaults reproduce the previous hardcoded behavior. ──
status: { audience: 'anonymous', fields: {} },
activity: { audience: 'anonymous', fields: {} },
champs: { audience: 'anonymous', fields: {} },
// Protocol 6 adds `champ.boss.killed` to this feature, and with it the first
// field on a champs frame that is about PEOPLE rather than about an altar.
//
// `damagers` is the ranked table of who fought the boss and for how much. It is
// the honest basis for "who slew the champion" and it is also a performance
// record of named players that nobody consented to publish, which is precisely
// the tension the ladder exists to let a shard resolve for itself. It defaults
// to `staff`: the kill is public (a champion falling is announced in-world and
// is the content the board is for), the roll of who did the damage is not. A
// shard that wants a public board lowers one rule.
//
// Nested for the same reason `market.fees` and `houses.schedule` are: one rule
// covers the whole table rather than a rule per column, and the columns here
// are actor objects whose `acct`/`webId` remain admin-only by the locked-field
// rule regardless of what this is set to.
//
// `killer` is deliberately NOT listed. It is the single actor whose blow landed
// last, it is announced in-game to everyone present, and it is the same shape
// and the same disclosure `mob.killed` has published on the public activity
// feed since before this framework existed.
champs: { audience: 'anonymous', fields: { damagers: 'staff' } },
guilds: { audience: 'anonymous', fields: {} },
governors: { audience: 'anonymous', fields: {} },
// The public Houses page showed IDOC location only; owner/price were staff.
@@ -91,9 +111,22 @@ const FEATURES = {
// `ownerName`/`ownerSerial` are the flattened spellings shapeHouse emits on the
// REST read models. Both are listed so one rule covers the wire and the read
// model — the flattened `ownerAcct` needs no entry, being locked by rule 1.
// Protocol 5 adds `schedule` — when the next stage lands and, where ServUO can
// actually know it, when the house collapses. It defaults to `anonymous` because
// that is what the public IDOC page is FOR: the countdown is the content, and a
// house at IDOC is already announced in game. It is listed rather than left
// unconfigurable so a shard that considers a precise collapse time an unfair
// advantage can raise it, and it is one NESTED key so raising it hides the whole
// schedule rather than three of its four parts.
houses: {
audience: 'anonymous',
fields: { owner: 'staff', ownerName: 'staff', ownerSerial: 'staff', price: 'staff' },
fields: {
owner: 'staff',
ownerName: 'staff',
ownerSerial: 'staff',
price: 'staff',
schedule: 'anonymous',
},
},
// /public/shard/online listed linked staff to everyone but gated location to
// admin+moderator — which is exactly the `staff` rung.
@@ -124,9 +157,25 @@ const FEATURES = {
// `ownerSerial` is listed alongside `ownerName` for the same reason `houses`
// lists both: an admin who hides the owner's name and is left with a serial
// that every other board resolves back to that name has not hidden anything.
// Protocol 5 adds `fees`, and it does NOT follow the rest of this feature's
// defaults. The shop name, the owner and the location are already visible to any
// player through the stock in-game Vendor Search gump, which is the whole argument
// for publishing them. A vendor's held gold, daily charge and dismissal date are
// not: in game they are visible to the OWNER, on that vendor's own gump. Publishing
// them anonymously would be a genuinely new disclosure and a targeting aid — it
// says which shops are about to be abandoned and how much coin is sitting in each.
// So it defaults to `admin`, the only default here that does not reproduce prior
// behaviour, because there is no prior behaviour to reproduce.
//
// Nested for the same reason `location` is: one rule covers all seven parts.
market: {
audience: 'anonymous',
fields: { ownerName: 'anonymous', ownerSerial: 'anonymous', location: 'anonymous' },
fields: {
ownerName: 'anonymous',
ownerSerial: 'anonymous',
location: 'anonymous',
fees: 'admin',
},
},
}
@@ -160,9 +209,20 @@ const KIND_FEATURE = new Map(
// boards
'champ.update': 'champs',
'champ.remove': 'champs',
// Protocol 6. Without this line rule 2 would fail the new kind closed to
// admin-only — correct as a default, and wrong as an outcome: a champion
// falling is exactly what the public board is for.
'champ.boss.killed': 'champs',
'guild.update': 'guilds',
'guild.remove': 'guilds',
'guild.join': 'guilds',
// Protocol 4. Both carry actor data — a roster is an array of actor objects
// and guild.leave names a serial — so they ride the same `guilds` feature and
// the same locked-field rules: `acct`/`webId` inside a roster member are
// stripped below admin by suffix, exactly as `guild.leader.acct` already is.
// Without these two lines rule 2 would fail them closed to admin-only.
'guild.roster': 'guilds',
'guild.leave': 'guilds',
'city.update': 'governors',
'presence.online': 'presence',
'region.enter': 'presence',
@@ -170,6 +230,13 @@ const KIND_FEATURE = new Map(
// registry (house.update / house.remove — owner, price, co-owners) stays
// off the map deliberately, so it remains admin-only exactly as before.
'house.decay': 'houses',
// Protocol 5's `account.login.result` is deliberately NOT here, and the omission
// is the decision rather than an oversight. Rule 2 fails an unmapped kind closed
// to admin-only, which is the right answer for a frame that carries an IP address
// and says whether a password was accepted — the same reasoning that keeps
// house.update and account.login.attempt off this map. Adding it would mean
// choosing a feature an admin could then widen, and there is no rung below admin
// this frame belongs on.
// v3
'world.ruleset': 'ruleset',
'points.board': 'leaderboards',
@@ -179,6 +246,14 @@ const KIND_FEATURE = new Map(
// needs it live. An admin can turn it on.
'vendor.listing': 'market',
'vendor.listing.remove': 'market',
// Protocol 6 part b's `lease.applied` and `lease.expired` are deliberately NOT
// here, on the same reasoning that keeps `account.login.result` off it. They are
// operational frames about the WEBSITE changing this shard's configuration --
// which key, from what to what, on whose run, and whether the shard's own
// deadline had to put it back because nobody asked. Rule 2 fails an unmapped
// kind closed to admin-only, which is where an audit trail of the site's writes
// belongs; mapping them would mean choosing a feature an operator could then
// widen, and there is no rung below admin these frames belong on.
}),
)

View File

@@ -351,10 +351,17 @@ function tagValue(block, name) {
* ~40 fields on every one of ~6,500 records to keep 14 of them. The records are
* flat, so a per-record regex sweep is both correct and cheap.
*
* Only the fields the site can actually show are kept. Everything to do with
* triggering, refractory windows, proximity, sequential spawning, sounds and
* `UniqueId` is dropped here rather than downstream — that is what holds the
* committed artifact under 1 MB.
* Only the fields the site can actually use are kept. Everything to do with
* triggering, refractory windows, proximity, sequential spawning and sounds is
* dropped here rather than downstream, which is what keeps the parsed atlas
* small.
*
* **`UniqueId` was on that list until Phase 12b and is now kept**, because a
* property lease has to name one particular spawner and this is the only name
* for one that exists off-shard. The line that justified dropping it cited a
* committed artifact; there is no committed artifact — `spawnAtlasSource.js`
* says so in its own header ("nothing is precomputed and committed") — so the
* only real cost was ~37 bytes a row in a table, and it bought a dropdown.
*
* NOTE: the facet comes from each record's own `<Map>`, never from the file
* name. `Eodon.xml`, `GravewaterLake.xml` and the other named-area files all
@@ -389,6 +396,14 @@ function parsePoints(source) {
points.push({
name: tagValue(block, 'Name'),
// **Kept from Phase 12b, having been discarded since the atlas shipped.**
// It is `XmlSpawner.UniqueId` — the shard writes it into the spawn files
// and carries it on the live spawner — so it is the ONE way an authoring
// form can name a particular spawner without the shard being up. A serial
// cannot do that job: serials are assigned when the world is built and
// nothing off-shard knows them, which is why a property lease that could
// only be addressed by serial could have no dropdown at all.
uniqueId: tagValue(block, 'UniqueId'),
facet,
x: toInt(tagValue(block, 'X')),
y: toInt(tagValue(block, 'Y')),
@@ -548,6 +563,48 @@ function walkLocations(node, facet, path, out) {
* A spawn with no `type` is randomised on every activation, which the site must
* render as "random" rather than as an empty type.
*/
/**
* Item types a shard uses as decoration, from one `Data/Decoration/*.cfg`.
*
* The format is a header line naming a type and an item id, optionally followed
* by a parenthesised property list, and then one `x y z` line per placement:
*
* ```
* # switch
* Static 0x108F
* 5552 1864 11
* ```
*
* Only the header matters here. The properties are decoration-authoring details
* (`Hue=`, `Facing=`, `Name=`) and the coordinates are where the SHARD put its
* own scenery, neither of which an event author is choosing — they pick a type
* and a place of their own.
*
* Returns one entry per header line, not per distinct type: the same type
* appears under many item ids (a `BarredMetalDoor` for each facing), and how
* often a shard reaches for something is worth keeping. `spawnAtlasSource`
* aggregates.
*/
function parseDecoration(source) {
const out = []
if (!source) return out
for (const raw of String(source).split(/\r?\n/)) {
const line = raw.trim()
// A coordinate line starts with a digit or a minus (z is often negative),
// so the type test is not merely "not a comment".
if (line === '' || line.startsWith('#')) continue
const match = /^([A-Za-z_][A-Za-z0-9_]*)\s+0x([0-9A-Fa-f]+)/.exec(line)
if (!match) continue
out.push({ type: match[1], itemId: parseInt(match[2], 16) })
}
return out
}
function parseChampions(source) {
const root = parseXml(source)
const champions = []
@@ -675,6 +732,7 @@ module.exports = {
parseRegions,
parseLocations,
parseChampions,
parseDecoration,
buildPlacementIndex,
resolveRegion,
facetKey,

View File

@@ -23,6 +23,7 @@ const {
parseRegions,
parseLocations,
parseChampions,
parseDecoration,
buildPlacementIndex,
buildFacetIndex,
resolveFacetName,
@@ -35,6 +36,7 @@ const REGIONS_FILE = path.join('Data', 'Regions.xml')
const LOCATIONS_DIR = path.join('Data', 'Locations')
const SPAWNS_DIR = 'Spawns'
const CHAMPIONS_FILE = path.join('Config', 'ChampionSpawns.xml')
const DECORATION_DIR = path.join('Data', 'Decoration')
class AtlasSourceError extends Error {
constructor(message, code) {
@@ -62,6 +64,33 @@ function listXml(dir) {
}
}
/**
* Every `.cfg` under `dir`, recursively, tree-relative and forward-slashed.
*
* Recursive because `Data/Decoration` nests two deep in places
* (`Magincia/Trammel`, `Stygian Abyss/Ter Mur`, `Old/Britannia`) and a flat read
* would silently index a third of what the shard actually has — the failure
* mode being a dropdown that is quietly missing whole expansions rather than an
* error anyone would notice.
*/
function listCfgTree(dir, prefix = '') {
let entries
try {
entries = fs.readdirSync(dir, { withFileTypes: true })
} catch (err) {
if (err.code === 'ENOENT' || err.code === 'ENOTDIR') return []
throw err
}
const out = []
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
const rel = prefix ? `${prefix}/${entry.name}` : entry.name
if (entry.isDirectory()) out.push(...listCfgTree(path.join(dir, entry.name), rel))
else if (entry.name.toLowerCase().endsWith('.cfg')) out.push(rel)
}
return out
}
function readIfPresent(file) {
try {
return fs.readFileSync(file, 'utf8')
@@ -111,6 +140,12 @@ function readSources(root) {
push('Config/ChampionSpawns.xml', path.join(root, CHAMPIONS_FILE))
// Optional, like the champion file: a shard that has stripped its decoration still
// has a usable atlas, it just cannot offer the decoration verb anything to place.
for (const rel of listCfgTree(path.join(root, DECORATION_DIR))) {
push(`Data/Decoration/${rel}`, path.join(root, DECORATION_DIR, rel))
}
return { files }
}
@@ -140,8 +175,18 @@ function hashSources(root) {
*
* 2 — respawn delays normalised to seconds (they are per-record minutes OR
* seconds in the source, decided by `DelayInSec`).
* 3 — the decoration index, from `Data/Decoration/**\/*.cfg`.
* 4 — a spawn point keeps its `UniqueId`, which is what a property lease
* targets (Phase 12b). The bump is what re-reads a tree the boot path
* would otherwise skip on an unchanged hash — the source files have not
* changed, only what is kept from them.
* 5 — and it did NOT keep it: version 4 bumped the parser and the aggregator
* below still discarded the field, so the intent above shipped as a
* comment. This bump is what makes an already-imported tree re-read now
* that the mapping keeps it; without it `sameSources` sees an unchanged
* tree and every existing install stays empty.
*/
const PARSER_VERSION = 2
const PARSER_VERSION = 5
/** True when two source fingerprints describe the same tree. */
function sameSources(a, b) {
@@ -264,6 +309,16 @@ function buildAtlas(root, options = {}) {
const place = resolveRegion(point.x, point.y, point.facet, placement, resolveOpts)
return {
name: point.name,
// **The field this whole `PARSER_VERSION` note was about, and it was
// dropped right here.** The parser has produced it since Phase 12b and
// the column and the query have both been waiting for it, but this
// mapping rebuilds each point from an explicit field list and `uniqueId`
// was not on it — so every row landed with `unique_id` NULL, and
// `listSpawners`, whose WHERE is `unique_id IS NOT NULL`, could only ever
// answer empty. That made `uo.options.spawners` an empty dropdown and
// every Phase 12b object-property lease unauthorable, with nothing on the
// form to say why. Found by the Phase 16b walk against a released bundle.
uniqueId: point.uniqueId,
facet: point.facet,
x: point.x,
y: point.y,
@@ -294,6 +349,43 @@ function buildAtlas(root, options = {}) {
}
})
// Decoration: what this shard already calls scenery, which is what makes the
// authoring dropdown the operator's own vocabulary rather than our taste.
//
// **Keyed case-INSENSITIVELY, because the decoration files disagree with
// themselves about casing.** Stock 57.4 names four types under two spellings
// each — `CheckerBoard`/`Checkerboard`, `ChessBoard`/`Chessboard`,
// `MetalChest`/`Metalchest`, `SpinningWheelEastAddon`/`SpinningwheelEastAddon`
// — and in every pair exactly one is a real class, the other a mis-cased line
// the shard's own loader resolves anyway. A case-sensitive Map keeps both, and
// then `shard_decor_types.type` (a PRIMARY KEY under MariaDB's default
// `..._ai_ci` collation, which folds case) rejects the second row and takes the
// WHOLE import transaction down with it. That is not a decoration bug: with no
// atlas, every option source answers empty and no world verb can be authored at
// all. The shard end of this feature already knew — `BridgeWorld.cs` resolves a
// decor type with `FindTypeByName(name, ignoreCase: true)` and says why — so
// folding here is the two ends agreeing rather than a new rule.
//
// The first spelling seen wins, exactly as the first item id does. Either
// spelling resolves on the shard, so which one survives is cosmetic.
const decorUses = new Map()
for (const file of files) {
if (!file.label.startsWith('Data/Decoration/')) continue
for (const entry of parseDecoration(file.text)) {
const key = entry.type.toLowerCase()
const seen = decorUses.get(key)
if (seen) {
seen.uses += 1
continue
}
// The FIRST item id wins, and it is only a preview: a type appears under
// as many ids as it has facings or variants, and picking one arbitrarily
// is honest in a way that picking "the most used" would not be.
decorUses.set(key, { type: entry.type, itemId: entry.itemId, uses: 1 })
}
}
const decor = [...decorUses.values()].sort((a, b) => a.type.localeCompare(b.type))
const creatures = aggregateCreatures(points)
const facets = [...new Set(points.map((point) => point.facet))].sort()
const unresolved = points.filter((point) => !point.region && !point.landmark).length
@@ -311,6 +403,7 @@ function buildAtlas(root, options = {}) {
regions: regions.length,
landmarks: landmarks.length,
champions: champions.length,
decor: decor.length,
unresolvedPoints: unresolved,
},
source,
@@ -321,6 +414,7 @@ function buildAtlas(root, options = {}) {
landmarks,
champions,
points,
decor,
}
}

View File

@@ -11,11 +11,48 @@
// `X-UOLink-Version: <protocol>` so a protocol mismatch is caught (409) rather
// than mis-parsed. Config is cached for a few seconds to avoid decrypting the
// token on every call.
//
// ── Protocol 6: `idempotencyKey` on a write ────────────────────────────────
//
// The three write helpers the event engine drives take an optional
// `idempotencyKey`, which the sidecar passes to the shard verbatim. The shard
// executes a key at most once and answers a repeat with the ORIGINAL reply, which
// is what makes retrying a world write safe — before it, a lost acknowledgement
// and a command that never applied were the same event seen from here.
//
// **A key is a function of the caller's unit of work, never of the attempt.** The
// event runner derives it from `sha256(runId|stepId)`, so every retry of one step
// carries the same key and a different step never collides with it. Passing a
// fresh value per call would satisfy the type and defeat the entire mechanism.
//
// **The DELETEs deliberately take no key.** Their idempotency is inherent — the
// second removal of a town-crier entry or a news article is a no-op the shard is
// already happy to perform — and the sidecar builds those commands from the path
// rather than from a body, so carrying one would be a protocol change bought for
// a guarantee that already holds.
//
// A caller that sends no key gets exactly the pre-protocol-6 behaviour, which is
// what leaves the admin screens (which send none, being driven by a human who can
// see whether the thing happened) unchanged.
//
// One new status can now come back from a keyed write: **425**, the sidecar's
// mapping of `bridge.busy` — a command under this key is still in flight on the
// shard. It is transient and retryable, and `shardAnnounce.classify` already
// treats it so by falling through to its retry case.
const uoLinkConfig = require('../model/uoLinkConfig/uoLinkConfig.model')
const log = require('../core').logger('uo-link-client')
const TIMEOUT_MS = 12000 // sidecar waits up to 10s on the shard before 504
// The sidecar waits up to 10s on the shard before answering 504, so this sits
// just above it — every call answers rather than being abandoned mid-flight.
//
// **Exported because the event actions are declared against it** (EVENTS_PLAN.md
// Phase 9). An action's `budgetMs` must exceed this or core's dispatch deadline
// fires first and classifies the step `retry` without asking the module, which
// for a broadcast means announcing twice. `config/uoEventActions.js` states that
// relationship and its test asserts it, and both need the number to come from
// here rather than from a copy that can drift.
const TIMEOUT_MS = 12000
const CONFIG_TTL_MS = 5000
let cachedConfig = null
@@ -159,15 +196,18 @@ const createAccount = ({ actor, account, password, websiteUserId, ip }) =>
})
const unlinkAccount = ({ actor, account }) =>
call(`/link/${encodeURIComponent(account)}`, { method: 'DELETE', body: { actor } })
const postTownCrier = ({ id, lines, durationSec }) =>
call('/towncrier', { method: 'POST', body: { id, lines, durationSec } })
const postTownCrier = ({ id, lines, durationSec, idempotencyKey }) =>
call('/towncrier', { method: 'POST', body: { id, lines, durationSec, idempotencyKey } })
const deleteTownCrier = (id) => call(`/towncrier/${encodeURIComponent(id)}`, { method: 'DELETE' })
// Town Cryer News gump (Protocol 2.1). A full article (title/HTML body/image/URL)
// in the in-game News window; re-posting the same id REPLACES it. `announce`
// (default true on the sidecar) controls whether the criers proclaim the title.
const postNews = ({ id, title, body, image, url, announce }) =>
call('/news', { method: 'POST', body: { id: String(id), title, body, image, url, announce } })
const postNews = ({ id, title, body, image, url, announce, idempotencyKey }) =>
call('/news', {
method: 'POST',
body: { id: String(id), title, body, image, url, announce, idempotencyKey },
})
const deleteNews = (id) => call(`/news/${encodeURIComponent(id)}`, { method: 'DELETE' })
// ── Staff write plane (§6) ─────────────────────────────────────────────────
@@ -180,15 +220,166 @@ const adminBan = ({ actor, account, serial, durationSec, reason }) =>
call('/admin/ban', { method: 'POST', body: { actor, account, serial, durationSec, reason } })
const adminUnban = ({ actor, account }) =>
call('/admin/unban', { method: 'POST', body: { actor, account } })
const adminBroadcast = ({ actor, text, hue }) =>
call('/admin/broadcast', { method: 'POST', body: { actor, text, hue } })
const adminBroadcast = ({ actor, text, hue, idempotencyKey }) =>
call('/admin/broadcast', { method: 'POST', body: { actor, text, hue, idempotencyKey } })
// ── The event plane (protocol 6, EVENTS_PLAN.md Phase 11b) ─────────────────
//
// Leases and the run-scoped participation ledger. Both are gated on the shard by
// `Bridge.EventsEnabled`, which is deliberately NOT the admin plane's switch: an
// operator consenting to staff moderation from a screen has not thereby consented
// to the website changing their world on a schedule at four in the morning. A
// shard with the plane off answers 403, and the actions turn that into a refusal
// an author can read rather than a retry.
// Every lease this shard offers, with what each is worth right now and what is
// holding it. One read serves both questions core asks — `read()` wants the
// current value, `inForce()` wants to know whether the shard still has a record
// of the hold — so a lease costs one round trip, not two.
// **A targeted lease must name its target here** (protocol 7 part b). A key like
// `Spawner.MaxCount` is one capability over thousands of spawners, so it has no
// single `current` and the catalog walk cannot fill one in — while `read()` needs
// exactly one value for exactly one target before it applies anything. Naming both
// narrows the frame to that row and fills it.
//
// The frame also carries `holds`: every hold this shard has, whatever key or
// target. A catalog walk enumerates the KEYS but can never enumerate the holds on
// a targeted one — there is no list of spawners to walk — so `inForce()` reads
// that rather than the row's `held` flag.
const getLeases = ({ key, target } = {}) => {
const params = new URLSearchParams()
if (key) params.set('key', key)
if (target) params.set('target', target)
const query = params.toString()
return call(query ? `/lease?${query}` : '/lease')
}
// `holdMs` is authoritative and `untilMs` is display only. An absolute deadline
// computed here and honoured there is a deadline measured against two clocks, and
// a shard running ten minutes fast would restore a ten-minute lease the moment it
// took it. Values cross as TEXT whatever the lease's declared type: `1200` and
// `1200.0` are one number to a JSON parser and two strings to a compare-and-set.
const applyLease = ({ key, target, value, holdMs, untilMs, runId, idempotencyKey }) =>
call('/lease', {
method: 'POST',
body: { key, target, value: String(value), holdMs, untilMs, runId, idempotencyKey },
})
// `expected` is what this run applied and `baseline` is what to put back, both out
// of core's ledger rather than the shard's memory — so a release still works after
// a reconnect, and a shard that has forgotten the lease entirely (a restart, which
// reverts every config lease by design) answers honestly instead of refusing.
const releaseLease = ({ key, target, expected, baseline, idempotencyKey }) =>
call('/lease/release', {
method: 'POST',
body: {
key,
target,
expected: expected == null ? undefined : String(expected),
baseline: baseline == null ? undefined : String(baseline),
idempotencyKey,
},
})
// The participation ledger. The area is a map, a point and a radius rather than a
// region name, because protocol 6's own walk established that the most specific
// region containing an event is routinely anonymous.
const openParticipation = ({ runId, map, x, y, radius, holdMs, idempotencyKey }) =>
call('/participation', {
method: 'POST',
body: { runId: String(runId), map, x, y, radius, holdMs, idempotencyKey },
})
// A POST for a read, and the reason is the phase's headline: on a well-attended
// run the shard walks its members across Core ticks rather than in one inbound
// call, so a repeat arriving mid-walk is answered `bridge.busy` (425). A read that
// can legitimately be refused as a repeat in flight is not a GET.
const snapshotParticipation = ({ runId, idempotencyKey }) =>
call(`/participation/${encodeURIComponent(runId)}/snapshot`, {
method: 'POST',
body: { idempotencyKey },
})
const closeParticipation = ({ runId, idempotencyKey }) =>
call(`/participation/${encodeURIComponent(runId)}/close`, {
method: 'POST',
body: { idempotencyKey },
})
// ── The world verbs (protocol 7) ───────────────────────────────
//
// One endpoint for five author-facing verbs. `what` is the discriminator, and the
// per-verb fields ride alongside it: `type`/`name`/`hue`/`spread` for creatures and
// decoration, the three multipliers for a boss, `greeting`/`lines` for an oracle,
// `target`/`holdMs` for a gate.
//
// The shard registers every serial it places against the run and persists that
// registry, which is what makes `despawnWorld` below safe to point at a list of
// serials: it can only delete what the run actually owns.
const spawnWorld = (body) => call('/world', { method: 'POST', body })
// What the run still owns. A GET, unlike the participation snapshot: it carries no
// idempotency key and the shard answers it in one pass. An unknown run answers with an
// empty hand rather than a 404 — "owns nothing" and "never heard of it" are the same
// fact once the registry is the only record, and they stay the same fact across a
// restart, because the registry is written by the same world save as the objects it
// describes.
const ownedWorld = ({ runId }) => call(`/world/${encodeURIComponent(runId)}`)
// Give back what the run owns. No `serials` means everything, which is the call
// teardown makes. The reply splits three ways: `removed` was deleted, `gone` was
// already absent (a player killed it — an ordinary success), and `refused` was never
// this run's to delete.
//
// **It takes no idempotency key, and the parameter is gone rather than optional.**
// It used to accept one, and `revertOwned` passed the step's — the key the SPAWN
// went out under. The shard's at-most-once store is keyed on the key alone, so the
// despawn was answered with the spawn's stored reply and nothing was ever deleted.
// A repeat despawn needs no key: the second pass answers `gone`, which both ends
// already treat as a success. Removed from the signature so it cannot be handed
// one again by accident.
const despawnWorld = ({ runId, serials }) =>
call(`/world/${encodeURIComponent(runId)}/despawn`, {
method: 'POST',
body: { serials },
})
// ── Help-page (support) queue commands (§6) ────────────────────────────────
const respondPage = (pageId, { message, close }) =>
call(`/pages/${encodeURIComponent(pageId)}/respond`, { method: 'POST', body: { message, close } })
const closePage = (pageId) => call(`/pages/${encodeURIComponent(pageId)}/close`, { method: 'POST' })
// ── The one-shots (protocol 7 part b, EVENTS_PLAN.md Phase 12b) ────────────
//
// Neither owned nor borrowed: done is done. Both are gated on the shard by the
// same `Bridge.EventsEnabled` as the rest of the plane.
// What this shard will actually build, with the bounds it will build within. The
// module holds the same allowlist for its dropdown, so the form still works with
// the shard down; this is what is true when that copy is wrong.
const getGrantCatalog = () => call('/items')
// **The recipients are not sent.** The shard has held this run's participation
// ledger since it opened, keyed by the same character serials core stores as
// `member_key`, so the grant names a run and the shard resolves who was there.
// Sending a list would put the same list on the wire twice with a window in which
// the two disagree — and would have needed a core surface handing a module core's
// own participants.
const grantItem = ({ runId, item, amount, hue, name, where, idempotencyKey }) =>
call('/items/grant', {
method: 'POST',
body: { runId: String(runId), item, amount, hue, name, where, idempotencyKey },
})
// Starts a save. What actually happened rides `world.save.before`/`after` on the
// event stream, which have been there since protocol 2 — so this asserts only that
// the save was started, and a caller that needs the completion watches the feed it
// is already connected to.
const saveWorld = ({ idempotencyKey } = {}) =>
call('/world/save', { method: 'POST', body: { idempotencyKey } })
module.exports = {
TIMEOUT_MS,
invalidateConfig,
health,
getCharBySerial,
@@ -215,6 +406,18 @@ module.exports = {
deleteTownCrier,
postNews,
deleteNews,
getLeases,
applyLease,
releaseLease,
openParticipation,
snapshotParticipation,
closeParticipation,
spawnWorld,
ownedWorld,
despawnWorld,
getGrantCatalog,
grantItem,
saveWorld,
adminKick,
adminBan,
adminUnban,

54
sonar-project.properties Normal file
View File

@@ -0,0 +1,54 @@
# SonarQube analysis config for module-uo.
# Consumed by the scanner in .gitea/workflows/sonarqube.yml on push to main.
# The project key must match the one created in SonarQube (dashboard URL
# ?id=Module-uo).
sonar.projectKey=Module-uo
sonar.projectName=Module-uo
# Analysed application code.
#
# Unlike core's repo there is no `src/` directory to point at: the server half
# keeps its code at `server/` root (boot.js, core.js, index.js) beside its
# subdirectories, so the whole tree is included and the non-source parts are
# excluded below. That direction is deliberate — a new top-level server
# directory is scanned by default rather than silently unscanned, which is the
# safer way for this list to be wrong.
#
# `client/scripts` and `server/scripts` are in, not out: checkExternals.js and
# checkImports.js *are* the enforcement of MODULE_API.md §3.6 and §5.1, they
# each carry their own test suite, and both have already shipped defects that a
# reviewer missed (see MODULE_SYSTEM.md §2.7.1). Build code that decides whether
# a release is allowed out is not throwaway code.
sonar.sources=server,client/src,client/scripts
# Test code is analysed separately from sources so coverage/metrics attribute
# correctly. Both halves run on Node's built-in test runner (no browser/DOM):
# the server suite is CommonJS behind test/_setup.js, the client's is ESM.
sonar.tests=server/test,client/test
sonar.test.inclusions=server/test/**/*.test.js,client/test/**/*.test.js
# Coverage. The sonarqube.yml workflow runs both suites with Node's built-in
# test-coverage and writes an LCOV report for each BEFORE the scan runs; without
# them the dashboard shows 0% (the scanner never executes tests itself). Both
# suites are invoked from the repo root so the `SF:` paths come out
# repo-root-relative (server/..., client/src/...) and the scanner resolves them
# against the project base dir.
sonar.javascript.lcov.reportPaths=server/coverage/lcov.info,client/coverage/lcov.info
# Test execution ("Unit Tests" measure). A SEPARATE report from coverage: the
# lcov files above only populate Coverage, so without this the dashboard shows a
# coverage % but an empty "Unit Tests" tile. Written by scripts/sonar-test-reporter.mjs,
# a copy of core's — a pure leaf build helper, which is the side of the vendoring
# line that may be copied (MODULE_SYSTEM.md §2.7.1).
sonar.testExecutionReportPaths=server/coverage/test-execution.xml,client/coverage/test-execution.xml
# Never analyse dependencies, build output, generated artifacts, or fixtures.
#
# `client/dist` is the built chunk (gitignored, but the workflow builds it before
# scanning because client/test/{build,registration}.test.js import it).
# `server/swagger/doc.js` and the two committed generated artifacts at the repo
# root are inputs to and outputs of swagger-autogen, not hand-written code.
sonar.exclusions=**/node_modules/**,server/test/**,client/test/**,client/dist/**,server/swagger/**,server/data/**,server/coverage/**,client/coverage/**,**/*.min.js
sonar.sourceEncoding=UTF-8

View File

@@ -3131,6 +3131,56 @@
}
}
},
"/api/v1/public/shard/guilds/{id}": {
"get": {
"tags": [
"Public · Shard"
],
"summary": "One guild and its roster",
"description": "The detail view behind the board. Gated and projected through the same `guilds` feature, so an operator who raises that audience raises this too, and the locked acct/webId fields never survive below admin — a roster is where they appear in bulk. This page is also where core renders the Team activity feed, through the `uo.guild.detail` extension slot.",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "The guild id."
}
],
"responses": {
"200": {
"description": "The guild, with its roster",
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
},
"403": {
"description": "Forbidden"
},
"404": {
"description": "No such guild",
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
},
"500": {
"description": "Internal Server Error"
}
}
}
},
"/api/v1/public/shard/houses": {
"get": {
"tags": [