9 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
19 changed files with 2558 additions and 30 deletions

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",
"ref": "52eac24d170adbeb7cfb06486bc7512da1173ef9",
"refName": "edge @ MODULE_API 1.9.0, engagement Phase 11b (website#179)"
"ref": "d4516739b43de5cb83b8f0333f8f966280a5632f",
"refName": "edge @ MODULE_API 1.10.0, the event module contract (website#189, #190)"
}

View File

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

View File

@@ -467,6 +467,38 @@ const TEMPLATES = [
'{{champsUrl}}',
),
// ── The champion falls (Protocol 6) ─────────────────────────────────────
//
// The other half of the pair above, and the half the wire could not report
// until protocol 6 gave the shard a kind for it. Written as the crier's own
// follow-up: the same voice that announced the champion walking is the one
// that reports it did not walk far.
//
// `{{damagerNote}}` is a single-token block, so an unattributed kill renders
// the paragraph without it rather than as a sentence with a hole in it.
email(
'uo.champ.boss-killed',
'Champion spawn — the champion falls (town crier)',
'uo.champ.boss_killed',
'Hear ye — {{bossName}} has fallen',
[
heading('h', 'Hear ye, hear ye'),
text('p1',
'{{bossName}} has fallen{{atPlace}}.{{damagerNote}} The altar is quiet again, and it '
+ 'will not stay quiet.'),
button('cta', 'See the altars', '{{champsUrl}}'),
],
),
inapp(
'uo.champ.boss-killed-inapp',
'Champion spawn — the champion falls (in-app)',
'uo.champ.boss_killed',
'{{bossName}} has fallen',
'{{bossName}} has fallen{{atPlace}}.{{damagerNote}}',
'See the altars',
'{{champsUrl}}',
),
// ── A guildmaster of the craft ──────────────────────────────────────────
email(
'uo.skill.capped',
@@ -624,6 +656,14 @@ const TEMPLATES = [
const CHANNELS_OWNER = ['email', 'inapp']
const CHANNELS_BROADCAST = ['email', 'inapp', 'push']
// The same two channels as CHANNELS_OWNER and a different reason for them: a
// rule that goes to every subscriber but cannot be PUSHED, because push is
// keyed on a subscription id and no trigger in this module is also a registered
// stream. Same value, different fact — folding them into one constant would lose
// the distinction the moment somebody added push to whichever one they read as
// "the broadcast-ish list". See `uo.champ.boss_killed`.
const CHANNELS_CONTENT = ['email', 'inapp']
/** In-universe: both bodies are this module's, the digest is core's. */
const bodies = (key) => ({
email: `uo.${key}`,
@@ -863,6 +903,32 @@ const RULES = [
cooldown_seconds: 1800,
max_sends_per_hour: 1000,
},
{
trigger_id: 'uo.champ.boss_killed',
name: 'Champion spawn — the champion falls',
audience: 'subscribers',
// **`CHANNELS_CONTENT`, not `CHANNELS_BROADCAST`** — this is the one rule in
// the file that leaves push out, and it is not an oversight.
//
// Push delivery is keyed on the SUBSCRIPTION id, and a subscription row only
// ever exists for an id the preferences screen offered a push toggle for —
// which core's catalog grants to registered STREAMS and nothing else. This
// module's stream ids (`champ.start`, `idoc.warning`, …) and its trigger ids
// (`uo.champ.started`, …) are disjoint sets, so no trigger here can be pushed
// through the engagement path at all: the tickle resolves to zero endpoints
// while the send log records it delivered.
//
// That is true of every sibling rule above and is a pre-existing defect, not
// one this rule introduces. What this rule declines to do is add a
// twenty-first instance of it. See EVENTS_PLAN.md Phase 11a.
channels: CHANNELS_CONTENT,
template_keys: bodies('champ.boss-killed'),
// The same half-hour as its `boss_up` twin, and on the SAME subject — the
// spawn — so an altar that pops and is cleared inside the window produces the
// walk or the fall, not both.
cooldown_seconds: 1800,
max_sends_per_hour: 1000,
},
{
trigger_id: 'uo.server.up',
name: 'Shard — came online',
@@ -960,10 +1026,29 @@ const RULES = [
},
]
const RULE_GROUPS = [{
key: 'triggers-v1',
note: 'UO notifications stay off until an operator enables one',
rules: RULES,
}]
// A group is seeded ONCE, under its own settings guard. So a rule appended to an
// existing group reaches fresh installs and nothing else: every deployment that
// has already stamped `triggers-v1` is done with it forever, and the new rule
// would silently never arrive. That is Engagement Phase 11's seed-key finding,
// and core applied the same remedy in Events Phase 10 — a NEW key per addition,
// never an edit to an old one.
//
// So protocol 6's `uo.champ.boss_killed` rule ships as its own group rather than
// as a twenty-seventh entry above. `RULES` remains the whole declared set, which
// is what the "every declared trigger has exactly one rule" invariant reads.
const BOSS_KILLED = RULES.filter((r) => r.trigger_id === 'uo.champ.boss_killed')
const RULE_GROUPS = [
{
key: 'triggers-v1',
note: 'UO notifications stay off until an operator enables one',
rules: RULES.filter((r) => !BOSS_KILLED.includes(r)),
},
{
key: 'champ-boss-killed-v1',
note: 'The champion-falls notice, added with protocol 6; off like every other',
rules: BOSS_KILLED,
},
]
module.exports = { TEMPLATES, RULES, RULE_GROUPS }

View File

@@ -600,6 +600,42 @@ const COME_ONLINE = [
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',

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

@@ -104,7 +104,16 @@ module.exports = {
// 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) },
events: {
emit: (...args) => need().events.emit(...args),
// MODULE_API 1.10.0 (EVENTS.md F, Phase 8). "Ask every action of mine which
// of its ledgered resources the game still has." Core cannot know when to
// ask -- it has no concept of the game being up -- so the module says when,
// and `shardIngest` says it on a changed `bootId`. Fire-and-forget like
// `emit`, and for the same reason: core owns what happens next and there is
// nothing a game-event handler could correctly do with the answer.
reconcile: (...args) => need().events.reconcile(...args),
},
inbox: { push: (...args) => need().inbox.push(...args) },
secretBox: {
encrypt: (...args) => need().secretBox.encrypt(...args),

View File

@@ -47,6 +47,7 @@ module.exports = function register(ctx, api) {
const shardTriggers = require('./config/shardTriggers')
const shardAudiences = require('./config/shardAudiences')
const engagementSeeds = require('./config/engagementSeeds')
const uoEventActions = require('./config/uoEventActions')
const townCrierLeg = require('./utils/shardAnnounce')
const teamProvider = require('./model/teamProvider/teamProvider.model')
const guildCommand = require('./commands/guild.command')
@@ -157,6 +158,29 @@ const engagementSeeds = require('./config/engagementSeeds')
// either.
api.registerSlashCommands([guildCommand])
// The event contract (MODULE_API 1.10.0, EVENTS.md F, EVENTS_PLAN.md Phase 9).
// Three verbs an event author can put in a step, the one budget dimension that
// bounds a broadcast, and the three option sources the spawn atlas answers.
//
// **All of it is optional, by the contract's own posture.** A deployment
// without this module still has an event engine that can announce, wait, cue a
// human and publish results; what these add is the ability for an event to
// reach the GAME. Nothing here is a precondition for anything of core's.
//
// The wave is deliberately the verbs that need no protocol change: the write
// plane they use has existed since protocol 2.1 and the admin screens have
// driven it by hand for months. The world verbs -- creatures, gates, leases --
// wait for Phase 11 to put an idempotency key and a lease deadline on the wire,
// because a world write core cannot prove ran exactly once is not one this
// module is willing to make unattended.
api.registerEventBudgets(uoEventActions.BUDGETS)
api.registerEventActions(uoEventActions.ACTIONS)
// Phase 11b. One live-read config key, and the module never writes it: an author
// puts `core.lease` in a step and core owns the duration bound, the
// two-events-one-target check and the teardown restore.
api.registerEventLeases(uoEventActions.LEASES)
api.registerEventOptionSources(uoEventActions.OPTION_SOURCES)
api.onBoot(boot.onBoot)
api.onShutdown(boot.onShutdown)
@@ -166,5 +190,6 @@ const engagementSeeds = require('./config/engagementSeeds')
streams: shardStreams.STREAMS.length,
triggers: shardTriggers.TRIGGERS.length,
audiences: shardAudiences.AUDIENCES.length,
eventActions: uoEventActions.ACTIONS.length,
})
}

View File

@@ -50,7 +50,7 @@ function fakeCtx(overrides = {}) {
// 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) },
events: { emit: spy(undefined), reconcile: spy(undefined) },
inbox: { push: spy(undefined) },
secretBox: { encrypt: spy('enc'), decrypt: spy('dec') },
middleware: {
@@ -104,6 +104,9 @@ function fakeApi() {
slashCommands: [],
triggers: null,
audiences: null,
eventActions: null,
eventBudgets: null,
eventOptionSources: null,
hooks: {},
}
const called = new Set()
@@ -134,6 +137,16 @@ function fakeApi() {
// and merging two calls would make "which group is this rule in" — the
// question the one-shot seed guard answers — unanswerable.
registerEngagementSeeds(seeds) { once('registerEngagementSeeds'); record.engagementSeeds = seeds },
// MODULE_API 1.10.0 (EVENTS.md F, EVENTS_PLAN.md Phases 7 and 9). `once` on
// all three, matching core: it stages a registrant's whole batch and applies
// it as one, so a second call is a module changing its mind mid-register().
registerEventActions(actions) { once('registerEventActions'); record.eventActions = actions },
registerEventBudgets(budgets) { once('registerEventBudgets'); record.eventBudgets = budgets },
registerEventOptionSources(sources) { once('registerEventOptionSources'); record.eventOptionSources = sources },
// And the fourth, from Phase 11b. `once` for the same reason, and present here
// for a second one: a verb this module calls and this fake does not have is a
// TypeError in `entry.test.js` rather than a surprise at somebody's boot.
registerEventLeases(leases) { once('registerEventLeases'); record.eventLeases = leases },
onBoot(fn) { once('onBoot'); record.hooks.onBoot = fn },
onShutdown(fn) { once('onShutdown'); record.hooks.onShutdown = fn },
}

View File

@@ -96,8 +96,10 @@ test('the seventeen in-universe families have both channels; the nine plain ones
// letter from anybody.
assert.equal(r.template_keys.digest, 'notify.digest', `${r.trigger_id} digests generically`)
}
assert.equal(bespoke, 17)
assert.equal(seeds.TEMPLATES.length, 34)
// 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', () => {
@@ -222,7 +224,7 @@ 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']
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`)
@@ -236,7 +238,17 @@ 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.
assert.equal(seeds.RULE_GROUPS.length, 1)
// 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.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.strictEqual(typeof api.record.hooks.onBoot, 'function')
assert.strictEqual(typeof api.record.hooks.onShutdown, 'function')

View File

@@ -30,7 +30,11 @@ const one = (event) => {
// ── The catalogue itself ───────────────────────────────────────────────────
test('the declared set is the one ENGAGEMENT.md §8.6 commits to, carve-outs included', () => {
assert.equal(TRIGGERS.length, 26)
// 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.
@@ -113,6 +117,10 @@ test('every url variable a body can interpolate is actually SUPPLIED', () => {
'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' },
@@ -320,6 +328,21 @@ test('the pre-decision attempt kind is not mapped at all', () => {
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
@@ -339,6 +362,64 @@ test('champ.remove forgets the spawn, so its next appearance is a first sighting
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', () => {

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

@@ -95,6 +95,58 @@ test('an unknown viewer level cannot see a gated kind or a locked field', async
assert.equal('webId' in out.leader, false)
})
// ── Protocol 6: the champion defeat ──────────────────────────────────
const KILL = {
kind: 'champ.boss.killed',
serial: '0x40012345',
boss: 'Semidar',
killer: { serial: '0x55', name: 'Aldric', acct: 'seed_002', player: true },
damagers: [
{ serial: '0x55', name: 'Aldric', acct: 'seed_002', webId: '7', player: true, damage: 900 },
{ serial: '0x56', name: 'Bran', acct: 'seed_003', player: true, damage: 120 },
],
}
test('the kill is public and its damage table is not', () => {
const config = visibility.compileDefaults()
// The whole shape of this addition in one assertion: a champion falling is
// content the public board is FOR, and a ranked roll of who was strong enough
// to fell it is a performance record nobody published on purpose.
assert.equal(visibility.kindVisibleTo('champ.boss.killed', 'anonymous', config), true)
for (const level of ['anonymous', 'logged_in', 'player']) {
const out = visibility.projectFeature('champs', KILL, level, config)
assert.equal(out.boss, 'Semidar', `${level} sees which boss fell`)
assert.equal('damagers' in out, false, `${level} must not see the damage table`)
}
assert.equal(visibility.projectFeature('champs', KILL, 'staff', config).damagers.length, 2)
})
test('the killer rides the frame the way mob.killed already publishes one', () => {
// Deliberately NOT a configurable field. It is one actor, announced in-game to
// everyone present, and the same disclosure the public activity feed has made
// through `mob.killed` since before this framework existed.
const config = visibility.compileDefaults()
const out = visibility.projectFeature('champs', KILL, 'anonymous', config)
assert.equal(out.killer.name, 'Aldric')
assert.equal('acct' in out.killer, false, 'rule 1 still applies inside it')
})
test('an admin who lowers the damager rule still cannot see an account inside it', () => {
// Rule 1 beats a field rule wherever the two meet, and a damager entry is an
// actor object like any other. An admin who opens the table to everyone has
// published character names, which is what they chose; they have not published
// account names, which is not theirs to choose.
const config = visibility.compileDefaults()
config.champs.fields = { ...config.champs.fields, damagers: 'anonymous' }
const out = visibility.projectFeature('champs', KILL, 'anonymous', config)
assert.equal(out.damagers.length, 2)
assert.equal(out.damagers[0].name, 'Aldric')
assert.equal(out.damagers[0].damage, 900)
assert.equal('acct' in out.damagers[0], false)
assert.equal('webId' in out.damagers[0], false)
})
// ── Rule 1: locked fields ──────────────────────────────────────────────────
test('acct and webId are stripped below admin regardless of feature config', () => {
@@ -360,10 +412,21 @@ const V3_ADDED_PUBLIC_KINDS = ['world.ruleset', 'points.board']
// inside the roster's member array (see the roster test above).
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(
[...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(),
)
})

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

View File

@@ -662,6 +662,53 @@ const MAPPERS = {
}
},
// Protocol 6. A boss defeat, which until now could only be GUESSED at from
// `champ.update` losing its `bossUp` — a signal that also fires when a spawn is
// reset by a GM, when a boss despawns, and when the sweep simply reconnects.
// This one fires on the death itself.
//
// **The subject is the SPAWN, so it matches `uo.champ.boss_up`'s.** A rule with
// a cooldown on one altar therefore counts a boss going up and that same boss
// coming down as the same subject, which is what an operator writing "not more
// than once an hour about Destard" means. A kill the shard could not attribute
// to an altar carries no spawn, so the boss's own serial stands in — it is a
// subject that exists exactly once, which is all a cooldown needs of it.
//
// **Damagers are not surfaced as variables.** The table is on the frame and it
// is `staff` in the visibility config; putting names into a trigger's data
// would route them into mail an operator can address to `subscribers`, which is
// the field rule undone one layer up. `damagerCount` is a number and says the
// thing worth saying: how many took part.
'champ.boss.killed': (ev, tracker, out) => {
const spawnSerial = ev.serial == null ? null : String(ev.serial)
const bossSerial = ev.bossSerial == null ? null : String(ev.bossSerial)
const subject = spawnSerial || bossSerial
if (!subject) return
// The board no longer has a boss on this altar. Kept in step with the sweep's
// own view so the next `champ.update` carrying `bossUp: true` is read as a
// transition rather than as more of the same.
if (spawnSerial) tracker.champBossUp.set(spawnSerial, false)
const damagers = Array.isArray(ev.damagers) ? ev.damagers : []
out.push({
triggerId: 'uo.champ.boss_killed',
data: defined({
spawnSerial: subject,
champsUrl: PATHS.champs,
bossName: ev.boss || ev.bossType || 'the champion',
category: ev.category || undefined,
location: place(ev),
atPlace: trailing(place(ev), (p) => ` at ${p}`),
killerName: actorName(ev.killer),
damagerCount: damagers.length || undefined,
damagerNote: trailing(damagers.length || null, (n) =>
n === 1 ? ' One player fought it.' : ` ${n} players fought it.`),
}),
})
},
'champ.remove': (ev, tracker) => {
if (ev.serial == null) return
tracker.champActive.delete(String(ev.serial))

View File

@@ -17,7 +17,7 @@ const shardStateModel = require('../model/shardState/shardState.model')
const shardLinksModel = require('../model/shardLinks/shardLinks.model')
const shardMarketModel = require('../model/shardMarket/shardMarket.model')
const uoLinkConfigModel = require('../model/uoLinkConfig/uoLinkConfig.model')
const { settings: settingsModel } = require('../core')
const { settings: settingsModel, events: coreEvents } = require('../core')
const broadcaster = require('./shardBroadcast')
const shardPush = require('./shardPush')
const shardEngagement = require('./shardEngagement')
@@ -103,11 +103,12 @@ async function resolveShardName(shard, deps) {
// Apply the state-change side effect for a kind (if any). Returns a promise.
async function applyStateChange(event, deps) {
const { shardState, uoLinkConfig, log } = deps
const { shardState, uoLinkConfig, eventsReconcile, fromBackfill, log } = deps
switch (event.kind) {
case 'server.hello': {
const incoming = event.bootId || null
if (incoming && state.bootId && incoming !== state.bootId) {
const restarted = Boolean(incoming && state.bootId && incoming !== state.bootId)
if (restarted) {
log.warn('shard restarted (bootId changed) — clearing online roster', {
from: state.bootId,
to: incoming,
@@ -116,6 +117,31 @@ async function applyStateChange(event, deps) {
}
if (incoming) state.bootId = incoming
await uoLinkConfig.recordStatus({ pluginConnected: true, bootId: incoming, lastEventAt: event.t })
if (restarted && !fromBackfill) {
// EVENTS.md F: core has no concept of the game being up, so the module
// says when a ledger of live shard resources has become a claim about a
// world that no longer exists. This is that moment, and a changed
// `bootId` is the only thing that distinguishes it from a sidecar
// reconnect — which changes nothing in the game and must not orphan a row.
//
// **After `recordStatus`, and that ordering is load-bearing.** Every
// action's `reconcile()` decides what is still in force by comparing its
// stamp against the CURRENT boot id, which it reads back out of this
// row. Asking first would have every resource compared against the boot
// that has just ended, and every one of them would look live.
//
// **And never on a backfill replay**, which is the same rule the
// engagement fan-out and the SSE broadcast state below and is far more
// expensive to break here. A reconnect replays the last several
// `server.hello` frames in order — this rig saw three, each with a
// different `bootId` — so every replayed frame looks like a restart, and
// the intermediate ones would compare a resource stamped with the CURRENT
// boot against a boot that ended hours ago. The row is then `orphaned`:
// a live crier line core will never take down again, lost to nothing
// worse than the website reconnecting. The website-was-down case is not
// missed by skipping these — core asks every module at its own boot.
eventsReconcile()
}
return
}
case 'server.shutdown':
@@ -292,6 +318,15 @@ function resolveDeps(deps) {
broadcast: deps.broadcast || broadcaster.broadcast,
pushDispatch: deps.pushDispatch || shardPush.fromShardEvent,
engagement: deps.engagement || shardEngagement.fromShardEvent,
// MODULE_API 1.10.0 (EVENTS.md F, Phase 8). Injectable for the same reason
// every member above is: a test that asserted a shard restart triggers a
// reconcile must be able to see the call without a live event engine behind
// it.
eventsReconcile: deps.eventsReconcile || (() => coreEvents.reconcile()),
// Not injectable — it is the caller's statement about this frame rather than
// a dependency. It reaches `applyStateChange` because the reconcile below is
// the one state change that must not act on a replay; see the note there.
fromBackfill: Boolean(deps.fromBackfill),
log: deps.log || defaultLog,
}
}

View File

@@ -83,7 +83,27 @@ const FEATURES = {
// ── Shipped before v3. Defaults reproduce the previous hardcoded behavior. ──
status: { audience: 'anonymous', fields: {} },
activity: { audience: 'anonymous', fields: {} },
champs: { audience: 'anonymous', fields: {} },
// Protocol 6 adds `champ.boss.killed` to this feature, and with it the first
// field on a champs frame that is about PEOPLE rather than about an altar.
//
// `damagers` is the ranked table of who fought the boss and for how much. It is
// the honest basis for "who slew the champion" and it is also a performance
// record of named players that nobody consented to publish, which is precisely
// the tension the ladder exists to let a shard resolve for itself. It defaults
// to `staff`: the kill is public (a champion falling is announced in-world and
// is the content the board is for), the roll of who did the damage is not. A
// shard that wants a public board lowers one rule.
//
// Nested for the same reason `market.fees` and `houses.schedule` are: one rule
// covers the whole table rather than a rule per column, and the columns here
// are actor objects whose `acct`/`webId` remain admin-only by the locked-field
// rule regardless of what this is set to.
//
// `killer` is deliberately NOT listed. It is the single actor whose blow landed
// last, it is announced in-game to everyone present, and it is the same shape
// and the same disclosure `mob.killed` has published on the public activity
// feed since before this framework existed.
champs: { audience: 'anonymous', fields: { damagers: 'staff' } },
guilds: { audience: 'anonymous', fields: {} },
governors: { audience: 'anonymous', fields: {} },
// The public Houses page showed IDOC location only; owner/price were staff.
@@ -189,6 +209,10 @@ const KIND_FEATURE = new Map(
// boards
'champ.update': 'champs',
'champ.remove': 'champs',
// Protocol 6. Without this line rule 2 would fail the new kind closed to
// admin-only — correct as a default, and wrong as an outcome: a champion
// falling is exactly what the public board is for.
'champ.boss.killed': 'champs',
'guild.update': 'guilds',
'guild.remove': 'guilds',
'guild.join': 'guilds',
@@ -222,6 +246,14 @@ const KIND_FEATURE = new Map(
// needs it live. An admin can turn it on.
'vendor.listing': 'market',
'vendor.listing.remove': 'market',
// Protocol 6 part b's `lease.applied` and `lease.expired` are deliberately NOT
// here, on the same reasoning that keeps `account.login.result` off it. They are
// operational frames about the WEBSITE changing this shard's configuration --
// which key, from what to what, on whose run, and whether the shard's own
// deadline had to put it back because nobody asked. Rule 2 fails an unmapped
// kind closed to admin-only, which is where an audit trail of the site's writes
// belongs; mapping them would mean choosing a feature an operator could then
// widen, and there is no rung below admin these frames belong on.
}),
)

View File

@@ -11,11 +11,48 @@
// `X-UOLink-Version: <protocol>` so a protocol mismatch is caught (409) rather
// than mis-parsed. Config is cached for a few seconds to avoid decrypting the
// token on every call.
//
// ── Protocol 6: `idempotencyKey` on a write ────────────────────────────────
//
// The three write helpers the event engine drives take an optional
// `idempotencyKey`, which the sidecar passes to the shard verbatim. The shard
// executes a key at most once and answers a repeat with the ORIGINAL reply, which
// is what makes retrying a world write safe — before it, a lost acknowledgement
// and a command that never applied were the same event seen from here.
//
// **A key is a function of the caller's unit of work, never of the attempt.** The
// event runner derives it from `sha256(runId|stepId)`, so every retry of one step
// carries the same key and a different step never collides with it. Passing a
// fresh value per call would satisfy the type and defeat the entire mechanism.
//
// **The DELETEs deliberately take no key.** Their idempotency is inherent — the
// second removal of a town-crier entry or a news article is a no-op the shard is
// already happy to perform — and the sidecar builds those commands from the path
// rather than from a body, so carrying one would be a protocol change bought for
// a guarantee that already holds.
//
// A caller that sends no key gets exactly the pre-protocol-6 behaviour, which is
// what leaves the admin screens (which send none, being driven by a human who can
// see whether the thing happened) unchanged.
//
// One new status can now come back from a keyed write: **425**, the sidecar's
// mapping of `bridge.busy` — a command under this key is still in flight on the
// shard. It is transient and retryable, and `shardAnnounce.classify` already
// treats it so by falling through to its retry case.
const uoLinkConfig = require('../model/uoLinkConfig/uoLinkConfig.model')
const log = require('../core').logger('uo-link-client')
const TIMEOUT_MS = 12000 // sidecar waits up to 10s on the shard before 504
// The sidecar waits up to 10s on the shard before answering 504, so this sits
// just above it — every call answers rather than being abandoned mid-flight.
//
// **Exported because the event actions are declared against it** (EVENTS_PLAN.md
// Phase 9). An action's `budgetMs` must exceed this or core's dispatch deadline
// fires first and classifies the step `retry` without asking the module, which
// for a broadcast means announcing twice. `config/uoEventActions.js` states that
// relationship and its test asserts it, and both need the number to come from
// here rather than from a copy that can drift.
const TIMEOUT_MS = 12000
const CONFIG_TTL_MS = 5000
let cachedConfig = null
@@ -159,15 +196,18 @@ const createAccount = ({ actor, account, password, websiteUserId, ip }) =>
})
const unlinkAccount = ({ actor, account }) =>
call(`/link/${encodeURIComponent(account)}`, { method: 'DELETE', body: { actor } })
const postTownCrier = ({ id, lines, durationSec }) =>
call('/towncrier', { method: 'POST', body: { id, lines, durationSec } })
const postTownCrier = ({ id, lines, durationSec, idempotencyKey }) =>
call('/towncrier', { method: 'POST', body: { id, lines, durationSec, idempotencyKey } })
const deleteTownCrier = (id) => call(`/towncrier/${encodeURIComponent(id)}`, { method: 'DELETE' })
// Town Cryer News gump (Protocol 2.1). A full article (title/HTML body/image/URL)
// in the in-game News window; re-posting the same id REPLACES it. `announce`
// (default true on the sidecar) controls whether the criers proclaim the title.
const postNews = ({ id, title, body, image, url, announce }) =>
call('/news', { method: 'POST', body: { id: String(id), title, body, image, url, announce } })
const postNews = ({ id, title, body, image, url, announce, idempotencyKey }) =>
call('/news', {
method: 'POST',
body: { id: String(id), title, body, image, url, announce, idempotencyKey },
})
const deleteNews = (id) => call(`/news/${encodeURIComponent(id)}`, { method: 'DELETE' })
// ── Staff write plane (§6) ─────────────────────────────────────────────────
@@ -180,8 +220,74 @@ const adminBan = ({ actor, account, serial, durationSec, reason }) =>
call('/admin/ban', { method: 'POST', body: { actor, account, serial, durationSec, reason } })
const adminUnban = ({ actor, account }) =>
call('/admin/unban', { method: 'POST', body: { actor, account } })
const adminBroadcast = ({ actor, text, hue }) =>
call('/admin/broadcast', { method: 'POST', body: { actor, text, hue } })
const adminBroadcast = ({ actor, text, hue, idempotencyKey }) =>
call('/admin/broadcast', { method: 'POST', body: { actor, text, hue, idempotencyKey } })
// ── The event plane (protocol 6, EVENTS_PLAN.md Phase 11b) ─────────────────
//
// Leases and the run-scoped participation ledger. Both are gated on the shard by
// `Bridge.EventsEnabled`, which is deliberately NOT the admin plane's switch: an
// operator consenting to staff moderation from a screen has not thereby consented
// to the website changing their world on a schedule at four in the morning. A
// shard with the plane off answers 403, and the actions turn that into a refusal
// an author can read rather than a retry.
// Every lease this shard offers, with what each is worth right now and what is
// holding it. One read serves both questions core asks — `read()` wants the
// current value, `inForce()` wants to know whether the shard still has a record
// of the hold — so a lease costs one round trip, not two.
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) ────────────────────────────────
const respondPage = (pageId, { message, close }) =>
@@ -189,6 +295,7 @@ const respondPage = (pageId, { message, close }) =>
const closePage = (pageId) => call(`/pages/${encodeURIComponent(pageId)}/close`, { method: 'POST' })
module.exports = {
TIMEOUT_MS,
invalidateConfig,
health,
getCharBySerial,
@@ -215,6 +322,12 @@ module.exports = {
deleteTownCrier,
postNews,
deleteNews,
getLeases,
applyLease,
releaseLease,
openParticipation,
snapshotParticipation,
closeParticipation,
adminKick,
adminBan,
adminUnban,