20 Commits

Author SHA1 Message Date
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
33 changed files with 7135 additions and 39 deletions

View File

@@ -101,7 +101,7 @@ server/index.js the entry point — register(ctx, api), synchronous, no
server/router/ routers + controllers, one directory per tier server/router/ routers + controllers, one directory per tier
server/model/ one directory per table family; nothing crosses the boundary server/model/ one directory per table family; nothing crosses the boundary
server/utils/ sidecar client, visibility, ingest, town crier, cliloc, atlas 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/schema.sql idempotent fragment, replayed by core's ensureSchema()
server/db/purge.sql destructive; only ever run by an explicit purge server/db/purge.sql destructive; only ever run by an explicit purge
server/scripts/ the three checks: imports, the fragment, the frozen manifest 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 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 **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. 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 A generated file nobody reviews is a generated file nobody notices going wrong, so each lands in a

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 `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. **It points at `edge` for the length of the Event System window** (org lead, 2026-09-04), and that is the one line here a reader should not tidy back. This module registers event actions from EVENTS_PLAN.md Phase 9, and `api.registerEventActions` exists only from MODULE_API 1.10.0 -- under the previous `main` pin `register()` throws and the module does not load at all, so the job would be red by construction for eight phases and would prove nothing while a real regression hid behind it. Phase 16's cutover re-pins it to `main`, which is the same commit that turns the Integration kit green again.",
"repo": "https://gitea.whitlocktech.com/RunicGateway/website.git", "repo": "https://gitea.whitlocktech.com/RunicGateway/website.git",
"ref": "963d734dcc09580a7d8bb676370b4faf9b8727b2", "ref": "d4516739b43de5cb83b8f0333f8f966280a5632f",
"refName": "main @ the Teams cutover (website#161)" "refName": "edge @ MODULE_API 1.10.0, the event module contract (website#189, #190)"
} }

View File

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

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,
}

View File

@@ -0,0 +1,967 @@
// ── module-uo's event verbs, wave 1 ────────────────────────────────────────
//
// EVENTS.md §F, EVENTS_PLAN.md Phase 9. The first three actions an event author
// can put in a step that reach the game, plus the budget dimension that bounds
// one of them and the three option sources the atlas answers.
//
// **Nothing here is new plumbing.** `uoLinkClient` has carried `adminBroadcast`,
// `postTownCrier`/`deleteTownCrier` and `postNews`/`deleteNews` since protocol
// 2.1; the admin screens have driven all three by hand for months. What this file
// adds is the declaration that lets the event engine drive them unattended —
// which is a different question, and the reason most of this file is about what
// happens when a call does NOT come back.
//
// ── The three rules that shape every declaration below ─────────────────────
//
// **1. `budgetMs` must exceed the client's own timeout, or the module never gets
// to classify its own failure.** `dispatch.classify()` answers `retry` for a
// budget timeout unconditionally and a module cannot override that — the module
// is not asked, because it is still awaiting a socket. `uoLinkClient.TIMEOUT_MS`
// is 12s and core's `DEFAULT_BUDGET_MS` is 10s, so on default settings core's
// deadline fires FIRST on every slow shard and the step is retried. Every action
// here therefore declares `budgetMs: 15000`: the client always answers first, and
// what the runner acts on is this file's judgement rather than a race.
//
// That is not a tuning detail. It is the whole of what makes rule 2 true.
//
// **2. A broadcast is retried, and protocol 6 is what changed that.** Wave 1
// shipped `uo.broadcast` answering `retry: false` to everything, because a
// retried broadcast was a second announcement to everyone online and nothing on
// the wire could make the shard refuse the repeat. A lost announcement was
// cheaper than a doubled one, and that was the whole argument.
//
// Protocol 6 removes its premise. Every write below now carries the step's
// `idempotencyKey`; the shard executes a key at most once and answers a repeat
// with the ORIGINAL reply rather than re-running it. So a retry of a broadcast
// whose acknowledgement was lost cannot announce twice — it collects the answer
// the first attempt never delivered. A shard restarting mid-run is now recovered
// from rather than written off, which is the case rule 2 used to throw away
// knowingly.
//
// Rule 1 is what keeps this true rather than merely intended: if core's deadline
// fired first the module would never be asked, and the retry would be core's
// unconditional one — carrying the same key, so still safe, but classified
// without the module's judgement.
// **2a. The one status that is new here.** A repeat arriving while the original
// is still in flight on the shard is answered `bridge.busy`, which the sidecar
// maps to **425**. It is transient by construction: the work is happening. It is
// not in `PERMANENT_STATUSES` and `classify()` falls through to retry, so it
// needs no arm of its own — but it is named so that a future tightening of that
// list has to decide about it deliberately.
//
// **3. What a shard restart wipes, `reconcile()` reports gone — and it knows
// which restart it was without asking.** There is no "list the town-crier lines"
// or "list the news articles" on the wire, and adding one would be protocol work
// for a question the module can already answer: a town-crier line and an
// event-owned news article both live in shard memory, so a restart is
// definitionally the loss of both. `perform()` stamps the shard's `bootId` into
// the resource payload and `reconcile()` reports in force exactly the rows whose
// stamp still matches. That is correct for BOTH callers — the module's own
// `ctx.events.reconcile()` on a changed `bootId`, and core's boot-time sweep,
// where the shard may not have restarted at all and answering "all gone" would
// abandon live rows.
const core = require('../core')
const uoLinkClient = require('../utils/uoLinkClient')
const uoLinkConfig = require('../model/uoLinkConfig/uoLinkConfig.model')
const shardAtlas = require('../model/shardAtlas/shardAtlas.model')
const { classify: classifySidecarWrite } = require('../utils/shardAnnounce')
const log = core.logger('uo-events')
// See rule 1 in the header. Above `uoLinkClient.TIMEOUT_MS` (12s), below core's
// `MAX_BUDGET_MS` (1h) by a mile.
const BUDGET_MS = 15000
// The sidecar's own caps, mirrored from the admin routes that already validate
// against them (`admin/uoLink.router.js` for the crier, `admin/shard.router.js`
// for the broadcast). Pre-checked here so an over-long line is a refusal a DRY
// RUN can show the author, rather than a 400 mid-run.
const MAX_BROADCAST_LEN = 300
const MAX_CRIER_LINES = 8
const MAX_CRIER_LINE_LEN = 200
const MAX_CRIER_DURATION_SEC = 86400
const MAX_NEWS_TITLE = 120
const MAX_NEWS_BODY = 900
// How many options one source will answer with. Real UO facets carry a few
// hundred regions and landmarks and ~800 constructible creature types, so this is
// comfortably above the data rather than a guess at it — and a deployment that
// exceeds it gets a log line naming the source and the counts, because a dropdown
// that silently omits the landmark an author is looking for is the defect this
// bound would otherwise introduce.
const MAX_OPTIONS = 2000
/**
* The id both keyed verbs post under.
*
* The step's idempotency key is `sha256(runId|stepId)` truncated to 40 hex — a
* function of identity and never of attempt — so a retry re-posts the SAME id and
* the sidecar replaces rather than stacks. That is the property that makes the
* crier and the news gump safe to retry and the broadcast not.
*
* **The `evt-` prefix is load-bearing for news.** `newsGump.js` posts articles
* under the bare website post id (`String(post.id)`) and `reassertAll()` re-pushes
* that whole set on every sidecar reconnect. An event article numbered into the
* same space would be a collision with a post — silently, and in whichever
* direction wrote last. 4 + 40 characters, inside the sidecar's 64-char cap.
*/
const resourceId = (idempotencyKey) => `evt-${idempotencyKey}`
/** The shard boot this write belongs to, or null when nothing has connected yet. */
async function currentBootId() {
try {
const config = await uoLinkConfig.getSafe()
return config.bootId || null
} catch (err) {
// Never fatal to a world write. A missing stamp means `reconcile()` cannot
// vouch for the row, which leaves core believing its own ledger — the
// pre-Phase-8 behaviour, and the right way to be wrong.
log.warn('could not read the shard boot id for an event resource', { error: err.message })
return null
}
}
// Statuses that will never succeed however many times they are tried: a data
// refusal, a bad token, a switched-off write plane, a protocol mismatch. Named
// here rather than folded into `shardAnnounce.classify` because 403 is reachable
// only from the `/admin/*` verbs — the announce leg posts to the town crier,
// which the admin write plane does not gate — and widening a shared classifier
// for a case its own caller cannot produce is how a shared rule stops being one.
const PERMANENT_STATUSES = new Set([400, 401, 403, 404, 409])
/**
* What the shard actually said, in its own words.
*
* **The sidecar explains its refusals and `legError` drops the explanation**, and
* this was worth its own helper the moment an event started making these calls
* unattended. A `403` body reads `{"reason":"admin write plane disabled"}`;
* `legError` looks for `data.message`, finds nothing, and falls back to "sidecar
* responded 403". For a staff member clicking a button that is survivable — they
* know what they just switched off. For an event that ran at four in the morning,
* the run log is the only place anyone will ever learn why, and "403" is not an
* answer an operator can act on.
*/
function sidecarReason(result, what) {
const data = (result && result.data) || {}
return data.reason || data.message || (result && result.error) || `the shard refused the ${what}`
}
/**
* The sidecar's answer, as an event outcome.
*
* **The announce leg's classification, not a second opinion.** `shardAnnounce`
* already decides what each status from this transport means — 400 a data
* problem, 401/409 a config problem, everything else transient — and it decides
* it about the same sidecar over the same client. Two copies of that judgement is
* how the two drift, which is the argument `core.announce` makes for deferring to
* a leg's own `classify()`.
*/
function sidecarFailure(result, what) {
const { outcome } = classifySidecarWrite(result)
const permanent = PERMANENT_STATUSES.has(result && result.status)
return {
ok: false,
retry: outcome === 'retry' && !permanent,
error: sidecarReason(result, what),
}
}
/** Split an authored text block into crier lines, and say why it is not one. */
function crierLines(raw) {
const lines = String(raw == null ? '' : raw)
.split(/\r?\n/)
.map((l) => l.trim())
.filter(Boolean)
if (!lines.length) return { ok: false, error: 'the message is empty' }
if (lines.length > MAX_CRIER_LINES) {
return { ok: false, error: `the criers carry ${MAX_CRIER_LINES} lines and this is ${lines.length}` }
}
const over = lines.find((l) => l.length > MAX_CRIER_LINE_LEN)
if (over) {
return {
ok: false,
error: `a crier line is capped at ${MAX_CRIER_LINE_LEN} characters and "${over.slice(0, 40)}…" is ${over.length}`,
}
}
return { ok: true, lines }
}
// ── Budgets ────────────────────────────────────────────────────────────────
//
// One dimension, and only `uo.broadcast` spends it (org lead, 2026-09-04). A run
// that broadcasts forty times is the spam failure mode, and a per-run cap is the
// only thing standing between an authoring mistake and every player online. The
// two keyed verbs get none: they are posted under a run-scoped id and a repeat
// REPLACES, so the thing a cap would guard against does not exist for them.
const BUDGETS = [
{
id: 'uo.broadcasts',
label: 'Global broadcasts',
unit: 'broadcasts',
description: 'System messages this run may put in front of everyone online.',
},
]
// ── Participation (protocol 6 part b, EVENTS_PLAN.md Phase 11b) ────────────
//
// EVENTS.md §G rates participation attribution as the largest remaining piece of
// new UO work and says why nothing composed out of the existing streams stands in
// for it: `region.enter` plus `mob.killed` is loosely composable and NOT
// trustworthy enough to publish results on. Nothing scopes a kill or an arrival to
// a run, nothing separates a passer-by from an attendee, and nothing survives a
// relog.
//
// So the shard counts, and reports one opaque number per member. Core stores the
// number and never interprets it, which is what keeps the engine game-agnostic:
// "a minute present plus five a kill" is a sentence about Ultima Online.
//
// **Members are keyed by character serial**, matching this module's Teams
// `memberKey` (`teamProvider.model.js`), so one module speaks one member
// vocabulary and a participant joins to a roster without a translation table.
/** The widest area an event may declare, mirroring the shard's own bound. */
const MAX_AREA_RADIUS = 300
/**
* A shard-reported `webId` as a website user id, or undefined.
*
* The shard writes this only for an account that is actually linked, so most
* characters carry none and `undefined` is the ordinary answer rather than a
* failure. Checked rather than coerced, because core refuses a `userId` that is
* not a positive integer and it is right to: the column is a foreign key into
* `users`, and a non-number that happened to survive a coercion would attribute
* somebody's attendance to a stranger.
*/
function webUserId(webId) {
if (webId === undefined || webId === null || webId === '') return undefined
const n = Number(webId)
return Number.isInteger(n) && n > 0 ? n : undefined
}
/** Resolve a `facet/name` landmark to the point the shard counts around. */
async function landmarkPoint(value) {
const raw = String(value == null ? '' : value)
const cut = raw.indexOf('/')
if (cut < 1) {
return { ok: false, error: `"${raw}" is not a facet/name place` }
}
const facet = raw.slice(0, cut)
const name = raw.slice(cut + 1)
const rows = await shardAtlas.listLandmarks({ facet })
const hit = rows.find((r) => r.facet === facet && r.name === name)
if (!hit) {
return { ok: false, error: `this shard's atlas has no landmark called "${name}" on ${facet}` }
}
return { ok: true, map: hit.facet, x: hit.x, y: hit.y }
}
// ── Actions ────────────────────────────────────────────────────────────────
const ACTIONS = [
{
id: 'uo.broadcast',
label: 'Broadcast to everyone online',
description:
'Puts one system message in front of every player currently logged in. Sent once and never retried — a repeat would be a second announcement, and until the shard can refuse a duplicate there is no way to take one back.',
// Nothing in the world changes and nothing is created; a message goes out.
// Same class as `core.announce`, and for the same reason — which also gives
// it `skip` as its default disposition, so a run does not stop over an
// announcement that did not go out.
risk: 'notify',
// There is no undo, and declaring `ledger` would put a row in the cleanup
// ledger that teardown could never resolve.
reversible: 'none',
version: 1,
budgetMs: BUDGET_MS,
cost: () => ({ 'uo.broadcasts': 1 }),
params: [
{
name: 'text',
type: 'string',
required: true,
example: 'The gates of Britain open at dusk. Gather at the bank.',
description: `The message, up to ${MAX_BROADCAST_LEN} characters.`,
},
{
name: 'hue',
type: 'int',
required: false,
example: 1153,
description: 'UO colour id for the message. Left out, the shard uses its system colour.',
},
],
async perform({ runId, idempotencyKey, params, verify }) {
const text = String(params.text == null ? '' : params.text).trim()
// Checked here rather than left to the sidecar's 400, so the DRY RUN shows
// the author the refusal — which is the whole point of having one.
if (!text) return { ok: false, retry: false, error: 'the message is empty' }
if (text.length > MAX_BROADCAST_LEN) {
return {
ok: false,
retry: false,
error: `a broadcast is capped at ${MAX_BROADCAST_LEN} characters and this is ${text.length}`,
}
}
if (verify) return { ok: true }
// `event:<runId>` (org lead, 2026-09-04). The shard records an actor on
// every staff write and echoes it back as an `admin.audit` event, so this
// is what an operator reads in the game's own audit trail afterwards. No
// staff member pressed a button — attributing it to one would be a false
// record — and the run id is the thing that makes the line actionable.
const result = await uoLinkClient.adminBroadcast({
actor: `event:${runId}`,
text,
hue: params.hue === undefined || params.hue === null ? undefined : Number(params.hue),
// Protocol 6, and the line rule 2 said would change. The key is the
// step's, so every attempt at this step carries the same one and the
// shard refuses the repeat — which is what makes the retry below safe to
// ask for at all.
idempotencyKey,
})
if (result.ok) return { ok: true }
// **A transient failure is now retried**, where wave 1 gave up on it. What
// used to make a retry unsafe was that the shard could not tell a repeat
// from a fresh command; it can now, so a 503 from a shard that is merely
// restarting is recovered from instead of being written off.
//
// The classification itself is `sidecarFailure`'s — the announce leg's own
// judgement about this transport, deferred to rather than second-guessed,
// exactly as the two keyed verbs below already do. That this action now
// uses the SAME helper as its siblings, instead of a hand-rolled variant
// that forced every outcome terminal, is most of the change here.
return sidecarFailure(result, 'broadcast')
},
},
{
id: 'uo.towncrier.post',
label: 'Post to the town criers',
description:
'Puts up to eight lines in the mouths of the town criers for a set time, and takes them down again when the event ends.',
// `notify` is about what a FAILURE costs — nothing is half-changed and the
// run should carry on — while `ledger` is about what SUCCESS leaves behind.
// The two are independent questions and this is the combination where that
// shows: an announcement that can be withdrawn.
risk: 'notify',
reversible: 'ledger',
version: 1,
budgetMs: BUDGET_MS,
params: [
{
// **One text block, not a list, because the param vocabulary has no
// array type** (`VARIABLE_TYPES` is string/int/float/boolean/datetime/url).
// Splitting on newlines is the honest encoding of eight short lines in a
// textarea, and the caps are checked before anything is sent.
name: 'lines',
type: 'string',
required: true,
example: 'Hear ye! The Britain gates open at dusk.\nSeek the herald by the bank.',
description: `One line per newline. Up to ${MAX_CRIER_LINES} lines of ${MAX_CRIER_LINE_LEN} characters.`,
},
{
name: 'durationMinutes',
type: 'int',
required: false,
example: 60,
description: 'How long the criers keep saying it. Left out, the shard keeps it for an hour.',
},
],
async perform({ runId, idempotencyKey, params, verify }) {
const parsed = crierLines(params.lines)
if (!parsed.ok) return { ok: false, retry: false, error: parsed.error }
let durationSec
if (params.durationMinutes !== undefined && params.durationMinutes !== null) {
const minutes = Number(params.durationMinutes)
if (!Number.isFinite(minutes) || minutes <= 0) {
return { ok: false, retry: false, error: `"${params.durationMinutes}" is not a number of minutes` }
}
durationSec = Math.min(Math.round(minutes * 60), MAX_CRIER_DURATION_SEC)
}
if (verify) return { ok: true }
const id = resourceId(idempotencyKey)
const bootId = await currentBootId()
// Protocol 6. This verb was already safe to retry — a repeat under the same
// `id` REPLACES the crier entry rather than stacking a second one — so the
// key buys no new safety here. It is sent because it costs nothing and
// makes the retry a no-op on the shard rather than a redundant world write,
// and because a write plane where only some commands are keyed is one
// somebody will later have to reason about per verb.
const result = await uoLinkClient.postTownCrier({
id,
lines: parsed.lines,
durationSec,
idempotencyKey,
})
if (!result.ok) return sidecarFailure(result, 'town-crier post')
// The stamp rule 3 rests on. `runId` rides along so a row read out of the
// ledger says which run put it up without a join.
return { ok: true, resources: [{ kind: 'towncrier', ref: id, payload: { bootId, runId } }] }
},
async revert({ resources }) {
const failed = []
for (const resource of resources) {
const result = await uoLinkClient.deleteTownCrier(resource.ref)
// §L: "gone, and that is fine" is a successful revert. A crier line whose
// duration simply ran out is a 404, and it is the outcome we wanted.
if (!result.ok && result.status !== 404) failed.push(resource.ref)
}
if (!failed.length) return { ok: true }
return { ok: true, failed }
},
reconcile: reconcileByBootId,
},
{
id: 'uo.news.post',
label: 'Post an article to the news gump',
description:
"Puts an article in the in-game Town Cryer news window for the life of the event, and pulls it when the event ends. Separate from the site's own news posts, which sync there on their own.",
risk: 'notify',
reversible: 'ledger',
version: 1,
budgetMs: BUDGET_MS,
params: [
{
name: 'title',
type: 'string',
required: true,
example: 'The Britannian Midsummer Fair',
description: `The article heading, up to ${MAX_NEWS_TITLE} characters.`,
},
{
name: 'body',
type: 'string',
required: true,
example: 'Merchants from every city gather in Britain for three days of trade and contest.',
description: `The article, up to ${MAX_NEWS_BODY} characters. Plain text; the gump renders a small HTML subset and this is wrapped for it.`,
},
{
name: 'url',
type: 'url',
required: false,
example: 'https://example.com/site/events',
description: "The article's \"more info\" link. Left out, the gump shows no link.",
},
{
name: 'image',
type: 'int',
required: false,
example: 5013,
description: 'A shard art id to illustrate the article. Left out, the sidecar uses a neutral scroll.',
},
{
name: 'announce',
type: 'boolean',
required: false,
example: true,
description: 'Whether the criers proclaim the title when it goes up. Left out, they do.',
},
],
async perform({ runId, idempotencyKey, params, verify }) {
const title = String(params.title == null ? '' : params.title).replace(/\s+/g, ' ').trim()
const body = String(params.body == null ? '' : params.body).trim()
if (!title) return { ok: false, retry: false, error: 'the article has no title' }
if (title.length > MAX_NEWS_TITLE) {
return {
ok: false,
retry: false,
error: `a news title is capped at ${MAX_NEWS_TITLE} characters and this is ${title.length}`,
}
}
if (!body) return { ok: false, retry: false, error: 'the article has no body' }
if (body.length > MAX_NEWS_BODY) {
return {
ok: false,
retry: false,
error: `a news body is capped at ${MAX_NEWS_BODY} characters and this is ${body.length}`,
}
}
if (verify) return { ok: true }
const id = resourceId(idempotencyKey)
const bootId = await currentBootId()
const result = await uoLinkClient.postNews({
id,
title,
// The same gump-HTML shape `newsGump.buildArticle` uses, so an event
// article and a site article read alike in the window they share.
body: `<CENTER>${title}</CENTER><BR><BR>${body}`,
image:
params.image === undefined || params.image === null ? undefined : Number(params.image),
url: params.url || undefined,
announce: params.announce === undefined || params.announce === null ? true : Boolean(params.announce),
// Protocol 6, for the same reason the crier carries one — except that
// here it does buy something. `announce: true` makes the criers proclaim
// the article's title when it is posted, so a re-post under the same id
// replaces the article silently but proclaims it AGAIN. The key stops the
// second proclamation, which was the one part of this verb that was never
// as idempotent as its `id` made it look.
idempotencyKey,
})
if (!result.ok) return sidecarFailure(result, 'news article')
return { ok: true, resources: [{ kind: 'news', ref: id, payload: { bootId, runId } }] }
},
async revert({ resources }) {
const failed = []
for (const resource of resources) {
const result = await uoLinkClient.deleteNews(resource.ref)
if (!result.ok && result.status !== 404) failed.push(resource.ref)
}
if (!failed.length) return { ok: true }
return { ok: true, failed }
},
reconcile: reconcileByBootId,
},
{
id: 'uo.participation.open',
label: 'Start counting who takes part',
description:
'Declares where this event happens and starts crediting the players who are there. Presence plus kill credit inside the area, counted on the shard and kept in its world save, so a restart mid-event does not lose the tally.',
// **`inspect`, not `change`.** Nothing in the world moves and no player can
// see it: the shard starts keeping a tally about a place. §K puts the
// default-off line between `inspect` and `change`, and a step that only
// watches is not one an operator should have to switch on before an event can
// record who came.
risk: 'inspect',
// Ledgered anyway, because the shard IS holding something on this run's
// behalf — one of a bounded number of counting slots — and teardown has to
// give it back. Reversibility is about what a run owes, not about how loud it
// was in taking it.
reversible: 'ledger',
version: 1,
budgetMs: BUDGET_MS,
params: [
{
name: 'place',
type: 'string',
required: true,
example: 'Felucca/Britain',
source: 'uo.options.landmarks',
description: 'Where the event happens. The tally counts a circle around this point.',
},
{
name: 'radius',
type: 'int',
required: true,
example: 40,
description: `How many tiles around it count as being there, up to ${MAX_AREA_RADIUS}.`,
},
{
name: 'durationMinutes',
type: 'int',
required: false,
example: 240,
description:
'How long to keep counting if nothing closes it. Left out, the shard counts until teardown.',
},
],
async perform({ runId, idempotencyKey, params, verify }) {
const radius = Number(params.radius)
if (!Number.isInteger(radius) || radius < 1 || radius > MAX_AREA_RADIUS) {
return {
ok: false,
retry: false,
error: `an area is 1 to ${MAX_AREA_RADIUS} tiles, and "${params.radius}" is not`,
}
}
const point = await landmarkPoint(params.place)
if (!point.ok) return { ok: false, retry: false, error: point.error }
let holdMs
if (params.durationMinutes !== undefined && params.durationMinutes !== null) {
const minutes = Number(params.durationMinutes)
if (!Number.isFinite(minutes) || minutes <= 0) {
return { ok: false, retry: false, error: `"${params.durationMinutes}" is not a number of minutes` }
}
holdMs = Math.round(minutes * 60_000)
}
if (verify) return { ok: true }
const result = await uoLinkClient.openParticipation({
runId,
map: point.map,
x: point.x,
y: point.y,
radius,
holdMs,
idempotencyKey,
})
if (!result.ok) return sidecarFailure(result, 'participation open')
// **No `bootId` stamp, and that is the point of the phase.** Every other
// resource in this file is stamped with the shard boot that made it, because
// a town-crier line and a news article live in shard memory and a restart is
// definitionally the loss of both. A participation ledger is the first thing
// this bridge PERSISTS: it is in the world save, so it survives the restart
// that would have proved the others gone. Reconcile has to ask.
return {
ok: true,
resources: [{ kind: 'participation', ref: String(runId), payload: { runId, place: params.place, radius } }],
}
},
async revert({ resources }) {
const failed = []
for (const resource of resources) {
const result = await uoLinkClient.closeParticipation({ runId: resource.ref })
// §L: "gone, and that is fine" is a successful revert. A run the shard has
// already forgotten answers `known: false` with a 200 for exactly this.
if (!result.ok && result.status !== 404) failed.push(resource.ref)
}
if (!failed.length) return { ok: true }
return { ok: true, failed }
},
/**
* **Not `reconcileByBootId`, and this is the one resource for which that is
* true.** The boot-stamp trick 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, and reporting it lost on a boot change would orphan the
* one resource the phase went to the trouble of persisting.
*
* So it asks. A 404 is the shard saying it is not counting that run; anything
* else unanswerable leaves the row alone.
*/
async reconcile({ resources }) {
const inForce = []
for (const resource of resources) {
const result = await uoLinkClient.snapshotParticipation({ runId: resource.ref })
if (result.ok) {
inForce.push(resource.ref)
continue
}
// Only an explicit "I am not counting that" takes a row out. A shard that
// is down, slow or refusing has not said the ledger is gone.
if (result.status !== 404) inForce.push(resource.ref)
}
return { ok: true, inForce }
},
},
{
id: 'uo.participation.collect',
label: 'Record who took part',
description:
"Reads the shard's tally for this run and files it as the run's participants, so results and player history have something true to render.",
risk: 'inspect',
// Nothing is created and nothing is owed. The rows it writes are core's
// `event_run_participants`, whose `UNIQUE (run_id, member_key)` makes a
// retried collect an upsert rather than a doubled leaderboard.
reversible: 'none',
version: 1,
budgetMs: BUDGET_MS,
params: [],
async perform({ runId, idempotencyKey, verify }) {
if (verify) return { ok: true }
const result = await uoLinkClient.snapshotParticipation({ runId, idempotencyKey })
if (!result.ok) {
// 425 is `bridge.busy`: a snapshot of this run is already walking on the
// shard. Transient by construction — the work is happening — and it is not
// in `PERMANENT_STATUSES`, so `sidecarFailure` classifies it retry without
// needing an arm of its own.
return sidecarFailure(result, 'participation tally')
}
const rows = (result.data && result.data.participants) || []
return {
ok: true,
participants: rows.map((row) => ({
// The serial, which is this module's member vocabulary everywhere.
memberKey: row.serial,
// **Resolved here, and only when the shard could resolve it.** A
// `userId` is a foreign key into `users`, and core refuses anything that
// is not a positive integer rather than coercing — a character serial
// passed here would either fail the insert or, worse, attribute
// somebody's attendance to a stranger who happened to hold that id.
userId: webUserId(row.webId),
score: row.score,
joinedAt: row.firstMs ? new Date(row.firstMs) : undefined,
// Opaque to core, and carried so a results table can say WHY somebody
// scored what they did. A number an operator can only believe or not is
// a number they will not defend when a player argues with it.
meta: {
name: row.name || null,
seconds: row.seconds,
minutes: row.minutes,
kills: row.kills,
},
})),
}
},
},
]
/**
* Which of these does the shard still have? — answered from the boot stamp.
*
* Shared by both keyed verbs because the answer has the same shape for both:
* shard memory, lost on restart. See rule 3 in the header for why this needs no
* round trip and why it must not simply answer "all gone".
*
* **A row with no stamp is reported IN FORCE.** It was written by a build before
* the stamp existed, or by a `perform()` whose config read hiccuped, and "I do not
* know" must never be read as "it is gone" — core orphans exactly what this omits,
* and an orphaned row is one teardown will never try to take back.
*/
async function reconcileByBootId({ resources }) {
const bootId = await currentBootId()
// Nothing has connected since this process came up, so there is no current boot
// to compare against. Declining to answer leaves core believing its ledger.
if (!bootId) return { ok: false, error: 'the shard has not identified itself since boot' }
const inForce = resources
.filter((r) => {
const stamped = r.payload && r.payload.bootId
return !stamped || stamped === bootId
})
.map((r) => r.ref)
return { ok: true, inForce }
}
// ── Leases (protocol 6 part b, EVENTS_PLAN.md Phase 11b) ───────────────────
//
// **One key, and the catalog is short because ServUO made it short.** EVENTS.md
// §D describes the 258 `Config.Get` call sites as splitting into two patterns —
// cached at type initialisation, where a lease applies cleanly and does nothing,
// and read live, where it takes effect at once. Measured on 57.4 the split is not
// near even: of the 158 non-Bridge sites in `Scripts/`, roughly eight are live
// reads. Phase 11b ships the one that is both live and observable, and Phase 12
// adds the rest behind the boot-time self-check that drops a key which does not
// take.
//
// The module never writes a lease and never bounds one. An author puts
// `core.lease` in a step; core reads the baseline, reserves the target against
// the two-events-one-target index, applies the value with its deadline and
// restores it at teardown through `restore()` below. What is here is the three
// callables, plus the fourth this phase added.
/** How long core will let this deployment hold a config lease. Twelve hours. */
const MAX_LEASE_MS = 12 * 60 * 60 * 1000
/** The shard's lease list, or null when it could not be read. */
async function leaseRow(key) {
const result = await uoLinkClient.getLeases()
if (!result.ok) return null
const rows = (result.data && result.data.leases) || []
return rows.find((r) => r && r.key === key) || null
}
const LEASES = [
{
id: 'uo.playercaps.skillcap',
label: 'Starting skill cap',
description:
"The per-skill cap a newly created character starts with. Read live at character creation, so it applies to everyone made while the lease is held and to nobody made before it.",
type: 'float',
// The shard enforces the same bounds independently, and that duplication is
// deliberate: this pair is what core checks at AUTHORING time so a bad value
// is a refusal on a form, and the shard's pair is what is true when the
// website is wrong.
min: 1000,
max: 1500,
maxDurationMs: MAX_LEASE_MS,
async read() {
const row = await leaseRow('PlayerCaps.SkillCap')
if (!row) return { ok: false, error: 'the shard did not report its lease catalog' }
return { ok: true, value: row.current }
},
async apply(value, until) {
// **A duration, not the deadline.** `until` is an absolute time computed
// here and honoured there, which is a deadline measured against two clocks;
// a shard running ten minutes fast would restore a ten-minute lease the
// instant it took it. The absolute time still rides along, because a
// console that can say when the hold ends is worth the extra field.
const holdMs = new Date(until).getTime() - Date.now()
if (!Number.isFinite(holdMs) || holdMs <= 0) {
return { ok: false, error: 'the lease deadline has already passed' }
}
const result = await uoLinkClient.applyLease({
key: 'PlayerCaps.SkillCap',
value,
holdMs: Math.round(holdMs),
untilMs: new Date(until).getTime(),
})
if (!result.ok) return { ok: false, error: sidecarReason(result, 'lease') }
return { ok: true }
},
async restore(baseline, { expected } = {}) {
const result = await uoLinkClient.releaseLease({
key: 'PlayerCaps.SkillCap',
expected,
baseline,
})
// **Drift is a 200 carrying `lease.drifted`, not an HTTP failure**, because
// the shard did exactly what it was asked: it compared, and it declined to
// overwrite somebody's deliberate change. Core records that as a distinct
// successful outcome rather than an error, so the shape it wants back is
// `{ ok: false, drifted: true, current }` and not a thrown call.
if (result.ok && result.data && result.data.kind === 'lease.drifted') {
return { ok: false, drifted: true, current: result.data.current }
}
if (!result.ok) return { ok: false, error: sidecarReason(result, 'lease release') }
return { ok: true }
},
/**
* Whether the shard still has a record of the hold (Phase 11b).
*
* **Not a comparison against `read()`**, and the difference is the whole
* reason this callable exists. A value that differs from what the run applied
* is DRIFT, which `restore()` above reports 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.
*
* A config lease is memory-only on the shard, so a restart reverts it and the
* catalog reports `held: false` — which is exactly the case core could not see
* before this phase, and the reason a restarted shard used to leave a run
* hunting a baseline nobody was holding.
*/
async inForce() {
const row = await leaseRow('PlayerCaps.SkillCap')
if (!row) return { ok: false, error: 'the shard did not report its lease catalog' }
return { ok: true, held: row.held === true }
},
},
]
// ── Option sources ─────────────────────────────────────────────────────────
//
// Answered from the spawn atlas, which is derived from the operator's own ServUO
// tree on every boot and stored — so these resolve with the shard down, which is
// the property that makes them safe to put behind an authoring form.
//
// **Wave 1's three verbs use none of them.** They ship here rather than with
// their consumers in Phase 12 (org lead, 2026-09-04) because they cost nothing
// new, and Phase 12 is a five-repo protocol bump that should not also be carrying
// its first atlas plumbing. `/admin/events/catalog/options/:sourceId` exercises
// them today.
//
// **A place is named `facet/name`, not `name`.** Two facets both have a Britain,
// and a value that can name two different places is a value a Phase 12 step
// cannot act on. The author reads the label and the group; the stored value is
// unambiguous.
/** Bound one source's answer, and say so when the atlas is bigger than the bound. */
function bounded(rows, sourceId) {
if (rows.length <= MAX_OPTIONS) return rows
log.warn('option source truncated — the atlas is larger than the dropdown bound', {
source: sourceId,
available: rows.length,
served: MAX_OPTIONS,
})
return rows.slice(0, MAX_OPTIONS)
}
const OPTION_SOURCES = [
{
id: 'uo.options.regions',
label: 'Regions',
description: "Named regions from the shard's own map definitions, by facet.",
async resolve() {
const rows = await shardAtlas.listRegions()
return bounded(rows, 'uo.options.regions').map((r) => ({
value: `${r.facet}/${r.name}`,
label: r.name,
group: r.facet,
}))
},
},
{
id: 'uo.options.landmarks',
label: 'Landmarks',
description: 'Named points of interest — towns, dungeons, moongates — by facet.',
async resolve() {
const rows = await shardAtlas.listLandmarks()
return bounded(rows, 'uo.options.landmarks').map((r) => ({
value: `${r.facet}/${r.name}`,
label: r.name,
// The atlas's own grouping where it has one, the facet otherwise — so a
// shard whose landmark file carries no groups still gets a usable
// dropdown rather than one flat list of several hundred names.
group: r.group || r.facet,
}))
},
},
{
id: 'uo.options.creatures',
label: 'Creatures',
description: 'Creature types the shard actually spawns, from the spawn atlas.',
async resolve() {
// The slug is unique by construction, so unlike a place a creature needs no
// qualifier: it is the same type wherever it spawns.
const { creatures } = await shardAtlas.searchCreatures({ limit: MAX_OPTIONS })
return creatures.map((c) => ({ value: c.slug, label: c.name }))
},
},
]
module.exports = {
ACTIONS,
BUDGETS,
LEASES,
OPTION_SOURCES,
// Exported for the tests, which assert the caps and the classification rules
// against the same constants the declarations use rather than against literals
// that could drift from them.
BUDGET_MS,
MAX_BROADCAST_LEN,
MAX_CRIER_LINES,
MAX_CRIER_LINE_LEN,
MAX_NEWS_TITLE,
MAX_NEWS_BODY,
MAX_OPTIONS,
MAX_AREA_RADIUS,
MAX_LEASE_MS,
PERMANENT_STATUSES,
webUserId,
landmarkPoint,
sidecarReason,
resourceId,
crierLines,
reconcileByBootId,
}

View File

@@ -93,6 +93,28 @@ module.exports = {
}, },
auth: { getUserFromRequest: (...args) => need().auth.getUserFromRequest(...args) }, auth: { getUserFromRequest: (...args) => need().auth.getUserFromRequest(...args) },
push: { publish: (...args) => need().push.publish(...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: { secretBox: {
encrypt: (...args) => need().secretBox.encrypt(...args), encrypt: (...args) => need().secretBox.encrypt(...args),
decrypt: (...args) => need().secretBox.decrypt(...args), decrypt: (...args) => need().secretBox.decrypt(...args),

View File

@@ -47,7 +47,7 @@ CREATE TABLE IF NOT EXISTS uo_link_config (
base_url VARCHAR(255) NULL, base_url VARCHAR(255) NULL,
ws_url VARCHAR(255) NULL, ws_url VARCHAR(255) NULL,
auth_token_enc TEXT NULL, auth_token_enc TEXT NULL,
protocol INT NOT NULL DEFAULT 4, protocol INT NOT NULL DEFAULT 5,
enabled TINYINT(1) NOT NULL DEFAULT 0, enabled TINYINT(1) NOT NULL DEFAULT 0,
status VARCHAR(20) NOT NULL DEFAULT 'disconnected', status VARCHAR(20) NOT NULL DEFAULT 'disconnected',
status_detail VARCHAR(500) NULL, status_detail VARCHAR(500) NULL,
@@ -727,3 +727,62 @@ 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_cliloc INT NULL;
ALTER TABLE shard_guild_members ADD COLUMN IF NOT EXISTS rank_name VARCHAR(64) 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`); 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');

View File

@@ -44,6 +44,10 @@ module.exports = function register(ctx, api) {
const usersShardExtension = require('./router/admin/usersShard.router') const usersShardExtension = require('./router/admin/usersShard.router')
const shardStreams = require('./config/shardStreams') 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 townCrierLeg = require('./utils/shardAnnounce')
const teamProvider = require('./model/teamProvider/teamProvider.model') const teamProvider = require('./model/teamProvider/teamProvider.model')
const guildCommand = require('./commands/guild.command') const guildCommand = require('./commands/guild.command')
@@ -88,6 +92,53 @@ module.exports = function register(ctx, api) {
api.registerNotificationStreams(shardStreams.STREAMS) api.registerNotificationStreams(shardStreams.STREAMS)
api.registerAnnounceLeg(townCrierLeg.leg) 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 // 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; // them for this deployment (MODULE_API 1.6.0). Core asks the three questions;
// everything about what a guild IS stays here. // everything about what a guild IS stays here.
@@ -107,6 +158,29 @@ module.exports = function register(ctx, api) {
// either. // either.
api.registerSlashCommands([guildCommand]) 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.onBoot(boot.onBoot)
api.onShutdown(boot.onShutdown) api.onShutdown(boot.onShutdown)
@@ -114,5 +188,8 @@ module.exports = function register(ctx, api) {
version: require('../module.json').version, version: require('../module.json').version,
routes: 'public:/shard,/atlas admin:/shard,/uo-link player:/shard', routes: 'public:/shard,/atlas admin:/shard,/uo-link player:/shard',
streams: shardStreams.STREAMS.length, streams: shardStreams.STREAMS.length,
triggers: shardTriggers.TRIGGERS.length,
audiences: shardAudiences.AUDIENCES.length,
eventActions: uoEventActions.ACTIONS.length,
}) })
} }

View File

@@ -39,4 +39,56 @@ const remove = (account, userId) =>
const removeByAccount = (account) => const removeByAccount = (account) =>
query('DELETE FROM shard_account_links WHERE account = ?', [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). // Drop the local mirror for an account (source-of-truth severed elsewhere).
const removeByAccount = (account) => db.removeByAccount(account) 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( await conn.query(
`INSERT INTO shard_vendors `INSERT INTO shard_vendors
(serial, shop_name, owner_serial, owner_name, map, x, y, z, region, house, (serial, shop_name, owner_serial, owner_name, owner_acct, map, x, y, z, region, house,
item_count, item_total, truncated, t) item_count, item_total, truncated, t,
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) 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), 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), z = VALUES(z), region = VALUES(region), house = VALUES(house),
item_count = VALUES(item_count), item_total = VALUES(item_total), item_count = VALUES(item_count), item_total = VALUES(item_total),
truncated = VALUES(truncated), t = VALUES(t), 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: -- Touched explicitly rather than left to ON UPDATE CURRENT_TIMESTAMP:
-- MariaDB does not fire that when every column is written back -- MariaDB does not fire that when every column is written back
-- unchanged, and a shop that is re-published identically is still -- unchanged, and a shop that is re-published identically is still
@@ -60,6 +71,7 @@ async function replaceVendor(vendor, items) {
vendor.shopName ?? null, vendor.shopName ?? null,
vendor.ownerSerial ?? null, vendor.ownerSerial ?? null,
vendor.ownerName ?? null, vendor.ownerName ?? null,
vendor.ownerAcct ?? null,
vendor.map ?? null, vendor.map ?? null,
Number.isFinite(vendor.x) ? vendor.x : null, Number.isFinite(vendor.x) ? vendor.x : null,
Number.isFinite(vendor.y) ? vendor.y : 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, Number.isFinite(vendor.itemTotal) ? vendor.itemTotal : items.length,
vendor.truncated ? 1 : 0, vendor.truncated ? 1 : 0,
Number.isFinite(vendor.t) ? vendor.t : null, 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_MAP = 40
const MAX_REGION = 80 const MAX_REGION = 80
const MAX_SERIAL = 20 const MAX_SERIAL = 20
const MAX_ACCT = 120
const clip = (value, max) => { const clip = (value, max) => {
if (value == null) return null if (value == null) return null
@@ -43,6 +44,42 @@ const int = (value, fallback = 0) => {
return Number.isFinite(n) ? Math.trunc(n) : fallback 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 ───────────────────────────────────────────────────────────────── // ── Ingest ─────────────────────────────────────────────────────────────────
/** /**
@@ -64,6 +101,10 @@ function flattenFrame(ev) {
shopName: clip(ev.shopName, MAX_SHOP), shopName: clip(ev.shopName, MAX_SHOP),
ownerSerial: clip(ev.ownerSerial, MAX_SERIAL), ownerSerial: clip(ev.ownerSerial, MAX_SERIAL),
ownerName: clip(ev.ownerName, MAX_OWNER), 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), map: clip(loc.map, MAX_MAP),
x: Number.isFinite(loc.x) ? Math.trunc(loc.x) : null, x: Number.isFinite(loc.x) ? Math.trunc(loc.x) : null,
y: Number.isFinite(loc.y) ? Math.trunc(loc.y) : 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)), itemTotal: int(ev.total, int(ev.count, 0)),
truncated: ev.truncated === true, truncated: ev.truncated === true,
t: Number.isFinite(ev.t) ? ev.t : null, t: Number.isFinite(ev.t) ? ev.t : null,
...fees(ev.fees),
} }
} }

View File

@@ -98,7 +98,12 @@ async function latestEconomy() {
// ── Houses / IDOC ──────────────────────────────────────────────────────── // ── Houses / IDOC ────────────────────────────────────────────────────────
const HOUSE_COLS = 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) const upsertHouse = (serial, fields) => upsertRow('shard_houses', 'serial', serial, fields)
@@ -215,6 +220,26 @@ const listGuildMembers = (guildId) =>
guildId, 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 // 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 // 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. // is not modelled (the board carries only counts + leader), so we don't guess it.
@@ -391,6 +416,8 @@ module.exports = {
removeGuildMember, removeGuildMember,
clearAllGuildMembers, clearAllGuildMembers,
listGuildMembers, listGuildMembers,
listGuildMemberAccounts,
listGovernorAccounts,
findGuildLedByActor, findGuildLedByActor,
listGuildsLedByAccounts, listGuildsLedByAccounts,
upsertGovernor, upsertGovernor,

View File

@@ -124,10 +124,39 @@ async function upsertHouse(data) {
built_on: data.builtOn ? new Date(data.builtOn) : null, built_on: data.builtOn ? new Date(data.builtOn) : null,
last_refreshed: data.lastRefreshed ? new Date(data.lastRefreshed) : null, last_refreshed: data.lastRefreshed ? new Date(data.lastRefreshed) : null,
is_idoc: String(data.stage).toUpperCase() === 'IDOC' ? 1 : 0, 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) 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) { function shapeHouse(r) {
return { return {
serial: r.serial, serial: r.serial,
@@ -149,6 +178,16 @@ function shapeHouse(r) {
inRegistry: r.in_registry == null ? undefined : Boolean(r.in_registry), inRegistry: r.in_registry == null ? undefined : Boolean(r.in_registry),
builtOn: r.built_on, builtOn: r.built_on,
lastRefreshed: r.last_refreshed, 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), isIdoc: Boolean(r.is_idoc),
updatedAt: r.updated_at, updatedAt: r.updated_at,
} }
@@ -423,6 +462,19 @@ async function listGuildMembers(guildId) {
})) }))
} }
// **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) { function shapeGuild(r) {
const payload = typeof r.payload === 'string' ? safeJson(r.payload) : r.payload const payload = typeof r.payload === 'string' ? safeJson(r.payload) : r.payload
return payload || { return payload || {
@@ -703,6 +755,8 @@ module.exports = {
upsertGuildRoster, upsertGuildRoster,
removeGuildMember, removeGuildMember,
listGuildMembers, listGuildMembers,
listGuildMemberAccounts,
listGovernorAccounts,
replaceGuilds, replaceGuilds,
findGuildForActor, findGuildForActor,
listGuildsLedForAccounts, listGuildsLedForAccounts,

View File

@@ -11,13 +11,16 @@ const { secretBox } = require('../../core')
// Only used before an admin has saved anything — the stored row wins once it exists, // 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. // and UOLINK_PROTOCOL still overrides for an operator running an older sidecar.
// //
// This says 4 because this build handles protocol 4's frames: `guild.roster` and // This says 5 because this build handles protocol 5's frames: house.decay's `schedule`,
// `guild.leave` ingest landed with the Teams cutover. It said 3 for a while after // vendor.listing's `ownerAcct` + `fees`, and the new `account.login.result` kind.
// that, which is the bug this constant is now the fix for — a FRESH install pinned //
// 3, the sidecar answered `409 protocol version mismatch` to every REST call, and a // It said 4 before that, and 3 for a while after protocol 4 shipped — which is the bug
// new deployment read nothing from its shard until an admin edited the number by // this constant is now the fix for. A FRESH install pinned 3, the sidecar answered
// hand in Admin → Shard. See the matching cutover in db/schema.sql. // `409 protocol version mismatch` to every REST call, and a new deployment read nothing
const DEFAULT_PROTOCOL = Number(process.env.UOLINK_PROTOCOL) || 4 // from its shard until an admin edited the number by hand in Admin → Shard. Bumping it
// in the SAME change as the emitters is the discipline that prevents a repeat; see the
// matching cutover in db/schema.sql.
const DEFAULT_PROTOCOL = Number(process.env.UOLINK_PROTOCOL) || 5
function toSafe(row) { function toSafe(row) {
if (!row) { if (!row) {
@@ -92,4 +95,7 @@ async function recordStatus({ status, statusDetail, pluginConnected, lastEventAt
return toSafe(row) 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

@@ -47,6 +47,11 @@ function fakeCtx(overrides = {}) {
settings: { get: spy(Promise.resolve(null)), set: spy(Promise.resolve()), getInstanceName: spy(Promise.resolve('Test')) }, settings: { get: spy(Promise.resolve(null)), set: spy(Promise.resolve()), getInstanceName: spy(Promise.resolve('Test')) },
auth: { getUserFromRequest: spy(null) }, auth: { getUserFromRequest: spy(null) },
push: { publish: spy(Promise.resolve()) }, 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') }, secretBox: { encrypt: spy('enc'), decrypt: spy('dec') },
middleware: { middleware: {
requireAuth: (req, res, next) => next(), requireAuth: (req, res, next) => next(),
@@ -97,6 +102,11 @@ function fakeApi() {
legs: [], legs: [],
teamProvider: null, teamProvider: null,
slashCommands: [], slashCommands: [],
triggers: null,
audiences: null,
eventActions: null,
eventBudgets: null,
eventOptionSources: null,
hooks: {}, hooks: {},
} }
const called = new Set() const called = new Set()
@@ -117,6 +127,26 @@ function fakeApi() {
// takes it: a second call is a module changing its mind halfway through // takes it: a second call is a module changing its mind halfway through
// register(), which core rejects. // register(), which core rejects.
registerSlashCommands(commands) { once('registerSlashCommands'); record.slashCommands = commands }, 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 }, onBoot(fn) { once('onBoot'); record.hooks.onBoot = fn },
onShutdown(fn) { once('onShutdown'); record.hooks.onShutdown = fn }, onShutdown(fn) { once('onShutdown'); record.hooks.onShutdown = fn },
} }

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,31 @@ test('registers exactly what module.json declares', () => {
assert.deepStrictEqual(api.record.extensions.map((e) => e.slot), manifest.extensions) assert.deepStrictEqual(api.record.extensions.map((e) => e.slot), manifest.extensions)
assert.deepStrictEqual(api.record.legs.map((l) => l.leg), ['towncrier']) 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.broadcast',
'uo.news.post',
'uo.participation.collect',
'uo.participation.open',
'uo.towncrier.post',
],
)
assert.deepStrictEqual(api.record.eventBudgets.map((b) => b.id), ['uo.broadcasts'])
// 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.
assert.deepStrictEqual(api.record.eventLeases.map((l) => l.id), ['uo.playercaps.skillcap'])
assert.deepStrictEqual(
api.record.eventOptionSources.map((s) => s.id).sort(),
['uo.options.creatures', 'uo.options.landmarks', 'uo.options.regions'],
)
assert.ok(api.record.streams.length > 0) assert.ok(api.record.streams.length > 0)
assert.strictEqual(typeof api.record.hooks.onBoot, 'function') assert.strictEqual(typeof api.record.hooks.onBoot, 'function')
assert.strictEqual(typeof api.record.hooks.onShutdown, 'function') assert.strictEqual(typeof api.record.hooks.onShutdown, 'function')

View File

@@ -144,12 +144,23 @@ test('both settings seeds are INSERT IGNORE, so a replay never resets a value',
// sidecar, which 409s every REST call — an install that reads nothing from its // 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. // 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', () => { 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)) const create = statements.find((s) => /CREATE TABLE.*uo_link_config/is.test(s))
assert.ok(create, 'uo_link_config is gone') assert.ok(create, 'uo_link_config is gone')
assert.match( assert.match(
create, create,
/protocol\s+INT\s+NOT NULL DEFAULT 4/i, new RegExp('protocol +INT +NOT NULL DEFAULT ' + DEFAULT_PROTOCOL + '(?![0-9])', 'i'),
'the CREATE TABLE default must name the protocol this build speaks', 'the CREATE TABLE default must name the protocol this build speaks',
) )
@@ -159,7 +170,34 @@ test('the column default pins the protocol this build speaks', () => {
/^ALTER TABLE\s+uo_link_config\s+MODIFY COLUMN protocol/i.test(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.ok(modifies.length > 0, 'the default-fixing MODIFY is gone')
assert.match(modifies[modifies.length - 1], /DEFAULT 4/i) 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', () => { test('the protocol-4 marker is written AFTER the update that reads it', () => {

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

@@ -261,3 +261,91 @@ test('the cliloc resolver is the path shapeItems resolves through', async () =>
const found = await clilocs.resolveMany([1023721]) const found = await clilocs.resolveMany([1023721])
assert.equal(found.get(1023721), 'quarter staff') 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(typeof out[0].startedAt, 'number')
assert.equal(out[0].endedAt, null) // an open term stays null, not coerced to 0 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) 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 ────────────────────────────────────────────────── // ── Rule 1: locked fields ──────────────────────────────────────────────────
test('acct and webId are stripped below admin regardless of feature config', () => { test('acct and webId are stripped below admin regardless of feature config', () => {
@@ -360,10 +412,21 @@ const V3_ADDED_PUBLIC_KINDS = ['world.ruleset', 'points.board']
// inside the roster's member array (see the roster test above). // inside the roster's member array (see the roster test above).
const V4_ADDED_PUBLIC_KINDS = ['guild.roster', 'guild.leave'] const V4_ADDED_PUBLIC_KINDS = ['guild.roster', 'guild.leave']
test('derived PUBLIC_KINDS is exactly the pre-v3 allowlist plus the v3 and v4 additions', () => { // 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( assert.deepEqual(
[...visibility.PUBLIC_KINDS].sort(), [...visibility.PUBLIC_KINDS].sort(),
[...PRE_V3_PUBLIC_KINDS, ...V3_ADDED_PUBLIC_KINDS, ...V4_ADDED_PUBLIC_KINDS].sort(), [
...PRE_V3_PUBLIC_KINDS,
...V3_ADDED_PUBLIC_KINDS,
...V4_ADDED_PUBLIC_KINDS,
...V6_ADDED_PUBLIC_KINDS,
].sort(),
) )
}) })
@@ -476,3 +539,109 @@ test('a link lookup failure downgrades rather than escalating', async () => {
visibility.forgetUser(6) visibility.forgetUser(6)
assert.equal(await visibility.viewerLevel({ user: { id: 6, role: 'player' } }), 'logged_in') 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

@@ -0,0 +1,477 @@
// 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: [] }
for (const name of ['adminBroadcast', 'postTownCrier', 'deleteTownCrier', 'postNews', 'deleteNews']) {
saved[name] = uoLinkClient[name]
}
saved.getSafe = uoLinkConfig.getSafe
saved.listRegions = shardAtlas.listRegions
saved.listLandmarks = shardAtlas.listLandmarks
saved.searchCreatures = shardAtlas.searchCreatures
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 } }
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.
shardAtlas.listLandmarks = async () => [{ facet: 'Felucca', name: 'Britain', x: 1496, y: 1628, z: 10 }]
})
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
})
// ── 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('a broadcast spends the one budget dimension the module declares', () => {
const declared = new Set(actions.BUDGETS.map((b) => b.id))
assert.deepEqual([...declared], ['uo.broadcasts'])
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)
})
// ── 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 needs no qualifier — the slug is the same type wherever it spawns', async () => {
shardAtlas.searchCreatures = async ({ limit }) => {
assert.equal(limit, actions.MAX_OPTIONS, 'the source must bound what it asks the atlas for')
return { creatures: [{ slug: 'orc-brute', name: 'Orc Brute' }] }
}
assert.deepEqual(await source('uo.options.creatures').resolve(), [
{ value: 'orc-brute', label: 'Orc Brute' },
])
})
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')
})

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 shardLinksModel = require('../model/shardLinks/shardLinks.model')
const shardMarketModel = require('../model/shardMarket/shardMarket.model') const shardMarketModel = require('../model/shardMarket/shardMarket.model')
const uoLinkConfigModel = require('../model/uoLinkConfig/uoLinkConfig.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 broadcaster = require('./shardBroadcast')
const shardPush = require('./shardPush') const shardPush = require('./shardPush')
const shardEngagement = require('./shardEngagement')
const defaultLog = require('../core').logger('shard-ingest') const defaultLog = require('../core').logger('shard-ingest')
// Notable kinds appended to the shard_events log. High-frequency/session kinds // Notable kinds appended to the shard_events log. High-frequency/session kinds
@@ -62,6 +63,11 @@ const LOGGED_KINDS = new Set([
const state = { bootId: null } const state = { bootId: null }
function reset() { function reset() {
state.bootId = null 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? // Should this event be written to the append-only log?
@@ -97,11 +103,12 @@ async function resolveShardName(shard, deps) {
// Apply the state-change side effect for a kind (if any). Returns a promise. // Apply the state-change side effect for a kind (if any). Returns a promise.
async function applyStateChange(event, deps) { async function applyStateChange(event, deps) {
const { shardState, uoLinkConfig, log } = deps const { shardState, uoLinkConfig, eventsReconcile, fromBackfill, log } = deps
switch (event.kind) { switch (event.kind) {
case 'server.hello': { case 'server.hello': {
const incoming = event.bootId || null 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', { log.warn('shard restarted (bootId changed) — clearing online roster', {
from: state.bootId, from: state.bootId,
to: incoming, to: incoming,
@@ -110,6 +117,31 @@ async function applyStateChange(event, deps) {
} }
if (incoming) state.bootId = incoming if (incoming) state.bootId = incoming
await uoLinkConfig.recordStatus({ pluginConnected: true, bootId: incoming, lastEventAt: event.t }) 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 return
} }
case 'server.shutdown': case 'server.shutdown':
@@ -169,8 +201,14 @@ async function applyStateChange(event, deps) {
name: event.name, name: event.name,
ownerSerial: event.ownerSerial, ownerSerial: event.ownerSerial,
ownerAcct: event.ownerAcct, 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, builtOn: event.builtOn,
lastRefreshed: event.lastRefreshed, lastRefreshed: event.lastRefreshed,
schedule: event.schedule,
}) })
return return
case 'champ.update': case 'champ.update':
@@ -279,6 +317,16 @@ function resolveDeps(deps) {
settings: deps.settings || settingsModel, settings: deps.settings || settingsModel,
broadcast: deps.broadcast || broadcaster.broadcast, broadcast: deps.broadcast || broadcaster.broadcast,
pushDispatch: deps.pushDispatch || shardPush.fromShardEvent, 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, log: deps.log || defaultLog,
} }
} }
@@ -294,6 +342,34 @@ async function ingest(event, deps = {}) {
let stored = false let stored = false
let logged = 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 { try {
await applyStateChange(event, d) await applyStateChange(event, d)
} catch (err) { } catch (err) {

View File

@@ -83,7 +83,27 @@ const FEATURES = {
// ── Shipped before v3. Defaults reproduce the previous hardcoded behavior. ── // ── Shipped before v3. Defaults reproduce the previous hardcoded behavior. ──
status: { audience: 'anonymous', fields: {} }, status: { audience: 'anonymous', fields: {} },
activity: { 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: {} }, guilds: { audience: 'anonymous', fields: {} },
governors: { audience: 'anonymous', fields: {} }, governors: { audience: 'anonymous', fields: {} },
// The public Houses page showed IDOC location only; owner/price were staff. // 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 // `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 // 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. // 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: { houses: {
audience: 'anonymous', 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 // /public/shard/online listed linked staff to everyone but gated location to
// admin+moderator — which is exactly the `staff` rung. // admin+moderator — which is exactly the `staff` rung.
@@ -124,9 +157,25 @@ const FEATURES = {
// `ownerSerial` is listed alongside `ownerName` for the same reason `houses` // `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 // 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. // 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: { market: {
audience: 'anonymous', audience: 'anonymous',
fields: { ownerName: 'anonymous', ownerSerial: 'anonymous', location: 'anonymous' }, fields: {
ownerName: 'anonymous',
ownerSerial: 'anonymous',
location: 'anonymous',
fees: 'admin',
},
}, },
} }
@@ -160,6 +209,10 @@ const KIND_FEATURE = new Map(
// boards // boards
'champ.update': 'champs', 'champ.update': 'champs',
'champ.remove': '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.update': 'guilds',
'guild.remove': 'guilds', 'guild.remove': 'guilds',
'guild.join': 'guilds', 'guild.join': 'guilds',
@@ -177,6 +230,13 @@ const KIND_FEATURE = new Map(
// registry (house.update / house.remove — owner, price, co-owners) stays // registry (house.update / house.remove — owner, price, co-owners) stays
// off the map deliberately, so it remains admin-only exactly as before. // off the map deliberately, so it remains admin-only exactly as before.
'house.decay': 'houses', '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 // v3
'world.ruleset': 'ruleset', 'world.ruleset': 'ruleset',
'points.board': 'leaderboards', 'points.board': 'leaderboards',
@@ -186,6 +246,14 @@ const KIND_FEATURE = new Map(
// needs it live. An admin can turn it on. // needs it live. An admin can turn it on.
'vendor.listing': 'market', 'vendor.listing': 'market',
'vendor.listing.remove': '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

@@ -11,11 +11,48 @@
// `X-UOLink-Version: <protocol>` so a protocol mismatch is caught (409) rather // `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 // than mis-parsed. Config is cached for a few seconds to avoid decrypting the
// token on every call. // 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 uoLinkConfig = require('../model/uoLinkConfig/uoLinkConfig.model')
const log = require('../core').logger('uo-link-client') 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 const CONFIG_TTL_MS = 5000
let cachedConfig = null let cachedConfig = null
@@ -159,15 +196,18 @@ const createAccount = ({ actor, account, password, websiteUserId, ip }) =>
}) })
const unlinkAccount = ({ actor, account }) => const unlinkAccount = ({ actor, account }) =>
call(`/link/${encodeURIComponent(account)}`, { method: 'DELETE', body: { actor } }) call(`/link/${encodeURIComponent(account)}`, { method: 'DELETE', body: { actor } })
const postTownCrier = ({ id, lines, durationSec }) => const postTownCrier = ({ id, lines, durationSec, idempotencyKey }) =>
call('/towncrier', { method: 'POST', body: { id, lines, durationSec } }) call('/towncrier', { method: 'POST', body: { id, lines, durationSec, idempotencyKey } })
const deleteTownCrier = (id) => call(`/towncrier/${encodeURIComponent(id)}`, { method: 'DELETE' }) const deleteTownCrier = (id) => call(`/towncrier/${encodeURIComponent(id)}`, { method: 'DELETE' })
// Town Cryer News gump (Protocol 2.1). A full article (title/HTML body/image/URL) // 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` // 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. // (default true on the sidecar) controls whether the criers proclaim the title.
const postNews = ({ 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 } }) call('/news', {
method: 'POST',
body: { id: String(id), title, body, image, url, announce, idempotencyKey },
})
const deleteNews = (id) => call(`/news/${encodeURIComponent(id)}`, { method: 'DELETE' }) const deleteNews = (id) => call(`/news/${encodeURIComponent(id)}`, { method: 'DELETE' })
// ── Staff write plane (§6) ───────────────────────────────────────────────── // ── Staff write plane (§6) ─────────────────────────────────────────────────
@@ -180,8 +220,74 @@ const adminBan = ({ actor, account, serial, durationSec, reason }) =>
call('/admin/ban', { method: 'POST', body: { actor, account, serial, durationSec, reason } }) call('/admin/ban', { method: 'POST', body: { actor, account, serial, durationSec, reason } })
const adminUnban = ({ actor, account }) => const adminUnban = ({ actor, account }) =>
call('/admin/unban', { method: 'POST', body: { actor, account } }) call('/admin/unban', { method: 'POST', body: { actor, account } })
const adminBroadcast = ({ actor, text, hue }) => const adminBroadcast = ({ actor, text, hue, idempotencyKey }) =>
call('/admin/broadcast', { method: 'POST', body: { actor, text, hue } }) 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.
const getLeases = () => call('/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, value, holdMs, untilMs, runId, idempotencyKey }) =>
call('/lease', {
method: 'POST',
body: { key, 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, expected, baseline, idempotencyKey }) =>
call('/lease/release', {
method: 'POST',
body: {
key,
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 },
})
// ── Help-page (support) queue commands (§6) ──────────────────────────────── // ── Help-page (support) queue commands (§6) ────────────────────────────────
const respondPage = (pageId, { message, close }) => const respondPage = (pageId, { message, close }) =>
@@ -189,6 +295,7 @@ const respondPage = (pageId, { message, close }) =>
const closePage = (pageId) => call(`/pages/${encodeURIComponent(pageId)}/close`, { method: 'POST' }) const closePage = (pageId) => call(`/pages/${encodeURIComponent(pageId)}/close`, { method: 'POST' })
module.exports = { module.exports = {
TIMEOUT_MS,
invalidateConfig, invalidateConfig,
health, health,
getCharBySerial, getCharBySerial,
@@ -215,6 +322,12 @@ module.exports = {
deleteTownCrier, deleteTownCrier,
postNews, postNews,
deleteNews, deleteNews,
getLeases,
applyLease,
releaseLease,
openParticipation,
snapshotParticipation,
closeParticipation,
adminKick, adminKick,
adminBan, adminBan,
adminUnban, adminUnban,