From 57419111e628f3de350a7205642eb66154b9f4f7 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Fri, 4 Sep 2026 07:23:03 -0500 Subject: [PATCH 1/8] =?UTF-8?q?feat(events):=20UO=20wave=201=20=E2=80=94?= =?UTF-8?q?=20the=20verbs=20that=20need=20no=20protocol=20change=20(Phase?= =?UTF-8?q?=209)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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-`, 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 --- ci/core-ref.json | 6 +- module.json | 4 +- server/config/uoEventActions.js | 533 ++++++++++++++++++ server/core.js | 11 +- server/index.js | 21 + server/test/_fakes.js | 11 +- server/test/entry.test.js | 15 + .../test/shardIngest.eventReconcile.test.js | 97 ++++ server/test/uoEventActions.test.js | 408 ++++++++++++++ server/utils/shardIngest.js | 29 +- server/utils/uoLinkClient.js | 12 +- 11 files changed, 1136 insertions(+), 11 deletions(-) create mode 100644 server/config/uoEventActions.js create mode 100644 server/test/shardIngest.eventReconcile.test.js create mode 100644 server/test/uoEventActions.test.js diff --git a/ci/core-ref.json b/ci/core-ref.json index af46040..d71a373 100644 --- a/ci/core-ref.json +++ b/ci/core-ref.json @@ -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": "66bb3b9a3fad01112c06f32d931c9bae56d22de6", - "refName": "main @ MODULE_API 1.9.0, the engagement cutover (website#180)" + "ref": "d4516739b43de5cb83b8f0333f8f966280a5632f", + "refName": "edge @ MODULE_API 1.10.0, the event module contract (website#189, #190)" } diff --git a/module.json b/module.json index ffc4999..af2651a 100644 --- a/module.json +++ b/module.json @@ -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", diff --git a/server/config/uoEventActions.js b/server/config/uoEventActions.js new file mode 100644 index 0000000..103985e --- /dev/null +++ b/server/config/uoEventActions.js @@ -0,0 +1,533 @@ +// ── 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 attempted exactly once.** `on_failure` is what happens +// AFTER `EVENT_STEP_MAX_ATTEMPTS` retries, and `skip` — the default for `notify` +// — is a disposition, not a retry policy; there is no per-action lever that says +// "do not retry me". The lever a module HAS is the failure envelope, so +// `uo.broadcast` answers `retry: false` to everything. A retried broadcast is a +// second announcement to everyone online, and there is no idempotency key on the +// wire until Phase 11 to make the shard refuse the repeat. A lost announcement is +// cheaper than a doubled one. +// +// **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 + } +} + +/** + * 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, error } = classifySidecarWrite(result) + return { ok: false, retry: outcome === 'retry', error: error || `the shard refused the ${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.', + }, +] + +// ── 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, 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:` (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), + }) + if (result.ok) return { ok: true } + + // **Every failure is terminal, deliberately** — see rule 2 in the header. + // A 503 from a shard that is merely restarting IS transient and this throws + // that retry away; that is the trade, taken knowingly, because the failure + // this refuses to risk is announcing twice to everyone online. Phase 11 + // puts an idempotency key on the wire and this line is what changes. + const { error } = classifySidecarWrite(result) + return { + ok: false, + retry: false, + error: `${error || 'the shard refused the broadcast'} (not retried: a repeat would announce twice)`, + } + }, + }, + + { + 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() + const result = await uoLinkClient.postTownCrier({ id, lines: parsed.lines, durationSec }) + 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: `
${title}


${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), + }) + 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, + }, +] + +/** + * 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 } +} + +// ── 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, + 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, + resourceId, + crierLines, + reconcileByBootId, +} diff --git a/server/core.js b/server/core.js index 1959383..b3bd57b 100644 --- a/server/core.js +++ b/server/core.js @@ -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), diff --git a/server/index.js b/server/index.js index e9ca46a..ec066e6 100644 --- a/server/index.js +++ b/server/index.js @@ -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,25 @@ 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) + api.registerEventOptionSources(uoEventActions.OPTION_SOURCES) + api.onBoot(boot.onBoot) api.onShutdown(boot.onShutdown) @@ -166,5 +186,6 @@ const engagementSeeds = require('./config/engagementSeeds') streams: shardStreams.STREAMS.length, triggers: shardTriggers.TRIGGERS.length, audiences: shardAudiences.AUDIENCES.length, + eventActions: uoEventActions.ACTIONS.length, }) } diff --git a/server/test/_fakes.js b/server/test/_fakes.js index f0bd101..ffcddd3 100644 --- a/server/test/_fakes.js +++ b/server/test/_fakes.js @@ -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,12 @@ 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 }, onBoot(fn) { once('onBoot'); record.hooks.onBoot = fn }, onShutdown(fn) { once('onShutdown'); record.hooks.onShutdown = fn }, } diff --git a/server/test/entry.test.js b/server/test/entry.test.js index 91b9249..211b440 100644 --- a/server/test/entry.test.js +++ b/server/test/entry.test.js @@ -53,6 +53,21 @@ 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.towncrier.post'], + ) + assert.deepStrictEqual(api.record.eventBudgets.map((b) => b.id), ['uo.broadcasts']) + 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') diff --git a/server/test/shardIngest.eventReconcile.test.js b/server/test/shardIngest.eventReconcile.test.js new file mode 100644 index 0000000..69a9e17 --- /dev/null +++ b/server/test/shardIngest.eventReconcile.test.js @@ -0,0 +1,97 @@ +// 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 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)) +}) diff --git a/server/test/uoEventActions.test.js b/server/test/uoEventActions.test.js new file mode 100644 index 0000000..2241780 --- /dev/null +++ b/server/test/uoEventActions.test.js @@ -0,0 +1,408 @@ +// 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' }) +}) + +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: attempted exactly once ─────────────────────────────────── + +test('a broadcast is never retried, whatever the sidecar says', async () => { + const broadcast = byId('uo.broadcast') + // Every failure this transport can produce: no route to the sidecar, a data + // refusal, a bad token, a protocol mismatch, a shard that is not connected and + // a shard that timed out. The last two are genuinely transient, and this is + // the trade being taken knowingly — a lost announcement is cheaper than one + // delivered twice to everyone online. + for (const status of [0, 400, 401, 409, 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, false, `a ${status} must not be retried`) + assert.match(result.error, /announce twice/, 'the refusal must say why it is not retried') + } +}) + +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, /
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], [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') +}) diff --git a/server/utils/shardIngest.js b/server/utils/shardIngest.js index 03762de..e084588 100644 --- a/server/utils/shardIngest.js +++ b/server/utils/shardIngest.js @@ -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, 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,23 @@ async function applyStateChange(event, deps) { } if (incoming) state.bootId = incoming await uoLinkConfig.recordStatus({ pluginConnected: true, bootId: incoming, lastEventAt: event.t }) + if (restarted) { + // 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. + // + // Fire-and-forget by the contract: core logs what it orphaned, and there + // is nothing an ingest handler could correctly do with the answer. + eventsReconcile() + } return } case 'server.shutdown': @@ -292,6 +310,11 @@ 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()), log: deps.log || defaultLog, } } diff --git a/server/utils/uoLinkClient.js b/server/utils/uoLinkClient.js index c88d186..5233836 100644 --- a/server/utils/uoLinkClient.js +++ b/server/utils/uoLinkClient.js @@ -15,7 +15,16 @@ 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 @@ -189,6 +198,7 @@ const respondPage = (pageId, { message, close }) => const closePage = (pageId) => call(`/pages/${encodeURIComponent(pageId)}/close`, { method: 'POST' }) module.exports = { + TIMEOUT_MS, invalidateConfig, health, getCharBySerial, -- 2.49.1 From 021f191f6503d56e309b7cae05c47fcb34772140 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Fri, 4 Sep 2026 07:36:03 -0500 Subject: [PATCH 2/8] fix(events): three defects the live rig found, two of them data loss MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- server/config/uoEventActions.js | 49 +++++++++++++++++-- .../test/shardIngest.eventReconcile.test.js | 29 +++++++++++ server/test/uoEventActions.test.js | 45 +++++++++++++++-- server/utils/shardIngest.js | 20 ++++++-- 4 files changed, 132 insertions(+), 11 deletions(-) diff --git a/server/config/uoEventActions.js b/server/config/uoEventActions.js index 103985e..c73c0a4 100644 --- a/server/config/uoEventActions.js +++ b/server/config/uoEventActions.js @@ -106,6 +106,31 @@ async function currentBootId() { } } +// 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. * @@ -117,8 +142,13 @@ async function currentBootId() { * a leg's own `classify()`. */ function sidecarFailure(result, what) { - const { outcome, error } = classifySidecarWrite(result) - return { ok: false, retry: outcome === 'retry', error: error || `the shard refused the ${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. */ @@ -228,11 +258,20 @@ const ACTIONS = [ // that retry away; that is the trade, taken knowingly, because the failure // this refuses to risk is announcing twice to everyone online. Phase 11 // puts an idempotency key on the wire and this line is what changes. - const { error } = classifySidecarWrite(result) + // + // **The clause is only added where a retry was genuinely given up**, and + // the rig is what made that distinction matter. A 403 — the shard's admin + // write plane switched off — 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 when what they need is the sentence the shard + // already wrote: "admin write plane disabled". A reason that explains the + // wrong thing is worse than a bare status code. + const reason = sidecarReason(result, 'broadcast') + if (PERMANENT_STATUSES.has(result.status)) return { ok: false, retry: false, error: reason } return { ok: false, retry: false, - error: `${error || 'the shard refused the broadcast'} (not retried: a repeat would announce twice)`, + error: `${reason} (not retried: a repeat would announce twice)`, } }, }, @@ -527,6 +566,8 @@ module.exports = { MAX_NEWS_TITLE, MAX_NEWS_BODY, MAX_OPTIONS, + PERMANENT_STATUSES, + sidecarReason, resourceId, crierLines, reconcileByBootId, diff --git a/server/test/shardIngest.eventReconcile.test.js b/server/test/shardIngest.eventReconcile.test.js index 69a9e17..9f3475b 100644 --- a/server/test/shardIngest.eventReconcile.test.js +++ b/server/test/shardIngest.eventReconcile.test.js @@ -87,6 +87,35 @@ test('a hello with no bootId at all changes nothing', async () => { 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. diff --git a/server/test/uoEventActions.test.js b/server/test/uoEventActions.test.js index 2241780..7fb22fe 100644 --- a/server/test/uoEventActions.test.js +++ b/server/test/uoEventActions.test.js @@ -142,15 +142,54 @@ test('a broadcast is never retried, whatever the sidecar says', async () => { // a shard that timed out. The last two are genuinely transient, and this is // the trade being taken knowingly — a lost announcement is cheaper than one // delivered twice to everyone online. - for (const status of [0, 400, 401, 409, 503, 504]) { + for (const status of [0, 400, 401, 403, 409, 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, false, `a ${status} must not be retried`) - assert.match(result.error, /announce twice/, 'the refusal must say why it is not retried') + // The clause belongs only where a retry was genuinely given up. On a + // permanent status it would explain the wrong thing. + if (!actions.PERMANENT_STATUSES.has(status)) { + assert.match(result.error, /announce twice/, 'a discarded retry must say why') + } else { + assert.doesNotMatch(result.error, /announce twice/, `a ${status} was never retryable`) + } } }) +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) @@ -223,7 +262,7 @@ test('the keyed verbs DO retry, because a repeat replaces', async () => { 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], [409, false], [503, true], [504, true], [0, true]]) { + 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) diff --git a/server/utils/shardIngest.js b/server/utils/shardIngest.js index e084588..7a040c1 100644 --- a/server/utils/shardIngest.js +++ b/server/utils/shardIngest.js @@ -103,7 +103,7 @@ 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, eventsReconcile, log } = deps + const { shardState, uoLinkConfig, eventsReconcile, fromBackfill, log } = deps switch (event.kind) { case 'server.hello': { const incoming = event.bootId || null @@ -117,7 +117,7 @@ async function applyStateChange(event, deps) { } if (incoming) state.bootId = incoming await uoLinkConfig.recordStatus({ pluginConnected: true, bootId: incoming, lastEventAt: event.t }) - if (restarted) { + 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 @@ -130,8 +130,16 @@ async function applyStateChange(event, deps) { // row. Asking first would have every resource compared against the boot // that has just ended, and every one of them would look live. // - // Fire-and-forget by the contract: core logs what it orphaned, and there - // is nothing an ingest handler could correctly do with the answer. + // **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 @@ -315,6 +323,10 @@ function resolveDeps(deps) { // 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, } } -- 2.49.1 From dc135159276ce26275428793e74baf3bc7d71733 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Fri, 4 Sep 2026 14:57:26 -0500 Subject: [PATCH 3/8] feat(events): send the idempotency key, and declare champ.boss.killed (Phase 11a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- server/config/engagementSeeds.js | 95 +++++++++++++++++++++++++++-- server/config/shardTriggers.js | 36 +++++++++++ server/config/uoEventActions.js | 89 ++++++++++++++++++--------- server/test/engagementSeeds.test.js | 20 ++++-- server/test/shardEngagement.test.js | 83 ++++++++++++++++++++++++- server/test/shardVisibility.test.js | 67 +++++++++++++++++++- server/test/uoEventActions.test.js | 59 +++++++++++++----- server/utils/shardEngagement.js | 47 ++++++++++++++ server/utils/shardVisibility.js | 26 +++++++- server/utils/uoLinkClient.js | 43 +++++++++++-- 10 files changed, 501 insertions(+), 64 deletions(-) diff --git a/server/config/engagementSeeds.js b/server/config/engagementSeeds.js index 7548123..8c06444 100644 --- a/server/config/engagementSeeds.js +++ b/server/config/engagementSeeds.js @@ -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 } diff --git a/server/config/shardTriggers.js b/server/config/shardTriggers.js index 5b73676..6d231d6 100644 --- a/server/config/shardTriggers.js +++ b/server/config/shardTriggers.js @@ -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', diff --git a/server/config/uoEventActions.js b/server/config/uoEventActions.js index c73c0a4..e4e7c3d 100644 --- a/server/config/uoEventActions.js +++ b/server/config/uoEventActions.js @@ -24,14 +24,31 @@ // // That is not a tuning detail. It is the whole of what makes rule 2 true. // -// **2. A broadcast is attempted exactly once.** `on_failure` is what happens -// AFTER `EVENT_STEP_MAX_ATTEMPTS` retries, and `skip` — the default for `notify` -// — is a disposition, not a retry policy; there is no per-action lever that says -// "do not retry me". The lever a module HAS is the failure envelope, so -// `uo.broadcast` answers `retry: false` to everything. A retried broadcast is a -// second announcement to everyone online, and there is no idempotency key on the -// wire until Phase 11 to make the shard refuse the repeat. A lost announcement is -// cheaper than a doubled one. +// **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" @@ -227,7 +244,7 @@ const ACTIONS = [ }, ], - async perform({ runId, params, verify }) { + 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. @@ -250,29 +267,25 @@ const ACTIONS = [ 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 } - // **Every failure is terminal, deliberately** — see rule 2 in the header. - // A 503 from a shard that is merely restarting IS transient and this throws - // that retry away; that is the trade, taken knowingly, because the failure - // this refuses to risk is announcing twice to everyone online. Phase 11 - // puts an idempotency key on the wire and this line is what changes. + // **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 clause is only added where a retry was genuinely given up**, and - // the rig is what made that distinction matter. A 403 — the shard's admin - // write plane switched off — 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 when what they need is the sentence the shard - // already wrote: "admin write plane disabled". A reason that explains the - // wrong thing is worse than a bare status code. - const reason = sidecarReason(result, 'broadcast') - if (PERMANENT_STATUSES.has(result.status)) return { ok: false, retry: false, error: reason } - return { - ok: false, - retry: false, - error: `${reason} (not retried: a repeat would announce twice)`, - } + // 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') }, }, @@ -329,7 +342,18 @@ const ACTIONS = [ const id = resourceId(idempotencyKey) const bootId = await currentBootId() - const result = await uoLinkClient.postTownCrier({ id, lines: parsed.lines, durationSec }) + // 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 @@ -435,6 +459,13 @@ const ACTIONS = [ 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') diff --git a/server/test/engagementSeeds.test.js b/server/test/engagementSeeds.test.js index a0f66ed..508fd41 100644 --- a/server/test/engagementSeeds.test.js +++ b/server/test/engagementSeeds.test.js @@ -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()) }) diff --git a/server/test/shardEngagement.test.js b/server/test/shardEngagement.test.js index 06aa935..b7f46fa 100644 --- a/server/test/shardEngagement.test.js +++ b/server/test/shardEngagement.test.js @@ -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', () => { diff --git a/server/test/shardVisibility.test.js b/server/test/shardVisibility.test.js index 771e874..a6e4dc5 100644 --- a/server/test/shardVisibility.test.js +++ b/server/test/shardVisibility.test.js @@ -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(), ) }) diff --git a/server/test/uoEventActions.test.js b/server/test/uoEventActions.test.js index 7fb22fe..cc838c6 100644 --- a/server/test/uoEventActions.test.js +++ b/server/test/uoEventActions.test.js @@ -133,30 +133,57 @@ test('a broadcast spends the one budget dimension the module declares', () => { assert.equal(byId('uo.news.post').cost, undefined) }) -// ── uo.broadcast: attempted exactly once ─────────────────────────────────── +// ── uo.broadcast: retried, because protocol 6 made that safe ─────────────── -test('a broadcast is never retried, whatever the sidecar says', async () => { +test('a broadcast is retried on a transient failure and never on a permanent one', async () => { const broadcast = byId('uo.broadcast') - // Every failure this transport can produce: no route to the sidecar, a data - // refusal, a bad token, a protocol mismatch, a shard that is not connected and - // a shard that timed out. The last two are genuinely transient, and this is - // the trade being taken knowingly — a lost announcement is cheaper than one - // delivered twice to everyone online. - for (const status of [0, 400, 401, 403, 409, 503, 504]) { + // 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, false, `a ${status} must not be retried`) - // The clause belongs only where a retry was genuinely given up. On a - // permanent status it would explain the wrong thing. - if (!actions.PERMANENT_STATUSES.has(status)) { - assert.match(result.error, /announce twice/, 'a discarded retry must say why') - } else { - assert.doesNotMatch(result.error, /announce twice/, `a ${status} was never retryable`) - } + 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 diff --git a/server/utils/shardEngagement.js b/server/utils/shardEngagement.js index 13914ee..783480e 100644 --- a/server/utils/shardEngagement.js +++ b/server/utils/shardEngagement.js @@ -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)) diff --git a/server/utils/shardVisibility.js b/server/utils/shardVisibility.js index 8f50510..de7c387 100644 --- a/server/utils/shardVisibility.js +++ b/server/utils/shardVisibility.js @@ -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', diff --git a/server/utils/uoLinkClient.js b/server/utils/uoLinkClient.js index 5233836..880b3df 100644 --- a/server/utils/uoLinkClient.js +++ b/server/utils/uoLinkClient.js @@ -11,6 +11,34 @@ // `X-UOLink-Version: ` 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') @@ -168,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) ───────────────────────────────────────────────── @@ -189,8 +220,8 @@ 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 } }) // ── Help-page (support) queue commands (§6) ──────────────────────────────── const respondPage = (pageId, { message, close }) => -- 2.49.1 From 88bfe9310eb1db044cbd1913b5aebf0bafef6e63 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Fri, 4 Sep 2026 19:31:57 -0500 Subject: [PATCH 4/8] feat(events): one lease and the participation verbs (Phase 11b) 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 --- server/config/uoEventActions.js | 362 +++++++++++++++++ server/index.js | 4 + server/test/_fakes.js | 4 + server/test/entry.test.js | 12 +- server/test/uoEventActions.test.js | 3 + server/test/uoEventLeaseParticipation.test.js | 382 ++++++++++++++++++ server/utils/shardVisibility.js | 8 + server/utils/uoLinkClient.js | 72 ++++ 8 files changed, 846 insertions(+), 1 deletion(-) create mode 100644 server/test/uoEventLeaseParticipation.test.js diff --git a/server/config/uoEventActions.js b/server/config/uoEventActions.js index e4e7c3d..7e78612 100644 --- a/server/config/uoEventActions.js +++ b/server/config/uoEventActions.js @@ -205,6 +205,61 @@ const BUDGETS = [ }, ] +// ── 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 = [ @@ -484,6 +539,195 @@ const ACTIONS = [ 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, + }, + })), + } + }, + }, ] /** @@ -512,6 +756,119 @@ async function reconcileByBootId({ resources }) { 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 @@ -586,6 +943,7 @@ const OPTION_SOURCES = [ 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 @@ -597,7 +955,11 @@ module.exports = { MAX_NEWS_TITLE, MAX_NEWS_BODY, MAX_OPTIONS, + MAX_AREA_RADIUS, + MAX_LEASE_MS, PERMANENT_STATUSES, + webUserId, + landmarkPoint, sidecarReason, resourceId, crierLines, diff --git a/server/index.js b/server/index.js index ec066e6..00e1c99 100644 --- a/server/index.js +++ b/server/index.js @@ -175,6 +175,10 @@ const engagementSeeds = require('./config/engagementSeeds') // 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) diff --git a/server/test/_fakes.js b/server/test/_fakes.js index ffcddd3..7b6364c 100644 --- a/server/test/_fakes.js +++ b/server/test/_fakes.js @@ -143,6 +143,10 @@ function fakeApi() { 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 }, } diff --git a/server/test/entry.test.js b/server/test/entry.test.js index 211b440..e0b838a 100644 --- a/server/test/entry.test.js +++ b/server/test/entry.test.js @@ -61,9 +61,19 @@ test('registers exactly what module.json declares', () => { // anywhere. assert.deepStrictEqual( api.record.eventActions.map((a) => a.id).sort(), - ['uo.broadcast', 'uo.news.post', 'uo.towncrier.post'], + [ + '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'], diff --git a/server/test/uoEventActions.test.js b/server/test/uoEventActions.test.js index cc838c6..50afe97 100644 --- a/server/test/uoEventActions.test.js +++ b/server/test/uoEventActions.test.js @@ -42,6 +42,9 @@ beforeEach(() => { 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(() => { diff --git a/server/test/uoEventLeaseParticipation.test.js b/server/test/uoEventLeaseParticipation.test.js new file mode 100644 index 0000000..8f34b64 --- /dev/null +++ b/server/test/uoEventLeaseParticipation.test.js @@ -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) +}) diff --git a/server/utils/shardVisibility.js b/server/utils/shardVisibility.js index de7c387..2357c09 100644 --- a/server/utils/shardVisibility.js +++ b/server/utils/shardVisibility.js @@ -246,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. }), ) diff --git a/server/utils/uoLinkClient.js b/server/utils/uoLinkClient.js index 880b3df..62263bc 100644 --- a/server/utils/uoLinkClient.js +++ b/server/utils/uoLinkClient.js @@ -223,6 +223,72 @@ const adminUnban = ({ actor, account }) => 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 }) => call(`/pages/${encodeURIComponent(pageId)}/respond`, { method: 'POST', body: { message, close } }) @@ -256,6 +322,12 @@ module.exports = { deleteTownCrier, postNews, deleteNews, + getLeases, + applyLease, + releaseLease, + openParticipation, + snapshotParticipation, + closeParticipation, adminKick, adminBan, adminUnban, -- 2.49.1 From 89be9d6a4e827448f0bd55c126aeb05e7f8a0e5a Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 7 Sep 2026 01:52:15 -0500 Subject: [PATCH 5/8] feat(events): the five world verbs an author sees (Phase 12a) `uo.creature.spawn`, `uo.boss.spawn`, `uo.npc.place`, `uo.gate.open` and `uo.decor.place`, over protocol 7's one command family. Five actions because five is what an author has; one `perform`/`revert`/`reconcile` because on the wire they are one thing. Five new budget dimensions -- `uo.creatures`, `uo.bosses`, `uo.npcs`, `uo.decor`, `uo.gate.minutes` -- all declared by THIS MODULE (org lead, 2026-09-07). Core meters whatever dimensions a module declares and holds no UO knowledge, which is the whole of what MODULE_API means by game-agnostic. A gate is priced in minutes rather than in gates: one standing all day and twelve standing five minutes each are not the same imposition on a world. `reconcile()` ASKS the shard, and is the one place in this file that must not use `reconcileByBootId`. A crier line lives in shard memory, so a changed `bootId` IS proof it is gone; a spawned creature is in the world SAVE and survives the restart the stamp would report it lost by. Anything `world.owned` does not list is gone -- safe only because the shard's registry and the objects it describes are written by the same save. Teardown reports `gone` as success and `refused` as failed. A creature a player killed is the point of having spawned it, and a run that ended `incomplete` because its event worked would be a report nobody could read. `refused` means the shard denies this run ever owned the serial, so nothing will delete it through this path and the row must land unresolved with a reason. The atlas gains a decoration index, parsed from the shard's own `Data/Decoration/**/*.cfg` -- 120 files, read RECURSIVELY because the real tree nests two deep and a flat read would index a fraction of it while looking like it worked. 313 distinct types. The decor verb resolves through it rather than passing a type name through, which keeps the verb to this shard's own decoration vocabulary AND fetches the item id: `Static` alone accounts for 5031 placements under 1992 different graphics, so a bare type name places the wrong thing. `PARSER_VERSION` -> 3, so an already-imported tree is re-read. Two things the build found in code that had already shipped: `uo.options.creatures` answered with the atlas SLUG -- unique, stable, and not something the shard can build, because a creature is constructed from a ServUO class name and `orc-brute` is not one. The atlas's `name` is the raw type token from the spawn files, so the fix was to stop discarding the half that works. Safe to change because Phase 12a is the source's first consumer; the file said so when it shipped. `uo.npc.place` could not be performed from its own required params. Both ends refuse an oracle with neither a greeting nor a line, but both fields were optional -- so a cross-field rule sat where no authoring form could render it. The greeting is now `required`, which says the same thing in the contract itself. Caught by the existing dry-run sweep, which is a better argument for that test than anything written about it when it shipped. 605 tests pass. `swagger-fragment.json` is stale on `edge` already and this phase adds no route, so it is left alone. Refs: docs/link/v7.md, docs/website/EVENTS_PLAN.md Phase 12a Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4 --- server/config/uoEventActions.js | 774 +++++++++++++++++++- server/db/schema.sql | 20 + server/model/shardAtlas/shardAtlas.db.js | 45 ++ server/model/shardAtlas/shardAtlas.model.js | 34 + server/test/entry.test.js | 25 +- server/test/spawnAtlas.parse.test.js | 47 ++ server/test/spawnAtlas.source.test.js | 57 ++ server/test/uoEventActions.test.js | 367 +++++++++- server/utils/spawnAtlasParse.js | 43 ++ server/utils/spawnAtlasSource.js | 58 +- server/utils/uoLinkClient.js | 33 + 11 files changed, 1484 insertions(+), 19 deletions(-) diff --git a/server/config/uoEventActions.js b/server/config/uoEventActions.js index 7e78612..eef19e1 100644 --- a/server/config/uoEventActions.js +++ b/server/config/uoEventActions.js @@ -1,8 +1,21 @@ -// ── module-uo's event verbs, wave 1 ──────────────────────────────────────── +// ── module-uo's event verbs ──────────────────────────────────────────────── // -// 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. +// EVENTS.md §F. Every action an event author can put in a step that reaches the +// game, the budget dimensions that bound them, the leases they may borrow, and +// the option sources the atlas answers. +// +// Three waves, and they are genuinely different kinds of thing: +// +// Phase 9 (wave 1) — `uo.broadcast`, `uo.towncrier.post`, `uo.news.post`. +// Announcements. Nothing in the world changes. +// Phase 11b — `uo.participation.*` and the one config lease. The +// shard watches, and the website borrows a value. +// Phase 12a (wave 2) — the five WORLD verbs: creatures, a boss, an oracle, a +// gate, decoration. Things appear, and this run owns them +// until teardown. See the section above `ACTIONS`. +// +// The header below is wave 1's, and its three rules still govern everything +// here — rule 1 in particular, which is why every action declares `budgetMs`. // // **Nothing here is new plumbing.** `uoLinkClient` has carried `adminBroadcast`, // `postTownCrier`/`deleteTownCrier` and `postNews`/`deleteNews` since protocol @@ -203,6 +216,49 @@ const BUDGETS = [ unit: 'broadcasts', description: 'System messages this run may put in front of everyone online.', }, + + // The world verbs (Phase 12a). These are the MODULE's dimensions, not core's + // (org lead, 2026-09-07): core meters whatever a module declares and holds no + // UO knowledge, which is the whole of what §F means by game-agnostic. Their + // defaults are the EM Program's published quotas. + // + // Every one of them is also bounded independently on the shard + // (`Bridge.EventsMax*`), which REFUSES rather than clamps. Two bounds is not + // belt and braces: an administrator raising a budget here is saying what a run + // may spend, and the operator's ceiling is saying what their world will take. + { + id: 'uo.creatures', + label: 'Creatures spawned', + unit: 'creatures', + description: 'Creatures this run may put into the world. Each one is deleted at teardown.', + }, + { + id: 'uo.bosses', + label: 'Bosses spawned', + unit: 'bosses', + description: 'Enhanced creatures this run may put into the world.', + }, + { + id: 'uo.npcs', + label: 'Oracle NPCs placed', + unit: 'NPCs', + description: 'Speaking NPCs this run may stand up at its venue.', + }, + { + id: 'uo.decor', + label: 'Decoration placed', + unit: 'items', + description: 'Scenery items this run may place. Immovable, and removed at teardown.', + }, + { + // A duration rather than a count, because one gate standing all day and + // twelve standing five minutes each are not the same imposition on a world, + // and a count would price them identically. + id: 'uo.gate.minutes', + label: 'Gate minutes', + unit: 'minutes', + description: 'Total minutes of temporary gate this run may open, across every gate.', + }, ] // ── Participation (protocol 6 part b, EVENTS_PLAN.md Phase 11b) ──────────── @@ -260,6 +316,264 @@ async function landmarkPoint(value) { return { ok: true, map: hit.facet, x: hit.x, y: hit.y } } +// ── The world verbs (protocol 7, EVENTS_PLAN.md Phase 12a) ────────────── +// +// Five verbs an author sees, and ONE command family underneath them, because +// each of them ends in the same sentence: an object exists, and this run owns +// it. So `perform`, `revert` and `reconcile` are written once here and the five +// declarations below differ only in what they validate and what they send. +// +// **`revert` and `reconcile` are shared but not interchangeable with the rest of +// this file's**, and the difference is the phase's headline. A crier line and a +// news article live in shard memory, so a changed `bootId` IS proof they are +// gone and `reconcileByBootId` can answer without asking. A spawned creature is +// in the world SAVE. It survives the restart the boot stamp would report it lost +// by, so the only honest answer is to ask the shard what it still holds — which +// is what `world.owned` is for, and why it prunes as it walks. + +/** The ledger kind every world verb files its serials under. */ +const OWNED_KIND = 'world' + +// Mirrors of the shard's own default ceilings (`Bridge.EventsMax*`), pre-checked +// here so an over-large step is a refusal a DRY RUN can show the author rather +// than a 400 arriving mid-run. The shard's are authoritative and an operator may +// set them lower, in which case its refusal is the one that lands \u2014 which is +// correct: these are a courtesy, not the bound. +const MAX_CREATURES = 30 +const MAX_BOSSES = 4 +const MAX_NPCS = 5 +const MAX_DECOR = 60 + +/** The widest scatter an author may ask for, mirroring the shard's own bound. */ +const MAX_SPREAD = 40 + +/** How much harder than a normal creature a boss may be made. */ +const MAX_BOSS_MULTIPLIER = 10 + +/** How many keyword lines one oracle answers to. */ +const MAX_ORACLE_LINES = 5 + +/** The longest a temporary gate may stand, in minutes. */ +const MAX_GATE_MINUTES = 240 + +/** Read a count param, or a refusal an author can act on. */ +function counted(raw, ceiling, what) { + const count = raw === undefined || raw === null || raw === '' ? 1 : Number(raw) + if (!Number.isInteger(count) || count < 1 || count > ceiling) { + return { ok: false, error: `place 1 to ${ceiling} ${what} at a time, and "${raw}" is not that` } + } + return { ok: true, count } +} + +/** Read an optional positive-integer param (a hue, a spread), or a refusal. */ +function optionalInt(raw, { max, name }) { + if (raw === undefined || raw === null || raw === '') return { ok: true, value: undefined } + const value = Number(raw) + if (!Number.isInteger(value) || value < 0 || (max !== undefined && value > max)) { + return { ok: false, error: `"${raw}" is not a ${name} this shard will take` } + } + return { ok: true, value } +} + +/** Read an optional multiplier, or a refusal. */ +function multiplier(raw, name) { + if (raw === undefined || raw === null || raw === '') return { ok: true, value: undefined } + const value = Number(raw) + if (!Number.isFinite(value) || value < 1 || value > MAX_BOSS_MULTIPLIER) { + return { + ok: false, + error: `a ${name} is 1 to ${MAX_BOSS_MULTIPLIER} times normal, and "${raw}" is not`, + } + } + return { ok: true, value } +} + +/** + * Parse an oracle's dialogue out of one textarea. + * + * One row per line, `keywords = what it says`, split on the FIRST `=` so the + * answer may contain one and the keywords may not: + * + * fire, flame = The flame you seek burns beneath the keep. + * gate = A gate will open at dusk, by the bank. + * + * A textarea rather than five pairs of fields because the action param types are + * scalars (`string`, `int`, `float`, `boolean`, `datetime`, `url`) and there is + * no array among them — and because §G's whole claim about this verb is that it + * is a web form. Ten numbered fields would be a worse one than a text box. + */ +function oracleLines(raw) { + const rows = [] + const text = raw === undefined || raw === null ? '' : String(raw) + + for (const line of text.split(/\r?\n/)) { + const trimmed = line.trim() + if (trimmed === '') continue + + const cut = trimmed.indexOf('=') + if (cut < 1) { + return { ok: false, error: `"${trimmed}" is not "keywords = what to say"` } + } + + const keywords = trimmed + .slice(0, cut) + .split(',') + .map((word) => word.trim()) + .filter(Boolean) + const say = trimmed.slice(cut + 1).trim() + + if (!keywords.length || !say) { + return { ok: false, error: `"${trimmed}" needs both a keyword and something to say` } + } + rows.push({ keywords: keywords.join(','), text: say }) + } + + if (rows.length > MAX_ORACLE_LINES) { + return { + ok: false, + error: `an oracle answers to at most ${MAX_ORACLE_LINES} things, and this gives ${rows.length}`, + } + } + return { ok: true, rows } +} + +/** + * Place something, and file every serial the shard hands back. + * + * One resource per SERIAL rather than one per call, so a group half of which a + * player killed reconciles per creature instead of all-or-nothing. The payload + * carries what it was, because a ledger row reading "this run owned something + * and it is gone" is worth less to an operator than one naming the orc. + */ +async function placeOwned({ runId, idempotencyKey, what, body }) { + const result = await uoLinkClient.spawnWorld({ + runId: String(runId), + what, + idempotencyKey, + ...body, + }) + if (!result.ok) return sidecarFailure(result, `${what} placement`) + + const serials = Array.isArray(result.data && result.data.serials) ? result.data.serials : [] + return { + ok: true, + resources: serials.map((serial) => ({ + kind: OWNED_KIND, + ref: String(serial), + payload: { runId: String(runId), what, type: body.type || what, name: body.name || null }, + })), + } +} + +/** + * Give back what this step placed. + * + * `gone` is not reported at all, deliberately: a creature a player killed is the + * point of having spawned it, and §L already says "gone, and that is fine" is a + * successful revert. `refused` IS reported, as `failed`, because it means the + * shard denies this run ever owned that serial — nothing will ever delete it + * through this path, so the row must land unresolved with a reason rather than + * be quietly marked reverted. + */ +async function revertOwned({ runId, resources, idempotencyKey }) { + const result = await uoLinkClient.despawnWorld({ + runId: String(runId), + serials: resources.map((resource) => resource.ref), + idempotencyKey, + }) + if (!result.ok) return { ok: false, error: sidecarReason(result, 'despawn') } + + const refused = Array.isArray(result.data && result.data.refused) + ? result.data.refused.map(String) + : [] + return refused.length ? { ok: true, failed: refused } : { ok: true } +} + +/** + * Ask the shard what this run still owns. + * + * NOT `reconcileByBootId`. See the section header: these resources are in the + * world save and survive the restart the boot stamp would report them lost by. + * + * Anything the shard does not list is gone, and that is a safe reading only + * because the registry and the objects it describes are written by the SAME + * world save — they cannot get out of step with each other. An unreachable or + * refusing shard has said nothing, so the whole group is left alone. + */ +async function reconcileOwned({ runId, resources }) { + const result = await uoLinkClient.ownedWorld({ runId: String(runId) }) + if (!result.ok) return { ok: false, error: sidecarReason(result, 'owned') } + + const rows = Array.isArray(result.data && result.data.owned) ? result.data.owned : [] + const held = new Set(rows.map((row) => String(row.serial))) + return { ok: true, inForce: resources.filter((r) => held.has(r.ref)).map((r) => r.ref) } +} + +/** The three fields every world verb shares, so five declarations cannot drift apart. */ +const OWNED_COMMON = { + // Something appears in the world where there was nothing. §K's default-off + // line falls between `inspect` and `change`, so an operator switches these on + // deliberately — which is the right consent for a scheduled, unattended + // change to a live world. + risk: 'change', + reversible: 'ledger', + version: 1, + budgetMs: BUDGET_MS, + revert: revertOwned, + reconcile: reconcileOwned, +} + +/** + * The body creatures and bosses share: a resolved place, a validated type, and + * the optional dressing. The boss verb adds its multipliers on top. + * + * The creature is named by its ServUO TYPE, which is what + * `uo.options.creatures` now answers with \u2014 see the option source. The atlas + * slug would be unusable here: the shard constructs from a class name, and a + * value an author picks that the shard cannot act on is not a value. + */ +async function creatureBody(params, ceiling) { + const place = await landmarkPoint(params.place) + if (!place.ok) return { ok: false, error: place.error } + + const howMany = counted(params.count, ceiling, 'creatures') + if (!howMany.ok) return { ok: false, error: howMany.error } + + const type = String(params.creature || '').trim() + if (!type) return { ok: false, error: 'pick a creature' } + + const hue = optionalInt(params.hue, { name: 'colour' }) + if (!hue.ok) return { ok: false, error: hue.error } + + const spread = optionalInt(params.spread, { max: MAX_SPREAD, name: 'spread' }) + if (!spread.ok) return { ok: false, error: spread.error } + + return { + ok: true, + value: { + map: place.map, + x: place.x, + y: place.y, + count: howMany.count, + type, + name: String(params.name || '').trim() || undefined, + hue: hue.value, + spread: spread.value, + }, + } +} + +/** The `place` param, shared by every verb: where in the world this happens. */ +const PLACE_PARAM = { + name: 'place', + type: 'string', + required: true, + example: 'Felucca/Britain', + source: 'uo.options.landmarks', + description: 'Where this happens.', +} + + // ── Actions ──────────────────────────────────────────────────────────────── const ACTIONS = [ @@ -671,7 +985,6 @@ const ACTIONS = [ return { ok: true, inForce } }, }, - { id: 'uo.participation.collect', label: 'Record who took part', @@ -728,6 +1041,416 @@ const ACTIONS = [ } }, }, + + { + id: 'uo.creature.spawn', + label: 'Spawn creatures', + description: + "Puts creatures into the world at a place you choose, optionally renamed and recoloured. Each one is recorded against this run and deleted at teardown \u2014 and a creature players kill in the meantime is an ordinary outcome, not a failure.", + + ...OWNED_COMMON, + cost: (p) => ({ 'uo.creatures': Number(p.count) || 1 }), + + params: [ + PLACE_PARAM, + { + name: 'creature', + type: 'string', + required: true, + example: 'Orc', + source: 'uo.options.creatures', + description: "Which creature. The list is what this shard's own spawners actually use.", + }, + { name: 'count', type: 'int', required: true, example: 8, description: 'How many.' }, + { + name: 'name', + type: 'string', + required: false, + example: 'Rotting Orc', + description: 'What they are called. Left out, the creature keeps its own name.', + }, + { + name: 'hue', + type: 'int', + required: false, + example: 1157, + description: 'UO colour id. Left out, the creature keeps its own colour.', + }, + { + name: 'spread', + type: 'int', + required: false, + example: 6, + description: `How many tiles to scatter them across, up to ${MAX_SPREAD}. Left out, they arrive on one tile.`, + }, + ], + + async perform({ runId, idempotencyKey, params, verify }) { + const body = await creatureBody(params, MAX_CREATURES) + if (!body.ok) return { ok: false, retry: false, error: body.error } + if (verify) return { ok: true } + return placeOwned({ runId, idempotencyKey, what: 'creature', body: body.value }) + }, + }, + + { + id: 'uo.boss.spawn', + label: 'Spawn a boss', + description: + "An ordinary creature made harder and given a name \u2014 EVENTS.md's \"enhanced regular mob\". The event owns what it created and never touches a creature it did not; there is no verb here that reaches an existing boss.", + + ...OWNED_COMMON, + cost: (p) => ({ 'uo.bosses': Number(p.count) || 1 }), + + params: [ + PLACE_PARAM, + { + name: 'creature', + type: 'string', + required: true, + example: 'OrcCaptain', + source: 'uo.options.creatures', + description: 'Which creature to build it from.', + }, + { + name: 'name', + type: 'string', + required: true, + example: 'Gruk the Unbroken', + description: 'What it is called. Required here \u2014 an unnamed boss is just a hard orc.', + }, + { name: 'count', type: 'int', required: false, example: 1, description: 'How many.' }, + { + name: 'hitsMultiplier', + type: 'float', + required: false, + example: 3, + description: `How much tougher than normal, up to ${MAX_BOSS_MULTIPLIER}.`, + }, + { + name: 'damageMultiplier', + type: 'float', + required: false, + example: 1.5, + description: `How much harder it hits, up to ${MAX_BOSS_MULTIPLIER}.`, + }, + { + name: 'statMultiplier', + type: 'float', + required: false, + example: 2, + description: `How much its strength, dexterity and intelligence are raised, up to ${MAX_BOSS_MULTIPLIER}.`, + }, + { name: 'hue', type: 'int', required: false, example: 1175, description: 'UO colour id.' }, + ], + + async perform({ runId, idempotencyKey, params, verify }) { + const body = await creatureBody(params, MAX_BOSSES) + if (!body.ok) return { ok: false, retry: false, error: body.error } + + if (!String(params.name || '').trim()) { + return { ok: false, retry: false, error: 'a boss needs a name' } + } + + for (const field of ['hitsMultiplier', 'damageMultiplier', 'statMultiplier']) { + const parsed = multiplier(params[field], field.replace('Multiplier', ' multiplier')) + if (!parsed.ok) return { ok: false, retry: false, error: parsed.error } + if (parsed.value !== undefined) body.value[field] = parsed.value + } + + if (verify) return { ok: true } + return placeOwned({ runId, idempotencyKey, what: 'boss', body: body.value }) + }, + }, + + { + id: 'uo.npc.place', + label: 'Stand up an oracle', + description: + 'A speaking NPC that greets players who come near and answers to words you choose. It cannot be killed, looted or moved, so it is still where this run left it when teardown comes to collect it.', + + ...OWNED_COMMON, + cost: (p) => ({ 'uo.npcs': Number(p.count) || 1 }), + + params: [ + PLACE_PARAM, + { + name: 'name', + type: 'string', + required: true, + example: 'Marisa the Seer', + description: 'What it is called.', + }, + { + name: 'title', + type: 'string', + required: false, + example: 'the seer', + description: 'A title shown under the name.', + }, + { + // **Required, and it was optional until the dry-run sweep caught it.** + // An oracle with neither a greeting nor a line stands there in silence, + // which both ends refuse — so with both fields optional the verb could + // not be performed from its own required params, and no authoring form + // could render it as valid either. A cross-field "at least one of these" + // rule is the wrong shape for a declaration core reads as data; making + // the greeting required says the same thing in the contract itself. + name: 'greeting', + type: 'string', + required: true, + example: 'You have the look of someone with a question.', + description: 'Said once to each player who comes near.', + }, + { + name: 'lines', + type: 'string', + required: false, + example: 'fire, flame = The flame you seek burns beneath the keep.', + description: `One per line, "keywords = what to say", up to ${MAX_ORACLE_LINES}. Keywords are separated by commas and matched anywhere in what a player says.`, + }, + { + name: 'sex', + type: 'string', + required: false, + example: 'female', + description: '"female" or "male". Left out, male.', + }, + { name: 'count', type: 'int', required: false, example: 1, description: 'How many.' }, + { name: 'hue', type: 'int', required: false, example: 1002, description: 'Skin colour id.' }, + ], + + async perform({ runId, idempotencyKey, params, verify }) { + const place = await landmarkPoint(params.place) + if (!place.ok) return { ok: false, retry: false, error: place.error } + + const howMany = counted(params.count, MAX_NPCS, 'oracles') + if (!howMany.ok) return { ok: false, retry: false, error: howMany.error } + + const name = String(params.name || '').trim() + if (!name) return { ok: false, retry: false, error: 'an oracle needs a name' } + + const lines = oracleLines(params.lines) + if (!lines.ok) return { ok: false, retry: false, error: lines.error } + + const greeting = String(params.greeting || '').trim() + // Refused HERE as well as on the shard, because this is the one the author + // can act on: a dry run says so instead of the step failing mid-run + // against a rule nobody had seen. `required` catches an absent field; + // this catches a field holding nothing but spaces. + if (!greeting) { + return { + ok: false, + retry: false, + error: 'an oracle with nothing to say would stand there in silence', + } + } + + const hue = optionalInt(params.hue, { name: 'colour' }) + if (!hue.ok) return { ok: false, retry: false, error: hue.error } + + if (verify) return { ok: true } + + return placeOwned({ + runId, + idempotencyKey, + what: 'npc', + body: { + map: place.map, + x: place.x, + y: place.y, + count: howMany.count, + name, + title: String(params.title || '').trim() || undefined, + sex: String(params.sex || '').trim().toLowerCase() === 'female' ? 'female' : undefined, + hue: hue.value, + greeting, + lines: lines.rows, + }, + }) + }, + }, + + { + id: 'uo.gate.open', + label: 'Open a gate', + description: + 'A moongate from one place to another, for a bounded time. The shard closes it when the time is up whether or not the website is ever heard from again, so a run whose engine dies leaves a world that comes back early rather than one stuck open.', + + ...OWNED_COMMON, + cost: (p) => ({ 'uo.gate.minutes': Number(p.durationMinutes) || 0 }), + + params: [ + { ...PLACE_PARAM, description: 'Where the gate stands.' }, + { + name: 'destination', + type: 'string', + required: true, + example: 'Felucca/Yew', + source: 'uo.options.landmarks', + description: 'Where it leads.', + }, + { + name: 'durationMinutes', + type: 'int', + required: true, + example: 120, + description: `How long it stands, up to ${MAX_GATE_MINUTES} minutes.`, + }, + { + name: 'name', + type: 'string', + required: false, + example: 'to the gathering', + description: 'What it is called when a player looks at it.', + }, + { name: 'hue', type: 'int', required: false, example: 1153, description: 'UO colour id.' }, + ], + + async perform({ runId, idempotencyKey, params, verify }) { + const place = await landmarkPoint(params.place) + if (!place.ok) return { ok: false, retry: false, error: place.error } + + const target = await landmarkPoint(params.destination) + if (!target.ok) return { ok: false, retry: false, error: target.error } + + const minutes = Number(params.durationMinutes) + if (!Number.isInteger(minutes) || minutes < 1 || minutes > MAX_GATE_MINUTES) { + return { + ok: false, + retry: false, + error: `a gate stands 1 to ${MAX_GATE_MINUTES} minutes, and "${params.durationMinutes}" is not that`, + } + } + + const hue = optionalInt(params.hue, { name: 'colour' }) + if (!hue.ok) return { ok: false, retry: false, error: hue.error } + + if (verify) return { ok: true } + + return placeOwned({ + runId, + idempotencyKey, + what: 'gate', + body: { + map: place.map, + x: place.x, + y: place.y, + // A duration, never an absolute time. An absolute deadline computed + // here and honoured there is measured against two clocks, and a shard + // ten minutes fast would collect the gate the instant it opened \u2014 + // the same argument protocol 6 made for a lease's `holdMs`. + holdMs: minutes * 60_000, + name: String(params.name || '').trim() || undefined, + hue: hue.value, + target: { map: target.map, x: target.x, y: target.y }, + }, + }) + }, + }, + + { + id: 'uo.decor.place', + label: 'Place decoration', + description: + "Scenery for the venue, from what this shard already calls decoration. Placed immovable so it is still there at teardown, and removed then. Containers are refused: deleting one would delete whatever a player had left inside it.", + + ...OWNED_COMMON, + cost: (p) => ({ 'uo.decor': Number(p.count) || 1 }), + + params: [ + PLACE_PARAM, + { + name: 'item', + type: 'string', + required: true, + example: 'Brazier', + source: 'uo.options.decor', + description: "Which item. The list comes from this shard's own decoration files.", + }, + { name: 'count', type: 'int', required: true, example: 6, description: 'How many.' }, + { + name: 'hue', + type: 'int', + required: false, + example: 1157, + description: 'UO colour id. Left out, the item keeps its own colour.', + }, + { + name: 'spread', + type: 'int', + required: false, + example: 4, + description: `How many tiles to scatter them across, up to ${MAX_SPREAD}.`, + }, + { + name: 'name', + type: 'string', + required: false, + example: 'a festival brazier', + description: 'What it is called when a player looks at it.', + }, + ], + + async perform({ runId, idempotencyKey, params, verify }) { + const place = await landmarkPoint(params.place) + if (!place.ok) return { ok: false, retry: false, error: place.error } + + const howMany = counted(params.count, MAX_DECOR, 'items') + if (!howMany.ok) return { ok: false, retry: false, error: howMany.error } + + const type = String(params.item || '').trim() + if (!type) return { ok: false, retry: false, error: 'pick something to place' } + + // Resolved through the atlas rather than passed straight through, which + // does two things at once. + // + // It keeps the verb to the vocabulary this shard's own decoration files + // use \u2014 a tighter boundary than "any item that is not a container", and + // the one the decision actually took. + // + // And it fetches the ITEM ID, which some types cannot do without. Measured + // on ServUO 57.4: `Static` accounts for 5031 of the tree's decoration + // placements under **1992 different graphics**, because for that class the + // graphic IS the identity \u2014 a bare `new Static()` is not the switch or the + // paving stone the author picked, it is whatever the class defaults to. + // 131 of the 313 types carry more than one id (a door has one per facing). + const known = await shardAtlas.getDecorType(type) + if (!known) { + return { + ok: false, + retry: false, + error: `this shard's decoration files never mention "${type}"`, + } + } + + const hue = optionalInt(params.hue, { name: 'colour' }) + if (!hue.ok) return { ok: false, retry: false, error: hue.error } + + const spread = optionalInt(params.spread, { max: MAX_SPREAD, name: 'spread' }) + if (!spread.ok) return { ok: false, retry: false, error: spread.error } + + if (verify) return { ok: true } + + return placeOwned({ + runId, + idempotencyKey, + what: 'decor', + body: { + map: place.map, + x: place.x, + y: place.y, + count: howMany.count, + type: known.type, + itemId: known.itemId || undefined, + hue: hue.value, + spread: spread.value, + name: String(params.name || '').trim() || undefined, + }, + }) + }, + }, + ] /** @@ -932,10 +1655,33 @@ const OPTION_SOURCES = [ 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. + // **The value is the ServUO TYPE NAME, not the atlas slug** (Phase 12a). + // + // Wave 1 declared this source before anything consumed it and used the + // slug, which is unique and stable and cannot be acted on: the shard + // constructs a creature from a class name, and `orccaptain` is not one. + // The atlas's `name` IS the type token, preserved verbatim from the spawn + // files (`displayName` picks the best-attested spelling of the raw token), + // so no lookup table is needed \u2014 only the decision to stop throwing the + // usable half away. + // + // Safe to change because Phase 12a is this source's first consumer; the + // file said so when it shipped. const { creatures } = await shardAtlas.searchCreatures({ limit: MAX_OPTIONS }) - return creatures.map((c) => ({ value: c.slug, label: c.name })) + return creatures.map((c) => ({ value: c.name, label: c.name })) + }, + }, + + { + id: 'uo.options.decor', + label: 'Decoration', + description: "Item types this shard already uses as scenery, most-used first.", + async resolve() { + // From `Data/Decoration/**/*.cfg` at atlas-import time, so this is the + // operator's own decoration vocabulary rather than a list curated by us \u2014 + // and, like every source here, it resolves with the shard down. + const rows = await shardAtlas.listDecorTypes() + return bounded(rows, 'uo.options.decor').map((r) => ({ value: r.type, label: r.type })) }, }, ] @@ -956,6 +1702,18 @@ module.exports = { MAX_NEWS_BODY, MAX_OPTIONS, MAX_AREA_RADIUS, + MAX_CREATURES, + MAX_BOSSES, + MAX_NPCS, + MAX_DECOR, + MAX_SPREAD, + MAX_BOSS_MULTIPLIER, + MAX_ORACLE_LINES, + MAX_GATE_MINUTES, + OWNED_KIND, + oracleLines, + revertOwned, + reconcileOwned, MAX_LEASE_MS, PERMANENT_STATUSES, webUserId, diff --git a/server/db/schema.sql b/server/db/schema.sql index e86ca58..0fb9a16 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -544,6 +544,26 @@ CREATE TABLE IF NOT EXISTS shard_landmarks ( INDEX idx_shard_landmarks_name (name) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +-- Item types this shard uses as decoration, from Data/Decoration/**/*.cfg. +-- +-- Import-owned like every other shard_* atlas table. It exists so the events +-- decoration verb can offer an author a dropdown of what THIS shard already +-- calls scenery, rather than a list of item types curated by us: a shard with +-- custom decoration gets its own, and the list resolves with the shard offline +-- because it came out of the tree at import time. +-- +-- `item_id` is a preview, not an identity. A type appears under as many item +-- ids as it has facings or variants (a BarredMetalDoor under eight), and the +-- first one seen is kept; the plugin constructs from the TYPE NAME and picks +-- its own graphic. `uses` is how many times the shard's own decoration reaches +-- for the type, which is the only ordering signal available that means anything. +CREATE TABLE IF NOT EXISTS shard_decor_types ( + type VARCHAR(120) NOT NULL PRIMARY KEY, + item_id INT NOT NULL DEFAULT 0, + uses INT NOT NULL DEFAULT 0, + INDEX idx_shard_decor_types_uses (uses) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + -- Configured champion altars from Config/ChampionSpawns.xml. This is static -- roster data ("there is an Unholy Terror altar in Deceit") and is distinct from -- the live champ.update feed in shard_champs ("it is on level 3 right now"). diff --git a/server/model/shardAtlas/shardAtlas.db.js b/server/model/shardAtlas/shardAtlas.db.js index c3db3e5..0380d10 100644 --- a/server/model/shardAtlas/shardAtlas.db.js +++ b/server/model/shardAtlas/shardAtlas.db.js @@ -16,6 +16,7 @@ const ATLAS_TABLES = [ 'shard_regions', 'shard_landmarks', 'shard_champion_spawns', + 'shard_decor_types', ] async function insertBatched(conn, sql, rows) { @@ -103,6 +104,16 @@ async function replaceAtlas(atlas, art = {}) { ]), ) + // Optional: a tree with no Data/Decoration leaves this empty rather than + // failing the import, and the decoration verb then simply has nothing to + // offer. `?? []` rather than a guard, so an atlas built by an older parser + // (no `decor` key at all) reloads cleanly instead of throwing here. + counts.decor = await insertBatched( + conn, + 'INSERT INTO shard_decor_types (type, item_id, uses) VALUES (?,?,?)', + (atlas.decor ?? []).map((d) => [d.type, d.itemId ?? 0, d.uses ?? 0]), + ) + // Point ids are assigned explicitly rather than left to AUTO_INCREMENT: the // join rows need to know them and `conn.batch()` reports no usable insertId // for a multi-row insert. Safe because this transaction just emptied the @@ -357,6 +368,38 @@ function listLandmarks({ facet = '', q = '' } = {}) { ) } +/** + * Every decoration type this shard uses, most-used first. + * + * Ordered by `uses` because a dropdown of 313 types needs the ones the shard + * actually reaches for at the top; the alphabetical tiebreak keeps the order + * stable across imports, which matters for a form an author scrolls. + */ +function listDecorTypes({ q = '' } = {}) { + const where = [] + const params = [] + if (q) { + where.push('type LIKE ?') + params.push(`%${q}%`) + } + return query( + `SELECT type, item_id, uses + FROM shard_decor_types + ${where.length ? `WHERE ${where.join(' AND ')}` : ''} + ORDER BY uses DESC, type ASC`, + params, + ) +} + +/** One decoration type, or nothing when this shard's files never name it. */ +async function getDecorType(type) { + const rows = await query( + 'SELECT type, item_id, uses FROM shard_decor_types WHERE type = ?', + [type], + ) + return rows[0] || null +} + function listChampions({ facet = '' } = {}) { const params = [] let where = '' @@ -388,5 +431,7 @@ module.exports = { listCreatureCompanions, listRegions, listLandmarks, + listDecorTypes, + getDecorType, listChampions, } diff --git a/server/model/shardAtlas/shardAtlas.model.js b/server/model/shardAtlas/shardAtlas.model.js index 083edd1..20423cd 100644 --- a/server/model/shardAtlas/shardAtlas.model.js +++ b/server/model/shardAtlas/shardAtlas.model.js @@ -403,6 +403,38 @@ async function getCreature(slug, { facet = '', points = 200 } = {}) { } } +/** + * Decoration types, shaped for a dropdown. + * + * `type` is both the value and the label: it is the ServUO class name and it is + * what the plugin constructs from, so showing the author anything else would + * put a name on the screen that does not appear in the refusal if the shard + * declines it. + */ +async function listDecorTypes(opts = {}) { + const rows = await db.listDecorTypes(opts) + return rows.map((r) => ({ + type: r.type, + itemId: Number(r.item_id) || 0, + uses: Number(r.uses) || 0, + })) +} + +/** + * One decoration type, or null. + * + * The events decoration verb resolves through this rather than passing a type + * name straight through, which does two things at once: it fetches the item id + * the graphic-holder classes need, and it keeps the verb to the vocabulary this + * shard's own decoration files use. A type the atlas has never seen is refused + * here rather than constructed there. + */ +async function getDecorType(type) { + const row = await db.getDecorType(String(type == null ? '' : type).trim()) + if (!row) return null + return { type: row.type, itemId: Number(row.item_id) || 0, uses: Number(row.uses) || 0 } +} + async function listRegions(opts = {}) { const rows = await db.listRegions(opts) return rows.map((r) => ({ @@ -485,6 +517,8 @@ module.exports = { getCreature, listRegions, listLandmarks, + listDecorTypes, + getDecorType, listChampions, listFacets, publicMeta, diff --git a/server/test/entry.test.js b/server/test/entry.test.js index e0b838a..7efa3f3 100644 --- a/server/test/entry.test.js +++ b/server/test/entry.test.js @@ -62,21 +62,42 @@ test('registers exactly what module.json declares', () => { assert.deepStrictEqual( api.record.eventActions.map((a) => a.id).sort(), [ + 'uo.boss.spawn', 'uo.broadcast', + 'uo.creature.spawn', + 'uo.decor.place', + 'uo.gate.open', 'uo.news.post', + 'uo.npc.place', 'uo.participation.collect', 'uo.participation.open', 'uo.towncrier.post', ], ) - assert.deepStrictEqual(api.record.eventBudgets.map((b) => b.id), ['uo.broadcasts']) + // Phase 12a's five are all the MODULE's dimensions, never core's (org lead, + // 2026-09-07): core meters whatever a module declares and knows nothing about + // Ultima Online. Asserted as an ordered list because the order is the order + // an author meets them in a cap meter. + assert.deepStrictEqual(api.record.eventBudgets.map((b) => b.id), [ + 'uo.broadcasts', + 'uo.creatures', + 'uo.bosses', + 'uo.npcs', + 'uo.decor', + 'uo.gate.minutes', + ]) // 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'], + [ + 'uo.options.creatures', + 'uo.options.decor', + 'uo.options.landmarks', + 'uo.options.regions', + ], ) assert.ok(api.record.streams.length > 0) assert.strictEqual(typeof api.record.hooks.onBoot, 'function') diff --git a/server/test/spawnAtlas.parse.test.js b/server/test/spawnAtlas.parse.test.js index 76d4bbc..ffe3de6 100644 --- a/server/test/spawnAtlas.parse.test.js +++ b/server/test/spawnAtlas.parse.test.js @@ -14,6 +14,7 @@ const { buildFacetIndex, resolveFacetName, slugify, + parseDecoration, decodeEntities, } = require('../utils/spawnAtlasParse') @@ -599,3 +600,49 @@ test('parsePoints: DelayInSec decides the unit, and both come out in seconds', ( assert.equal(seconds.minDelay, 5) assert.equal(seconds.maxDelay, 10) }) + +// ── parseDecoration (Phase 12a) ─────────────────────────────── + +test('parseDecoration: reads the type off each header and ignores the placements', () => { + const rows = parseDecoration(`# switch +Static 0x108F +5552 1864 11 +5399 1875 17 + +# crate +LargeCrate 0x0E3C +5408 607 45 +`) + assert.deepEqual(rows, [ + { type: 'Static', itemId: 0x108f }, + { type: 'LargeCrate', itemId: 0x0e3c }, + ]) +}) + +test('parseDecoration: a parenthesised property list is not part of the type', () => { + // These are the shard's own decoration details — which way a door faces, what + // hue a banner is — and an event author is choosing neither. Only the class + // name is, because that is what the plugin constructs from. + assert.deepEqual(parseDecoration('AnkhNorth 0x0004 (Hue=0x47E)'), [ + { type: 'AnkhNorth', itemId: 4 }, + ]) + assert.deepEqual(parseDecoration('ArmsAndWeaponsPrimer 0x0FEF (Name=a life of travel)'), [ + { type: 'ArmsAndWeaponsPrimer', itemId: 0x0fef }, + ]) +}) + +test('parseDecoration: a negative z on a placement line is not mistaken for a type', () => { + // The real trap in this format: a coordinate line starts with a digit OR a + // minus, so "not a comment" is not the test. A z of -12 is ordinary in every + // dungeon file in the tree. + assert.deepEqual(parseDecoration(`Static 0x07A4 +5558 1826 -12 +-5 -5 -5 +`), [{ type: 'Static', itemId: 0x07a4 }]) +}) + +test('parseDecoration: empty, comment-only and absent input all yield nothing', () => { + assert.deepEqual(parseDecoration(''), []) + assert.deepEqual(parseDecoration(null), []) + assert.deepEqual(parseDecoration('# nothing but a comment\n\n'), []) +}) diff --git a/server/test/spawnAtlas.source.test.js b/server/test/spawnAtlas.source.test.js index 6e56b8e..b22bdd3 100644 --- a/server/test/spawnAtlas.source.test.js +++ b/server/test/spawnAtlas.source.test.js @@ -39,6 +39,21 @@ function writeTree(root, { facets = ['Sosaria'], includeChampions = true } = {}) fs.mkdirSync(path.join(root, 'Data', 'Locations'), { recursive: true }) fs.mkdirSync(path.join(root, 'Config'), { recursive: true }) + // Decoration, NESTED, because the real tree nests two deep in places + // (`Magincia/Trammel`, `Stygian Abyss/Ter Mur`) and a flat read would index a + // fraction of it while looking like it worked. + fs.mkdirSync(path.join(root, 'Data', 'Decoration', 'Deep', 'Deeper'), { recursive: true }) + fs.writeFileSync( + path.join(root, 'Data', 'Decoration', 'top.cfg'), + '# a brazier\nBrazier 0x0E31\n100 100 0\n200 200 -5\n\nStatic 0x108F\n300 300 0\n', + 'utf8', + ) + fs.writeFileSync( + path.join(root, 'Data', 'Decoration', 'Deep', 'Deeper', 'nested.cfg'), + 'Brazier 0x0E31\n400 400 0\nLargeCrate 0x0E3C\n500 500 0\n', + 'utf8', + ) + for (const facet of facets) { fs.writeFileSync( path.join(root, 'Spawns', `${facet}.xml`), @@ -397,3 +412,45 @@ test('refresh: an explicit path overrides the configured one', async () => { assert.equal(result.status, 'imported') assert.deepEqual(result.addedFacets, ['Override']) }) + +// ── The decoration index (Phase 12a) ──────────────────────── + +test('decoration is read recursively and rolled up per type', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'atlas-decor-')) + try { + writeTree(root) + const atlas = buildAtlas(root) + + // Sorted by type, and `uses` counts every header line across the whole tree + // — the nested file's Brazier is the second use of the same type, not a + // second type. + assert.deepEqual(atlas.decor, [ + { type: 'Brazier', itemId: 0x0e31, uses: 2 }, + { type: 'LargeCrate', itemId: 0x0e3c, uses: 1 }, + { type: 'Static', itemId: 0x108f, uses: 1 }, + ]) + assert.equal(atlas.meta.counts.decor, 3) + + // Every decoration file is fingerprinted like every other source, so an + // operator editing one is a tree change the boot path notices. + const labels = Object.keys(atlas.meta.source).filter((l) => l.startsWith('Data/Decoration/')) + assert.deepEqual(labels.sort(), ['Data/Decoration/Deep/Deeper/nested.cfg', 'Data/Decoration/top.cfg']) + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } +}) + +test('a tree with no decoration at all still builds', () => { + // Optional, like the champion file. A shard that has stripped its decoration + // has a perfectly good atlas; the decoration verb simply has nothing to offer. + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'atlas-nodecor-')) + try { + writeTree(root) + fs.rmSync(path.join(root, 'Data', 'Decoration'), { recursive: true, force: true }) + const atlas = buildAtlas(root) + assert.deepEqual(atlas.decor, []) + assert.equal(atlas.meta.counts.decor, 0) + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } +}) diff --git a/server/test/uoEventActions.test.js b/server/test/uoEventActions.test.js index 50afe97..fbd3b9a 100644 --- a/server/test/uoEventActions.test.js +++ b/server/test/uoEventActions.test.js @@ -27,24 +27,60 @@ let calls const saved = {} beforeEach(() => { - calls = { broadcast: [], crier: [], crierDel: [], news: [], newsDel: [] } - for (const name of ['adminBroadcast', 'postTownCrier', 'deleteTownCrier', 'postNews', 'deleteNews']) { + calls = { + broadcast: [], crier: [], crierDel: [], news: [], newsDel: [], + spawn: [], despawn: [], owned: [], + } + for (const name of [ + 'adminBroadcast', 'postTownCrier', 'deleteTownCrier', 'postNews', 'deleteNews', + 'spawnWorld', 'ownedWorld', 'despawnWorld', + ]) { saved[name] = uoLinkClient[name] } saved.getSafe = uoLinkConfig.getSafe saved.listRegions = shardAtlas.listRegions saved.listLandmarks = shardAtlas.listLandmarks saved.searchCreatures = shardAtlas.searchCreatures + saved.listDecorTypes = shardAtlas.listDecorTypes + saved.getDecorType = shardAtlas.getDecorType uoLinkClient.adminBroadcast = async (b) => { calls.broadcast.push(b); return { ok: true, status: 200 } } uoLinkClient.postTownCrier = async (b) => { calls.crier.push(b); return { ok: true, status: 200 } } uoLinkClient.deleteTownCrier = async (id) => { calls.crierDel.push(id); return { ok: true, status: 200 } } uoLinkClient.postNews = async (b) => { calls.news.push(b); return { ok: true, status: 200 } } uoLinkClient.deleteNews = async (id) => { calls.newsDel.push(id); return { ok: true, status: 200 } } + // Phase 12a. Two serials back by default, so a spawn produces a resource list + // longer than one and the per-serial ledger shape is what the suite exercises. + uoLinkClient.spawnWorld = async (b) => { + calls.spawn.push(b) + const n = b.count || 1 + return { + ok: true, + status: 200, + data: { serials: Array.from({ length: n }, (_, i) => `0x4000000${i}`) }, + } + } + uoLinkClient.ownedWorld = async (b) => { + calls.owned.push(b) + return { ok: true, status: 200, data: { owned: [{ serial: '0x40000000', what: 'creature' }] } } + } + uoLinkClient.despawnWorld = async (b) => { + calls.despawn.push(b) + return { ok: true, status: 200, data: { removed: b.serials || [], gone: [], refused: [] } } + } uoLinkConfig.getSafe = async () => ({ bootId: 'boot-1' }) // Phase 11b. `uo.participation.open` resolves its `place` param against the // atlas, so the dry-run sweep below reaches this rather than the database. - shardAtlas.listLandmarks = async () => [{ facet: 'Felucca', name: 'Britain', x: 1496, y: 1628, z: 10 }] + // Two landmarks, because Phase 12a's gate verb resolves a SECOND place: its + // destination. One would make the dry-run sweep below pass for the wrong + // reason, by never exercising the leg that can name a different point. + shardAtlas.listLandmarks = async () => [ + { facet: 'Felucca', name: 'Britain', x: 1496, y: 1628, z: 10 }, + { facet: 'Felucca', name: 'Yew', x: 542, y: 982, z: 0 }, + ] + shardAtlas.listDecorTypes = async () => [{ type: 'Brazier', itemId: 0x0E31, uses: 42 }] + shardAtlas.getDecorType = async (type) => + type === 'Brazier' ? { type: 'Brazier', itemId: 0x0E31, uses: 42 } : null }) afterEach(() => { @@ -55,6 +91,11 @@ afterEach(() => { shardAtlas.listRegions = saved.listRegions shardAtlas.listLandmarks = saved.listLandmarks shardAtlas.searchCreatures = saved.searchCreatures + shardAtlas.listDecorTypes = saved.listDecorTypes + shardAtlas.getDecorType = saved.getDecorType + for (const name of ['spawnWorld', 'ownedWorld', 'despawnWorld']) { + uoLinkClient[name] = saved[name] + } }) // ── The rule everything else depends on ──────────────────────────────────── @@ -116,9 +157,15 @@ test('the declarations satisfy the shape core validates them with', () => { } }) -test('a broadcast spends the one budget dimension the module declares', () => { +test('every dimension a cost names is one this module declares', () => { const declared = new Set(actions.BUDGETS.map((b) => b.id)) - assert.deepEqual([...declared], ['uo.broadcasts']) + // Phase 12a. All six are the MODULE's (org lead, 2026-09-07): core meters what + // a module declares and holds no UO knowledge, so a `uo.` dimension core knew + // about would be a leak of this game into the engine. + assert.deepEqual( + [...declared], + ['uo.broadcasts', 'uo.creatures', 'uo.bosses', 'uo.npcs', 'uo.decor', 'uo.gate.minutes'], + ) 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') @@ -134,6 +181,24 @@ test('a broadcast spends the one budget dimension the module declares', () => { // id, so there is no runaway for a cap to bound. assert.equal(byId('uo.towncrier.post').cost, undefined) assert.equal(byId('uo.news.post').cost, undefined) + + // Phase 12a. Asserted across EVERY action rather than one at a time, because + // the failure this catches is a typo in one dimension name out of six, which + // core answers by refusing the whole registration at load. + for (const action of actions.ACTIONS) { + if (typeof action.cost !== 'function') continue + const params = {} + for (const p of action.params) params[p.name] = p.example + for (const id of Object.keys(action.cost(params))) { + assert.ok(declared.has(id), `${action.id} spends "${id}", which nothing declares`) + } + } + + // A gate is priced in MINUTES, not in gates. One standing all day and twelve + // standing five minutes each are not the same imposition on a world, and a + // count would price them identically. + assert.deepEqual(byId('uo.gate.open').cost({ durationMinutes: 120 }), { 'uo.gate.minutes': 120 }) + assert.deepEqual(byId('uo.creature.spawn').cost({ count: 8 }), { 'uo.creatures': 8 }) }) // ── uo.broadcast: retried, because protocol 6 made that safe ─────────────── @@ -450,16 +515,26 @@ test('a landmark groups by the atlas grouping where it has one, the facet otherw 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 () => { +test('a creature option carries the type the shard can build, not the atlas slug', async () => { + // Changed in Phase 12a, and the reason is the point of the source existing. + // Wave 1 declared it before anything consumed it and used the slug — unique, + // stable, and unusable: the shard constructs from a ServUO class name, and + // `orc-brute` is not one. The atlas's `name` IS the raw type token from the + // spawn files, so the fix was to stop discarding the half that works. shardAtlas.searchCreatures = async ({ limit }) => { assert.equal(limit, actions.MAX_OPTIONS, 'the source must bound what it asks the atlas for') - return { creatures: [{ slug: 'orc-brute', name: 'Orc Brute' }] } + return { creatures: [{ slug: 'orcbrute', name: 'OrcBrute' }] } } assert.deepEqual(await source('uo.options.creatures').resolve(), [ - { value: 'orc-brute', label: 'Orc Brute' }, + { value: 'OrcBrute', label: 'OrcBrute' }, ]) }) +test('decoration options come from the shard\'s own decoration files', async () => { + const options = await source('uo.options.decor').resolve() + assert.deepEqual(options, [{ value: 'Brazier', label: 'Brazier' }]) +}) + test('an atlas larger than the dropdown bound is truncated and said so', async () => { const { ctx } = require('./_setup') shardAtlas.listRegions = async () => @@ -475,3 +550,279 @@ test('an atlas larger than the dropdown bound is truncated and said so', async ( .some(([message]) => /truncated/.test(message)) assert.ok(warned, 'a truncated source must leave a log line naming itself') }) + +// ── The world verbs (Phase 12a) ─────────────────────────────── + +test('a spawn files one ledger row per serial, not one per call', async () => { + // Per serial, because a group half of which a player killed has to reconcile + // per creature. One row per call would make teardown all-or-nothing over eight + // orcs of which six are gone, which is neither true nor useful. + const result = await byId('uo.creature.spawn').perform({ + runId: 7, + idempotencyKey: 'c'.repeat(40), + params: { place: 'Felucca/Britain', creature: 'Orc', count: 3 }, + verify: false, + }) + + assert.equal(result.ok, true) + assert.equal(result.resources.length, 3) + for (const resource of result.resources) { + assert.equal(resource.kind, actions.OWNED_KIND) + assert.equal(resource.payload.runId, '7') + assert.equal(resource.payload.what, 'creature') + assert.equal(resource.payload.type, 'Orc') + } + + // The place is resolved to a point HERE, so the shard is never handed a + // facet/name it would have to know how to read. + assert.equal(calls.spawn.length, 1) + assert.deepEqual( + { map: calls.spawn[0].map, x: calls.spawn[0].x, y: calls.spawn[0].y }, + { map: 'Felucca', x: 1496, y: 1628 }, + ) +}) + +test('a boss is a creature plus multipliers, and is refused above the ceiling', async () => { + const boss = byId('uo.boss.spawn') + const params = { + place: 'Felucca/Britain', + creature: 'OrcCaptain', + name: 'Gruk the Unbroken', + hitsMultiplier: 3, + damageMultiplier: 1.5, + } + + assert.equal((await boss.perform({ runId: 7, idempotencyKey: 'b'.repeat(40), params, verify: false })).ok, true) + assert.equal(calls.spawn[0].what, 'boss') + assert.equal(calls.spawn[0].hitsMultiplier, 3) + assert.equal(calls.spawn[0].damageMultiplier, 1.5) + // Absent, not zero: a multiplier nobody set must not arrive as a number the + // shard would then apply. + assert.equal(calls.spawn[0].statMultiplier, undefined) + + const tooMuch = await boss.perform({ + runId: 7, + idempotencyKey: 'b'.repeat(40), + params: { ...params, hitsMultiplier: actions.MAX_BOSS_MULTIPLIER + 1 }, + verify: false, + }) + assert.equal(tooMuch.ok, false) + assert.equal(tooMuch.retry, false, 'a ceiling will not move on a retry') + assert.equal(calls.spawn.length, 1, 'nothing may reach the shard once it is refused here') + + // Named, because an unnamed boss is just a hard orc — and because the name is + // what an operator reads in the ledger afterwards. + const unnamed = await boss.perform({ + runId: 7, + idempotencyKey: 'b'.repeat(40), + params: { ...params, name: ' ' }, + verify: false, + }) + assert.equal(unnamed.ok, false) +}) + +test('an oracle\'s dialogue is parsed from one textarea, and a bad row is named', async () => { + const parsed = actions.oracleLines('fire, flame = It burns beneath the keep.\n gate = At dusk. ') + assert.deepEqual(parsed, { + ok: true, + rows: [ + { keywords: 'fire,flame', text: 'It burns beneath the keep.' }, + { keywords: 'gate', text: 'At dusk.' }, + ], + }) + + // Split on the FIRST `=`, so an answer may contain one. + assert.deepEqual(actions.oracleLines('sum = 2 = 2 is four').rows, [ + { keywords: 'sum', text: '2 = 2 is four' }, + ]) + + assert.equal(actions.oracleLines('just some prose').ok, false) + assert.equal(actions.oracleLines('fire =').ok, false, 'a keyword with nothing to say is a mistake') + assert.equal(actions.oracleLines('= something').ok, false, 'something to say with no keyword is too') + + const tooMany = actions.oracleLines( + Array.from({ length: actions.MAX_ORACLE_LINES + 1 }, (_, i) => `w${i} = t${i}`).join('\n'), + ) + assert.equal(tooMany.ok, false) +}) + +test('an oracle with nothing to say is refused before it is stood up', async () => { + // `required: true` on the greeting catches an ABSENT field, at the edge, and + // this catches the one holding nothing but spaces — which reaches `perform` + // looking exactly like a filled-in form. + const result = await byId('uo.npc.place').perform({ + runId: 7, + idempotencyKey: 'n'.repeat(40), + params: { place: 'Felucca/Britain', name: 'Marisa', greeting: ' ' }, + verify: false, + }) + assert.equal(result.ok, false) + assert.equal(result.retry, false) + assert.match(result.error, /silence/) + assert.deepEqual(calls.spawn, []) +}) + +test('a keyword line reaches the shard as keywords and text, and nothing executable', async () => { + // The whole argument for not building this on `XmlSpawner2.XmlDialog`, which + // implements exactly this vocabulary and one field more: an `Action` string + // that runs commands. What crosses here is what an oracle SAYS. + const result = await byId('uo.npc.place').perform({ + runId: 7, + idempotencyKey: 'n'.repeat(40), + params: { + place: 'Felucca/Britain', + name: 'Marisa', + greeting: 'You have questions.', + lines: 'fire, flame = It burns beneath the keep.', + sex: 'female', + }, + verify: false, + }) + + assert.equal(result.ok, true) + assert.deepEqual(calls.spawn[0].lines, [ + { keywords: 'fire,flame', text: 'It burns beneath the keep.' }, + ]) + assert.equal(calls.spawn[0].sex, 'female') + for (const key of Object.keys(calls.spawn[0])) { + assert.notEqual(key, 'action', 'nothing executable may cross to the shard') + } +}) + +test('a gate crosses as a DURATION, and names both ends as points', async () => { + const result = await byId('uo.gate.open').perform({ + runId: 7, + idempotencyKey: 'g'.repeat(40), + params: { place: 'Felucca/Britain', destination: 'Felucca/Yew', durationMinutes: 120 }, + verify: false, + }) + + assert.equal(result.ok, true) + const sent = calls.spawn[0] + // A duration, never an absolute time: an absolute deadline computed here and + // honoured there is measured against two clocks, and a shard ten minutes fast + // would collect the gate the instant it opened. + assert.equal(sent.holdMs, 120 * 60_000) + assert.equal(sent.untilMs, undefined, 'an absolute deadline must not cross') + assert.deepEqual(sent.target, { map: 'Felucca', x: 542, y: 982 }) + + const tooLong = await byId('uo.gate.open').perform({ + runId: 7, + idempotencyKey: 'g'.repeat(40), + params: { + place: 'Felucca/Britain', + destination: 'Felucca/Yew', + durationMinutes: actions.MAX_GATE_MINUTES + 1, + }, + verify: false, + }) + assert.equal(tooLong.ok, false) + assert.equal(tooLong.retry, false) +}) + +test('teardown reports a refused serial as failed, and a killed creature as done', async () => { + const resources = [ + { kind: 'world', ref: '0x40000000', payload: {} }, + { kind: 'world', ref: '0x40000001', payload: {} }, + ] + + // `gone` is not a failure. A creature a player killed is the point of having + // spawned it, and §L already says "gone, and that is fine" is a successful + // revert — so a run does not end `incomplete` because its event worked. + uoLinkClient.despawnWorld = async () => ({ + ok: true, + status: 200, + data: { removed: ['0x40000000'], gone: ['0x40000001'], refused: [] }, + }) + assert.deepEqual(await actions.revertOwned({ runId: 7, resources }), { ok: true }) + + // `refused` IS. The shard denies this run ever owned it, so nothing will ever + // delete it through this path: the row must land unresolved with a reason + // rather than be quietly marked reverted. + uoLinkClient.despawnWorld = async () => ({ + ok: true, + status: 200, + data: { removed: ['0x40000000'], gone: [], refused: ['0x40000001'] }, + }) + assert.deepEqual(await actions.revertOwned({ runId: 7, resources }), { + ok: true, + failed: ['0x40000001'], + }) + + // An unreachable shard has not said anything about anything. + uoLinkClient.despawnWorld = async () => ({ ok: false, status: 503, data: null }) + assert.equal((await actions.revertOwned({ runId: 7, resources })).ok, false) +}) + +test('reconcile ASKS the shard, because these resources survive a restart', async () => { + // The one property that separates this from every other resource in the file. + // A crier line lives in shard memory, so a changed `bootId` IS proof it is + // gone; a spawned creature is in the world SAVE and survives the restart the + // boot stamp would report it lost by. + const resources = [ + { kind: 'world', ref: '0x40000000', payload: {} }, + { kind: 'world', ref: '0x40000001', payload: {} }, + ] + + assert.deepEqual(await actions.reconcileOwned({ runId: 7, resources }), { + ok: true, + inForce: ['0x40000000'], + }) + assert.deepEqual(calls.owned, [{ runId: '7' }]) + + // "I could not ask" must never be read as "it is gone": an unanswered group + // leaves every row alone rather than orphaning the lot. + uoLinkClient.ownedWorld = async () => ({ ok: false, status: 504, data: null }) + assert.equal((await actions.reconcileOwned({ runId: 7, resources })).ok, false) +}) + +test('every world verb declares the same undo contract', async () => { + // Five declarations sharing one spread object, asserted rather than assumed: + // a verb that quietly lost its `reconcile` would leave its rows unanswered for + // the life of the run, and nothing would report it — which is exactly the hole + // Phase 11b found in `core.lease`. + for (const id of ['uo.creature.spawn', 'uo.boss.spawn', 'uo.npc.place', 'uo.gate.open', 'uo.decor.place']) { + const action = byId(id) + assert.equal(action.risk, 'change', `${id} must be a world change`) + assert.equal(action.reversible, 'ledger', `${id} owns what it made`) + assert.equal(typeof action.revert, 'function', `${id} has no undo`) + assert.equal(typeof action.reconcile, 'function', `${id} can never be asked what it still holds`) + assert.ok(action.budgetMs > 12000, `${id} must outlast the client's own timeout`) + assert.equal(typeof action.cost, 'function', `${id} is capped by nothing`) + } +}) + +test('decoration carries the graphic, and a type this shard never decorates with is refused', async () => { + const decor = byId('uo.decor.place') + + const ok = await decor.perform({ + runId: 7, + idempotencyKey: 'd'.repeat(40), + params: { place: 'Felucca/Britain', item: 'Brazier', count: 2 }, + verify: false, + }) + assert.equal(ok.ok, true) + assert.equal(ok.resources.length, 2) + + // **The item id crosses, and it has to.** Measured on ServUO 57.4, `Static` + // accounts for 5031 decoration placements under 1992 DIFFERENT graphics, + // because for that class the graphic is the identity: a bare `new Static()` + // is never the paving stone the author picked. 131 of 313 types carry more + // than one id. + assert.equal(calls.spawn[0].type, 'Brazier') + assert.equal(calls.spawn[0].itemId, 0x0e31) + + // Resolving through the atlas is also the boundary: the verb places what this + // shard's own decoration files name, which is tighter than "any item that is + // not a container" and is the rule the decision actually took. + const unknown = await decor.perform({ + runId: 7, + idempotencyKey: 'd'.repeat(40), + params: { place: 'Felucca/Britain', item: 'BlackrockCrate', count: 1 }, + verify: false, + }) + assert.equal(unknown.ok, false) + assert.equal(unknown.retry, false) + assert.match(unknown.error, /never mention/) + assert.equal(calls.spawn.length, 1) +}) diff --git a/server/utils/spawnAtlasParse.js b/server/utils/spawnAtlasParse.js index 5d909f5..6bdd393 100644 --- a/server/utils/spawnAtlasParse.js +++ b/server/utils/spawnAtlasParse.js @@ -548,6 +548,48 @@ function walkLocations(node, facet, path, out) { * A spawn with no `type` is randomised on every activation, which the site must * render as "random" rather than as an empty type. */ +/** + * Item types a shard uses as decoration, from one `Data/Decoration/*.cfg`. + * + * The format is a header line naming a type and an item id, optionally followed + * by a parenthesised property list, and then one `x y z` line per placement: + * + * ``` + * # switch + * Static 0x108F + * 5552 1864 11 + * ``` + * + * Only the header matters here. The properties are decoration-authoring details + * (`Hue=`, `Facing=`, `Name=`) and the coordinates are where the SHARD put its + * own scenery, neither of which an event author is choosing — they pick a type + * and a place of their own. + * + * Returns one entry per header line, not per distinct type: the same type + * appears under many item ids (a `BarredMetalDoor` for each facing), and how + * often a shard reaches for something is worth keeping. `spawnAtlasSource` + * aggregates. + */ +function parseDecoration(source) { + const out = [] + if (!source) return out + + for (const raw of String(source).split(/\r?\n/)) { + const line = raw.trim() + + // A coordinate line starts with a digit or a minus (z is often negative), + // so the type test is not merely "not a comment". + if (line === '' || line.startsWith('#')) continue + + const match = /^([A-Za-z_][A-Za-z0-9_]*)\s+0x([0-9A-Fa-f]+)/.exec(line) + if (!match) continue + + out.push({ type: match[1], itemId: parseInt(match[2], 16) }) + } + + return out +} + function parseChampions(source) { const root = parseXml(source) const champions = [] @@ -675,6 +717,7 @@ module.exports = { parseRegions, parseLocations, parseChampions, + parseDecoration, buildPlacementIndex, resolveRegion, facetKey, diff --git a/server/utils/spawnAtlasSource.js b/server/utils/spawnAtlasSource.js index 10d307b..6e19e73 100644 --- a/server/utils/spawnAtlasSource.js +++ b/server/utils/spawnAtlasSource.js @@ -23,6 +23,7 @@ const { parseRegions, parseLocations, parseChampions, + parseDecoration, buildPlacementIndex, buildFacetIndex, resolveFacetName, @@ -35,6 +36,7 @@ const REGIONS_FILE = path.join('Data', 'Regions.xml') const LOCATIONS_DIR = path.join('Data', 'Locations') const SPAWNS_DIR = 'Spawns' const CHAMPIONS_FILE = path.join('Config', 'ChampionSpawns.xml') +const DECORATION_DIR = path.join('Data', 'Decoration') class AtlasSourceError extends Error { constructor(message, code) { @@ -62,6 +64,33 @@ function listXml(dir) { } } +/** + * Every `.cfg` under `dir`, recursively, tree-relative and forward-slashed. + * + * Recursive because `Data/Decoration` nests two deep in places + * (`Magincia/Trammel`, `Stygian Abyss/Ter Mur`, `Old/Britannia`) and a flat read + * would silently index a third of what the shard actually has — the failure + * mode being a dropdown that is quietly missing whole expansions rather than an + * error anyone would notice. + */ +function listCfgTree(dir, prefix = '') { + let entries + try { + entries = fs.readdirSync(dir, { withFileTypes: true }) + } catch (err) { + if (err.code === 'ENOENT' || err.code === 'ENOTDIR') return [] + throw err + } + + const out = [] + for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { + const rel = prefix ? `${prefix}/${entry.name}` : entry.name + if (entry.isDirectory()) out.push(...listCfgTree(path.join(dir, entry.name), rel)) + else if (entry.name.toLowerCase().endsWith('.cfg')) out.push(rel) + } + return out +} + function readIfPresent(file) { try { return fs.readFileSync(file, 'utf8') @@ -111,6 +140,12 @@ function readSources(root) { push('Config/ChampionSpawns.xml', path.join(root, CHAMPIONS_FILE)) + // Optional, like the champion file: a shard that has stripped its decoration still + // has a usable atlas, it just cannot offer the decoration verb anything to place. + for (const rel of listCfgTree(path.join(root, DECORATION_DIR))) { + push(`Data/Decoration/${rel}`, path.join(root, DECORATION_DIR, rel)) + } + return { files } } @@ -141,7 +176,7 @@ function hashSources(root) { * 2 — respawn delays normalised to seconds (they are per-record minutes OR * seconds in the source, decided by `DelayInSec`). */ -const PARSER_VERSION = 2 +const PARSER_VERSION = 3 /** True when two source fingerprints describe the same tree. */ function sameSources(a, b) { @@ -294,6 +329,25 @@ function buildAtlas(root, options = {}) { } }) + // Decoration: what this shard already calls scenery, which is what makes the + // authoring dropdown the operator's own vocabulary rather than our taste. + const decorUses = new Map() + for (const file of files) { + if (!file.label.startsWith('Data/Decoration/')) continue + for (const entry of parseDecoration(file.text)) { + const seen = decorUses.get(entry.type) + if (seen) { + seen.uses += 1 + continue + } + // The FIRST item id wins, and it is only a preview: a type appears under + // as many ids as it has facings or variants, and picking one arbitrarily + // is honest in a way that picking "the most used" would not be. + decorUses.set(entry.type, { type: entry.type, itemId: entry.itemId, uses: 1 }) + } + } + const decor = [...decorUses.values()].sort((a, b) => a.type.localeCompare(b.type)) + const creatures = aggregateCreatures(points) const facets = [...new Set(points.map((point) => point.facet))].sort() const unresolved = points.filter((point) => !point.region && !point.landmark).length @@ -311,6 +365,7 @@ function buildAtlas(root, options = {}) { regions: regions.length, landmarks: landmarks.length, champions: champions.length, + decor: decor.length, unresolvedPoints: unresolved, }, source, @@ -321,6 +376,7 @@ function buildAtlas(root, options = {}) { landmarks, champions, points, + decor, } } diff --git a/server/utils/uoLinkClient.js b/server/utils/uoLinkClient.js index 62263bc..aeff836 100644 --- a/server/utils/uoLinkClient.js +++ b/server/utils/uoLinkClient.js @@ -289,6 +289,36 @@ const closeParticipation = ({ runId, idempotencyKey }) => body: { idempotencyKey }, }) +// ── The world verbs (protocol 7) ─────────────────────────────── +// +// One endpoint for five author-facing verbs. `what` is the discriminator, and the +// per-verb fields ride alongside it: `type`/`name`/`hue`/`spread` for creatures and +// decoration, the three multipliers for a boss, `greeting`/`lines` for an oracle, +// `target`/`holdMs` for a gate. +// +// The shard registers every serial it places against the run and persists that +// registry, which is what makes `despawnWorld` below safe to point at a list of +// serials: it can only delete what the run actually owns. +const spawnWorld = (body) => call('/world', { method: 'POST', body }) + +// What the run still owns. A GET, unlike the participation snapshot: it carries no +// idempotency key and the shard answers it in one pass. An unknown run answers with an +// empty hand rather than a 404 — "owns nothing" and "never heard of it" are the same +// fact once the registry is the only record, and they stay the same fact across a +// restart, because the registry is written by the same world save as the objects it +// describes. +const ownedWorld = ({ runId }) => call(`/world/${encodeURIComponent(runId)}`) + +// Give back what the run owns. No `serials` means everything, which is the call +// teardown makes. The reply splits three ways: `removed` was deleted, `gone` was +// already absent (a player killed it — an ordinary success), and `refused` was never +// this run's to delete. +const despawnWorld = ({ runId, serials, idempotencyKey }) => + call(`/world/${encodeURIComponent(runId)}/despawn`, { + method: 'POST', + body: { serials, idempotencyKey }, + }) + // ── Help-page (support) queue commands (§6) ──────────────────────────────── const respondPage = (pageId, { message, close }) => call(`/pages/${encodeURIComponent(pageId)}/respond`, { method: 'POST', body: { message, close } }) @@ -328,6 +358,9 @@ module.exports = { openParticipation, snapshotParticipation, closeParticipation, + spawnWorld, + ownedWorld, + despawnWorld, adminKick, adminBan, adminUnban, -- 2.49.1 From 10fde87724bf6f1826b5585c841aa300d507c828 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 7 Sep 2026 08:08:08 -0500 Subject: [PATCH 6/8] feat(events): what an author borrows, and two one-shots (Phase 12b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five targeted leases over two planes, the item grant, the world save, and the atlas work the spawner dropdown needed. FIVE LEASES, ONE FACTORY `uo.spawner.maxcount`, `.mindelay`, `.maxdelay`, `.running` and `uo.seasonal.status`. The four callables differ only in which key they name, so they are built rather than repeated: five copies would be five chances for one of them to forget the drift check, which is the one thing §F says a lease must not be allowed to skip. It is `MaxCount`, not the `Amount` EVENTS_PLAN.md named -- there is no such property on ServUO 57.4. `MinDelay`/`MaxDelay` are TimeSpans, so the wire carries SECONDS: the spawn files' own `DelayInSec` flag proves both units are in use on a real tree, and a unit that cannot express five seconds cannot express this shard's own data. The seasonal lease is a THREE-value enum over EIGHT events. §G called `GetEntry(type).Status` "a nine-value enum" and had it backwards: `EventStatus` has three values and it is `EventType` that has nine entries. Eight rather than nine because `TreasuresOfTokuno` is excluded -- `IsActive()` reads its own `DropEra` rather than `Status`, so leasing it would apply cleanly, read back, restore cleanly and do nothing at all. Two behaviours worth the review. `inForce()` reads the frame's `holds` rather than a row's `held` flag, because a catalog walk can enumerate the keys but never the holds on a targeted one. And a target that VANISHED mid-run is a SUCCESSFUL restore: there is nothing to give back, and reporting it failed would leave a ledger row unresolved for ever over an object that is gone -- 12a's `gone` in the lease plane's vocabulary. THE GRANT NAMES A RUN, NEVER A RECIPIENT LIST Core has the participants in `event_run_participants`, but a module cannot read core's tables -- so the alternative was a new core surface handing them over. Not needed: the shard has held the run's ledger since it opened, keyed by the same serials core stores as `member_key`. And the grant is RETRYABLE. §G called it un-retryable because a lost acknowledgement and a grant that never applied were the same event, which is exactly the argument that made `uo.broadcast` answer `retry: false` in Phase 9. Protocol 6's idempotency key closes it. `uo.rewards` counts ITEMS rather than grants: 500 gold to forty people and a candle to forty people are not the same imposition. THE ATLAS KEEPS UniqueId AGAIN, AND THE SPAWNER SOURCE SEARCHES The parser has read `` and thrown it away since the atlas shipped, on a line citing a committed artifact -- there is no committed artifact, as `spawnAtlasSource.js` says in its own header. It is the ONLY name for one particular spawner that exists off the shard, so a property lease could not have had a dropdown without it. `PARSER_VERSION` -> 4 so an unchanged tree is re-read. `uo.options.spawners` is the first searchable source and the first that had to be: 6,707 spawn points against `MAX_OPTIONS`' 2,000, so a flat list would drop two thirds of the world and say nothing about which two thirds. ONE DEFECT IN ALREADY-MERGED CODE, AND IT WOULD HAVE BROKEN EVERYTHING The protocol pin never left 5. `uo_link_config.protocol` reaches the sidecar as `X-UOLink-Version` on every REST call and an exact mismatch is a 409, so from Phase 11a onward every sidecar call on a real deployment would have been refused -- the whole event plane dead, loudly, for a reason nobody would look here for. 11a took the wire to 6 and 12a to 7; neither moved the pin, in either of the two places this repo declares it. It survived both because both live walks set the column by hand while standing the rig up, which is exactly what makes a migration nobody runs invisible. All three sites go to 7. The test that guards them is worth understanding before trusting it: `schemaFragment.test.js` asserts the three declarations agree WITH EACH OTHER -- a real check they once failed -- but all three being equally stale passes it, and nothing in this repo can anchor it to the wire. Recorded in the model's own header so the next reader knows. CHECKS `npm test`: 620 pass, 0 fail (was 605). `check:imports` and `check:externals` clean; the client builds and its 42 tests pass. `check:swagger` reports the fragment stale -- it is ALREADY stale on `edge` (verified by stashing this branch's changes and re-running) and this phase adds no route, so it is left alone rather than regenerated inside an unrelated change. Two bugs the new tests caught in this branch's own code before it left: `counted()` returns `.count` and the grant read `.value`, so every grant went out with `amount: undefined` and the non-stackable guard never fired; and `optionalInt`'s `ok` was ignored, so a bad hue passed silently instead of refusing. Refs: docs/link/v7.md §11-§14, docs/website/EVENTS_PLAN.md Phase 12b Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4 --- server/config/uoEventActions.js | 527 ++++++++++++++++++ server/db/schema.sql | 35 +- server/model/shardAtlas/shardAtlas.db.js | 41 +- server/model/shardAtlas/shardAtlas.model.js | 22 + .../model/uoLinkConfig/uoLinkConfig.model.js | 28 +- server/test/entry.test.js | 31 +- server/test/spawnAtlas.parse.test.js | 11 +- server/test/uoEventActions.test.js | 20 +- server/test/uoEventBorrowed.test.js | 349 ++++++++++++ server/utils/spawnAtlasParse.js | 23 +- server/utils/spawnAtlasSource.js | 7 +- server/utils/uoLinkClient.js | 57 +- 12 files changed, 1122 insertions(+), 29 deletions(-) create mode 100644 server/test/uoEventBorrowed.test.js diff --git a/server/config/uoEventActions.js b/server/config/uoEventActions.js index eef19e1..674cedb 100644 --- a/server/config/uoEventActions.js +++ b/server/config/uoEventActions.js @@ -259,6 +259,13 @@ const BUDGETS = [ unit: 'minutes', description: 'Total minutes of temporary gate this run may open, across every gate.', }, + { + id: 'uo.rewards', + label: 'Items granted', + unit: 'items', + description: + 'Items handed to participants. Counted per item rather than per grant: a step giving 500 gold to 40 people is a different imposition from one giving a candle to 40 people, and a count of grants would price them the same.', + }, ] // ── Participation (protocol 6 part b, EVENTS_PLAN.md Phase 11b) ──────────── @@ -510,6 +517,79 @@ async function reconcileOwned({ runId, resources }) { } /** The three fields every world verb shares, so five declarations cannot drift apart. */ +// ── The one-shots' vocabulary (Phase 12b) ────────────────────────────────── + +/** + * What this shard will grant, mirroring the plugin's own allowlist. + * + * **Two copies of a short list, deliberately.** This one is what makes a bad + * value a refusal on an authoring form; the plugin's is what is true when this + * one is wrong — the same argument the lease bounds have carried since 11b. It + * is held here rather than read from `GET /items` because §F is explicit that an + * option source must resolve with the game side down, and an authoring screen a + * shard outage can make unusable is a worse failure than the typo the dropdown + * prevents. + * + * `stackable` is carried because it changes what an `amount` MEANS: five gold is + * one item with `Amount = 5`, and five cloaks would be five items — five chances + * to overflow a backpack halfway through with no way to say which half landed. + * Both ends refuse a non-stackable in quantity. + */ +const GRANTABLE = [ + { key: 'gold', label: 'Gold', stackable: true }, + { key: 'cloak', label: 'Cloak', stackable: false }, + { key: 'sandals', label: 'Sandals', stackable: false }, + { key: 'candle', label: 'Candle', stackable: false }, + { key: 'earrings', label: 'Silver earrings', stackable: false }, + { key: 'fireworks', label: 'Fireworks wand', stackable: false }, + { key: 'bottle', label: 'Message in a bottle', stackable: false }, +] + +/** The bound on one hand, mirroring `Bridge.EventsMaxGrantStack`. */ +const MAX_GRANT_STACK = 1000 + +/** + * The seasonal events a lease may name — eight of `EventType`'s nine. + * + * `TreasuresOfTokuno` is absent because `SeasonalEventEntry.IsActive()` + * special-cases it and reads `TreasuresOfTokuno.DropEra` rather than `Status`, + * so a lease on it would write a field nothing consults: the write succeeds, the + * value reads back, the compare-and-set restore passes, and nothing in the world + * changes. §N10 calls that "a capability that lies", and it is the one instance + * no runtime probe can catch — which is why it is excluded by name at both ends. + */ +const SEASONAL_EVENTS = [ + 'VirtueArtifacts', + 'TreasuresOfKotlCity', + 'SorcerersDungeon', + 'TreasuresOfDoom', + 'TreasuresOfKhaldun', + 'KrampusEncounter', + 'RisingTide', + 'Fellowship', +] + +/** ServUO's own display names for them, from `SeasonalEventSystem.LoadEntries()`. */ +const SEASONAL_LABELS = { + VirtueArtifacts: 'Virtue Artifacts', + TreasuresOfKotlCity: 'Treasures of Kotl', + SorcerersDungeon: "Sorcerer's Dungeon", + TreasuresOfDoom: 'Treasures of Doom', + TreasuresOfKhaldun: 'Treasures of Khaldun', + KrampusEncounter: 'Krampus Encounter', + RisingTide: 'Rising Tide', + Fellowship: 'Fellowship', +} + +/** + * How many spawners one search answers with. + * + * Well under `MAX_OPTIONS` on purpose: this is a typeahead rather than a select, + * and a hundred rows is already more than anybody reads. The bound that matters + * is that the SEARCH reaches the whole tree, which it does. + */ +const SPAWNER_OPTIONS = 100 + const OWNED_COMMON = { // Something appears in the world where there was nothing. §K's default-off // line falls between `inspect` and `change`, so an operator switches these on @@ -1451,6 +1531,178 @@ const ACTIONS = [ }, }, + // ── The one-shots (Phase 12b) ──────────────────────────────────────────── + // + // Neither owned nor borrowed. Nothing is ledgered because there is nothing + // core could come back for, which is what `reversible: 'none'` says. + { + id: 'uo.item.grant', + label: 'Grant an item', + description: + "Put an item into the hands of everyone who took part in this run. Irreversible: an object in a player's backpack cannot be recalled.", + + // **`irreversible`, not `change`.** The world is altered and cannot be put + // back, which is the honest class and the one that makes an author's + // default `on_failure` a pause rather than a retry-then-skip. + risk: 'irreversible', + // Nothing to give back. A `ledger` here would put a row in the cleanup + // ledger that teardown could never resolve — §G's `reversible: 'none'` for + // UO specifically, and the reason is the game's rather than the platform's. + reversible: 'none', + version: 1, + budgetMs: BUDGET_MS, + cost: (p) => ({ 'uo.rewards': Number(p.amount) || 1 }), + + params: [ + { + name: 'item', + type: 'string', + required: true, + example: 'gold', + source: 'uo.options.items', + description: 'What to hand out. The list is the shard’s own allowlist.', + }, + { + name: 'amount', + type: 'int', + required: true, + example: 500, + description: `How many each. Stackable items only; ${MAX_GRANT_STACK} at most.`, + }, + { + name: 'where', + type: 'string', + required: false, + example: 'backpack', + description: 'backpack or bank. Left out, the backpack.', + }, + { + name: 'hue', + type: 'int', + required: false, + example: 1157, + description: 'UO colour id, for a commemorative reward.', + }, + { + name: 'name', + type: 'string', + required: false, + example: 'a champion’s cloak', + description: 'What it is called when a player looks at it.', + }, + ], + + async perform({ runId, idempotencyKey, params, verify }) { + const item = String(params.item || '').trim() + if (!item) return { ok: false, retry: false, error: 'pick something to grant' } + + const known = GRANTABLE.find((g) => g.key === item) + if (!known) { + return { ok: false, retry: false, error: `this shard does not grant "${item}"` } + } + + // Its own check rather than `counted()`: that one's refusal reads "place 1 + // to N items at a time", which is the spawn verbs' sentence and wrong here + // — nothing is being placed. The bound is the same shape and mirrors + // `Bridge.EventsMaxGrantStack`. + const amount = Number(params.amount) + if (!Number.isInteger(amount) || amount < 1 || amount > MAX_GRANT_STACK) { + return { + ok: false, + retry: false, + error: `grant 1 to ${MAX_GRANT_STACK} at a time, and "${params.amount}" is not that`, + } + } + + if (!known.stackable && amount > 1) { + return { + ok: false, + retry: false, + error: `${known.label} does not stack, so it can only be granted one at a time`, + } + } + + const hue = optionalInt(params.hue, { max: 65535, name: 'colour' }) + if (!hue.ok) return { ok: false, retry: false, error: hue.error } + + const where = String(params.where || 'backpack').trim().toLowerCase() + if (where !== 'backpack' && where !== 'bank') { + return { ok: false, retry: false, error: `"${params.where}" is not backpack or bank` } + } + + // **The dry run stops here, and it has checked everything it can.** What + // it deliberately does not do is ask the shard who is present: a verify + // that failed because a run has no ledger open would refuse every grant + // authored before its own event ran, which is every grant. + if (verify) return { ok: true } + + const result = await uoLinkClient.grantItem({ + runId, + item, + amount, + hue: hue.value, + name: params.name ? String(params.name).slice(0, 40) : undefined, + where, + idempotencyKey, + }) + + // **Retryable, and protocol 6 is the whole reason.** §G called a grant + // un-retryable because a lost acknowledgement and a grant that never + // applied were the same event — exactly the argument that made + // `uo.broadcast` answer `retry: false` in Phase 9. An `idempotencyKey` + // closes that: a repeat is answered by the original reply, so a retried + // grant cannot be one winner receiving two. + if (!result.ok) return sidecarFailure(result, 'grant') + + const granted = Number(result.data?.granted) || 0 + const missed = (result.data && result.data.missed) || [] + + // A grant that reached nobody is a SUCCESS, and the distinction is the + // shard's: a run it was never told to count is a 404 above, while a run + // whose ledger is open and empty answers 200 with `granted: 0`. An event + // nobody attended still happened, and retrying against the same empty + // ledger would pause a run for ever. + return { + ok: true, + detail: { granted, missed: missed.length, ...(missed.length ? { why: missed.slice(0, 10) } : {}) }, + } + }, + }, + + { + id: 'uo.world.save', + label: 'Save the world', + description: + 'Ask the shard to write a world save. Useful as a phase boundary — the point after which what the event has done so far survives a crash.', + + // Nothing is created and nothing is altered; the world is written to disk. + // `inspect` would be a lie (it stops the world for a moment) and `change` + // is what that is. + risk: 'change', + reversible: 'none', + version: 1, + budgetMs: BUDGET_MS, + + params: [], + + async perform({ idempotencyKey, verify }) { + if (verify) return { ok: true } + + const result = await uoLinkClient.saveWorld({ idempotencyKey }) + + // 429 is the shard's save rate limit, and it is the one refusal on this + // plane that waiting fixes: the same request succeeds once the interval + // passes. It is not in `PERMANENT_STATUSES`, so `sidecarFailure` + // classifies it retry without needing an arm of its own — which is what + // makes a phase boundary retried rather than abandoned. + if (!result.ok) return sidecarFailure(result, 'world save') + + // What actually happened rides `world.save.before`/`after` on the event + // stream. This step reports only that the save was started, because that + // is the only thing the reply knows. + return { ok: true, detail: { started: true } } + }, + }, ] /** @@ -1507,6 +1759,142 @@ async function leaseRow(key) { return rows.find((r) => r && r.key === key) || null } +// ── The targeted leases (protocol 7 part b, Phase 12b) ───────────────────── +// +// What an event BORROWS. Five keys over two planes, and every one of them is +// targeted — a property lives on a particular object and a seasonal status on a +// particular event, so the lease id names the capability and the target names +// the thing. +// +// **The module still never writes a lease and never bounds one.** An author puts +// `core.lease` in a step naming a lease, a target, a value and a number of +// minutes; core reads the baseline, reserves `#` against the +// two-events-one-target index, applies the value with its deadline and restores +// it at teardown. What is here is the four callables, exactly as the config +// lease has had since 11b — with the target now handed to each of them. + +/** Twelve hours, the same ceiling the config lease carries. */ +const MAX_PROP_LEASE_MS = 12 * 60 * 60 * 1000 + +/** + * One row of the shard's lease frame, for one key and one target. + * + * A targeted key has no single `current`, so the shard is asked about the one + * that matters rather than walked. Null when the frame could not be read at all, + * which the callables turn into a refusal rather than a value. + */ +async function leaseRowFor(key, target) { + const result = await uoLinkClient.getLeases({ key, target }) + if (!result.ok) return null + const rows = (result.data && result.data.leases) || [] + return rows.find((r) => r && r.key === key) || null +} + +/** + * Whether the shard is still holding this key on this target. + * + * **Read from `holds`, not from a row's `held` flag**, and the difference only + * appears on a targeted key: `held` is filled in against the target the frame + * was narrowed to, so it answers for the row that was asked about — while + * `holds` is every hold the shard actually has. They agree here; `holds` is used + * because it is the one that stays true if the frame is ever asked without a + * target, and because it is the list a reconcile after a long outage wants. + */ +async function leaseHeld(key, target) { + const result = await uoLinkClient.getLeases({ key, target }) + if (!result.ok) return null + const holds = (result.data && result.data.holds) || [] + return holds.some((h) => h && h.key === key && String(h.target || '') === String(target || '')) +} + +/** + * The four callables every targeted lease shares. + * + * They differ only in which key they name, so they are built rather than + * repeated: five copies of this would be five chances for one of them to forget + * the drift check, which is the one thing §F says a lease must not be allowed to + * skip. + */ +function targetedLease({ id, key, label, description, type, min, max, values, targetLabel, source }) { + return { + id, + label, + description, + type, + ...(min === undefined ? {} : { min }), + ...(max === undefined ? {} : { max }), + ...(values ? { values } : {}), + maxDurationMs: MAX_PROP_LEASE_MS, + target: { + label: targetLabel, + source, + example: source === 'uo.options.spawners' ? '003f11b8-9bfa-4587-991e-ca263004efe6' : 'Fellowship', + }, + + async read({ target } = {}) { + const row = await leaseRowFor(key, target) + if (!row) return { ok: false, error: 'the shard did not report its lease catalog' } + // **`unreadable` is a refusal, and `current` missing is too.** A spawner + // that has been deleted answers with a reason rather than a value, and + // taking the lease anyway would record a fictional baseline and later + // write it onto whatever next held that id. + if (row.unreadable) return { ok: false, error: row.unreadable } + if (row.current === undefined || row.current === null) { + return { ok: false, error: `the shard could not read ${label} for that target` } + } + return { ok: true, value: row.current } + }, + + async apply(value, until, { target } = {}) { + 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, + target, + 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, target } = {}) { + const result = await uoLinkClient.releaseLease({ key, target, expected, baseline }) + + if (result.ok && result.data && result.data.kind === 'lease.drifted') { + return { ok: false, drifted: true, current: result.data.current } + } + + // **A target that no longer exists is a successful release**, not a + // failure. Somebody deleted the spawner mid-run: there is nothing to + // restore and nothing owed, and reporting it as failed would leave a + // ledger row unresolved for ever over an object that is gone. It is 12a's + // `gone` in the lease plane's vocabulary. + if (result.ok && result.data && result.data.targetGone === true) return { ok: true } + + if (!result.ok) return { ok: false, error: sidecarReason(result, 'lease release') } + return { ok: true } + }, + + async inForce({ target } = {}) { + const held = await leaseHeld(key, target) + if (held === null) return { ok: false, error: 'the shard did not report its lease catalog' } + return { ok: true, held } + }, + } +} + +const SPAWNER_TARGET = { + targetLabel: 'Which spawner', + source: 'uo.options.spawners', +} + +/** Twenty-four hours in seconds, the bound on a respawn window. */ +const MAX_SPAWN_DELAY_SEC = 86400 + const LEASES = [ { id: 'uo.playercaps.skillcap', @@ -1590,6 +1978,85 @@ const LEASES = [ return { ok: true, held: row.held === true } }, }, + // **`MaxCount`, not `Amount`.** EVENTS_PLAN.md named `Spawner.Amount`; there + // is no such property on ServUO 57.4. The count is `MaxCount` on BOTH + // `Spawner` and `XmlSpawner`, which share all four names here — a fact worth + // knowing rather than a convenience, because the shard's own `Spawns/*.xml` + // load as XmlSpawners while `[add spawner` makes the native one, and a catalog + // that named only one of them would work until the day it did not. + targetedLease({ + id: 'uo.spawner.maxcount', + key: 'Spawner.MaxCount', + label: 'Spawner: how many at once', + description: + 'How many creatures one spawner keeps alive. Takes effect on its next tick, so an invasion turns a spawner up for its duration and it winds back down at teardown.', + type: 'int', + min: 0, + max: 100, + ...SPAWNER_TARGET, + }), + // **Seconds, and the shard converts.** The property is a `TimeSpan` and the + // lease type vocabulary is int/float/bool/string with no duration in it. The + // unit is seconds rather than minutes because the spawn files' own + // `DelayInSec` flag proves both are in use on a real tree, and a unit that + // cannot express five seconds cannot express this shard's own data. + targetedLease({ + id: 'uo.spawner.mindelay', + key: 'Spawner.MinDelay', + label: 'Spawner: shortest respawn wait', + description: 'The shortest a spawner waits before replacing what was killed, in seconds.', + type: 'int', + min: 0, + max: MAX_SPAWN_DELAY_SEC, + ...SPAWNER_TARGET, + }), + targetedLease({ + id: 'uo.spawner.maxdelay', + key: 'Spawner.MaxDelay', + label: 'Spawner: longest respawn wait', + description: 'The longest a spawner waits before replacing what was killed, in seconds.', + type: 'int', + min: 0, + max: MAX_SPAWN_DELAY_SEC, + ...SPAWNER_TARGET, + }), + targetedLease({ + id: 'uo.spawner.running', + key: 'Spawner.Running', + label: 'Spawner: running', + description: + 'Whether a spawner runs at all. Switching one off for the length of an event is how a venue is cleared without deleting anything.', + type: 'bool', + ...SPAWNER_TARGET, + }), + // **A three-value enum over eight events, not a nine-way choice.** §G called + // `SeasonalEventSystem.GetEntry(type).Status` "a nine-value enum" and had it + // backwards: `EventStatus` has three values and it is `EventType` that has + // nine entries. + // + // Eight rather than nine because `TreasuresOfTokuno` is excluded on the shard: + // `IsActive()` special-cases it and reads `TreasuresOfTokuno.DropEra` instead + // of `Status`, so leasing it would write a field nothing consults — the write + // succeeds, the value reads back, a compare-and-set restore passes, and the + // capability does nothing at all. That is precisely the failure §N10's + // self-check exists for, and it is the one instance no runtime probe can + // catch, so it is caught by reading the source. + // + // And it is not a small toggle: `OnStatusChange()` calls a `CheckEnabled()` + // that generates or removes world content for six of the eight. It is safe — + // ServUO does exactly this to itself from a staff gump — but an author + // scheduling one should know it is more than a flag. + targetedLease({ + id: 'uo.seasonal.status', + key: 'Seasonal.Status', + label: 'Seasonal event status', + description: + "Switch one of ServUO's own seasonal events on or off for the length of a run. Six of the eight generate or remove world content when they change, so this is a bigger lever than it looks.", + type: 'string', + values: ['Inactive', 'Active', 'Seasonal'], + targetLabel: 'Which seasonal event', + source: 'uo.options.seasonal', + }), ] // ── Option sources ───────────────────────────────────────────────────────── @@ -1684,6 +2151,60 @@ const OPTION_SOURCES = [ return bounded(rows, 'uo.options.decor').map((r) => ({ value: r.type, label: r.type })) }, }, + { + id: 'uo.options.spawners', + label: 'Spawners', + // **The first searchable source, and the first one that had to be.** This + // tree has 6,707 spawn points against `MAX_OPTIONS`' 2,000, so a flat list + // would drop two thirds of the world and say nothing about which two thirds + // — the failure Phase 12a named for decoration, arriving for real. Core + // passes `q` to every source and requires it of none; this one reads it. + searchable: true, + description: "Spawners from the shard's own spawn files, searched by name, region or landmark.", + async resolve({ q } = {}) { + const rows = await shardAtlas.listSpawners({ q, limit: SPAWNER_OPTIONS }) + return rows.map((r) => ({ + // The `UniqueId`. It is the only name for one particular spawner that + // exists off the shard — a serial is assigned when the world is built — + // and it is what the plugin resolves a target by. + value: r.uniqueId, + label: r.name || r.uniqueId, + // Where it is, because two spawners can share a name and an author + // choosing between them is choosing a place. + group: r.region || r.landmark || r.facet, + })) + }, + }, + { + id: 'uo.options.seasonal', + label: 'Seasonal events', + description: "ServUO's own seasonal events, the ones whose status actually does something.", + async resolve() { + // **Held here rather than read from the shard, and eight rather than + // nine.** `EventType` is a compile-time enum in ServUO, so it does not + // change under a running shard and there is nothing to import; and + // `TreasuresOfTokuno` is left out because its `IsActive()` reads its own + // era rather than this status, so leasing it would be a capability that + // lies. The plugin refuses it independently. + return SEASONAL_EVENTS.map((name) => ({ value: name, label: SEASONAL_LABELS[name] || name })) + }, + }, + { + id: 'uo.options.items', + label: 'Grantable items', + description: 'What this shard is willing to hand out as a reward.', + async resolve() { + // Mirrored rather than read live, exactly like the lease bounds: this copy + // is what makes a bad value a refusal on a FORM, and the shard's own copy + // is what is true when this one is wrong. Reading it live would put an + // authoring dropdown behind the shard being up, which §F specifically + // says a source must not do. + return GRANTABLE.map((g) => ({ + value: g.key, + label: g.stackable ? `${g.label} (stacks)` : g.label, + })) + }, + }, ] module.exports = { @@ -1715,6 +2236,12 @@ module.exports = { revertOwned, reconcileOwned, MAX_LEASE_MS, + MAX_PROP_LEASE_MS, + MAX_SPAWN_DELAY_SEC, + MAX_GRANT_STACK, + GRANTABLE, + SEASONAL_EVENTS, + SPAWNER_OPTIONS, PERMANENT_STATUSES, webUserId, landmarkPoint, diff --git a/server/db/schema.sql b/server/db/schema.sql index 0fb9a16..862de6f 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -47,7 +47,7 @@ CREATE TABLE IF NOT EXISTS uo_link_config ( base_url VARCHAR(255) NULL, ws_url VARCHAR(255) NULL, auth_token_enc TEXT NULL, - protocol INT NOT NULL DEFAULT 5, + protocol INT NOT NULL DEFAULT 7, enabled TINYINT(1) NOT NULL DEFAULT 0, status VARCHAR(20) NOT NULL DEFAULT 'disconnected', status_detail VARCHAR(500) NULL, @@ -485,6 +485,13 @@ CREATE TABLE IF NOT EXISTS shard_spawn_points ( id INT AUTO_INCREMENT PRIMARY KEY, facet VARCHAR(40) NOT NULL, name VARCHAR(120) NULL, -- the ServUO spawner's own name + -- `XmlSpawner.UniqueId` (Phase 12b): the only name for one particular spawner + -- that exists OFF the shard. A property lease is targeted by it, because a + -- serial is assigned when the world is built and nothing here could know one -- + -- so without this column the lease's target field could have no dropdown at + -- all. NULLable: a shard's own spawners, added in-world rather than from the + -- spawn files, carry none, and they are addressed by serial instead. + unique_id VARCHAR(64) NULL, x INT NOT NULL, y INT NOT NULL, width INT NOT NULL DEFAULT 0, @@ -500,7 +507,10 @@ CREATE TABLE IF NOT EXISTS shard_spawn_points ( landmark VARCHAR(120) NULL, label VARCHAR(120) NOT NULL DEFAULT 'Wilderness', INDEX idx_shard_spawn_points_facet (facet), - INDEX idx_shard_spawn_points_label (label) + INDEX idx_shard_spawn_points_label (label), + -- The spawner target's dropdown searches by name, and 6,707 rows is more than + -- a dropdown holds, so the search is the read rather than a filter over one. + INDEX idx_shard_spawn_points_name (name) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- The many-to-many between the two above: one spawner commonly carries several @@ -806,3 +816,24 @@ UPDATE uo_link_config SET protocol = 5 WHERE id = 1 AND protocol < 5 AND NOT EXISTS (SELECT 1 FROM settings WHERE `key` = 'uo_link_protocol_5_migrated'); INSERT IGNORE INTO settings (`key`, value) VALUES ('uo_link_protocol_5_migrated', '1'); + +-- 4. The protocol pin again, at 7 -- and this block is a FIX to already-merged +-- code rather than ordinary Phase 12b work. +-- +-- Phase 11a took the wire to 6 and Phase 12a took it to 7, and neither moved +-- this. `uoLinkClient` sends `X-UOLink-Version: ` on every call and +-- the sidecar answers an exact mismatch with a 409, so a deployment that installed +-- this module at any point since Phase 10 would have had EVERY sidecar call +-- refused against a protocol-7 sidecar -- the whole event plane dead, loudly but +-- for a reason nobody would look here for. +-- +-- It survived two phases because both live walks set the column by hand while +-- standing the rig up, which is exactly the shape of a migration nobody runs. +-- One block carries an install the whole way rather than one per missed version: +-- `protocol < 7` is deliberate, and it is why the 4 and 5 blocks above wrote +-- `< n` rather than `= n-1`. +ALTER TABLE uo_link_config MODIFY COLUMN protocol INT NOT NULL DEFAULT 7; +UPDATE uo_link_config SET protocol = 7 + WHERE id = 1 AND protocol < 7 + AND NOT EXISTS (SELECT 1 FROM settings WHERE `key` = 'uo_link_protocol_7_migrated'); +INSERT IGNORE INTO settings (`key`, value) VALUES ('uo_link_protocol_7_migrated', '1'); diff --git a/server/model/shardAtlas/shardAtlas.db.js b/server/model/shardAtlas/shardAtlas.db.js index 0380d10..f411016 100644 --- a/server/model/shardAtlas/shardAtlas.db.js +++ b/server/model/shardAtlas/shardAtlas.db.js @@ -121,13 +121,14 @@ async function replaceAtlas(atlas, art = {}) { counts.points = await insertBatched( conn, 'INSERT INTO shard_spawn_points ' + - '(id, facet, name, x, y, width, height, spawn_range, max_count, min_delay, max_delay, ' + + '(id, facet, name, unique_id, x, y, width, height, spawn_range, max_count, min_delay, max_delay, ' + 'tod_start, tod_end, tod_mode, region, landmark, label) ' + - 'VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)', + 'VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)', atlas.points.map((p, i) => [ i + 1, p.facet, p.name, + p.uniqueId || null, p.x, p.y, p.width ?? 0, @@ -391,6 +392,41 @@ function listDecorTypes({ q = '' } = {}) { ) } +/** + * Spawners an author can name, searched by name and bounded (Phase 12b). + * + * **A search rather than a list, and the numbers are why.** This tree has 6,707 + * spawn points against a 2,000-entry dropdown bound, so a flat read would drop + * two thirds of the world and say nothing about which two thirds — the failure + * Phase 12a named for decoration, arriving for real. `resolveOptionSource` grew + * a `q` for this. + * + * Only rows with a `unique_id` are offered: that is the only name for a spawner + * that exists off the shard, and a row without one cannot be targeted from a + * form however it is labelled. A shard's own in-world spawners have none and are + * addressed by serial, which an author types rather than picks. + * + * Ordered by `max_count DESC` so the spawners worth an event's attention come + * first, with a stable alphabetical tiebreak for a form somebody scrolls. + */ +function listSpawners({ q = '', limit = 200 } = {}) { + const where = ['unique_id IS NOT NULL', "unique_id <> ''"] + const params = [] + if (q) { + where.push('(name LIKE ? OR region LIKE ? OR landmark LIKE ?)') + params.push(`%${q}%`, `%${q}%`, `%${q}%`) + } + params.push(Number(limit) || 200) + return query( + `SELECT unique_id, name, facet, region, landmark, max_count + FROM shard_spawn_points + WHERE ${where.join(' AND ')} + ORDER BY max_count DESC, name ASC + LIMIT ?`, + params, + ) +} + /** One decoration type, or nothing when this shard's files never name it. */ async function getDecorType(type) { const rows = await query( @@ -432,6 +468,7 @@ module.exports = { listRegions, listLandmarks, listDecorTypes, + listSpawners, getDecorType, listChampions, } diff --git a/server/model/shardAtlas/shardAtlas.model.js b/server/model/shardAtlas/shardAtlas.model.js index 20423cd..fb40b84 100644 --- a/server/model/shardAtlas/shardAtlas.model.js +++ b/server/model/shardAtlas/shardAtlas.model.js @@ -420,6 +420,27 @@ async function listDecorTypes(opts = {}) { })) } +/** + * Spawners an author can name, searched (Phase 12b). + * + * The value is the `UniqueId` because that is what the shard resolves a target + * by; the label is the spawner's own name, which is what an author recognises + * ("fel bulbous putrification" is a place they know). A row with no name still + * answers, labelled by its id, rather than being dropped: a nameless spawner is + * still a spawner somebody may need to turn down. + */ +async function listSpawners(opts = {}) { + const rows = await db.listSpawners(opts) + return rows.map((r) => ({ + uniqueId: r.unique_id, + name: r.name || null, + facet: r.facet, + region: r.region || null, + landmark: r.landmark || null, + maxCount: Number(r.max_count) || 0, + })) +} + /** * One decoration type, or null. * @@ -518,6 +539,7 @@ module.exports = { listRegions, listLandmarks, listDecorTypes, + listSpawners, getDecorType, listChampions, listFacets, diff --git a/server/model/uoLinkConfig/uoLinkConfig.model.js b/server/model/uoLinkConfig/uoLinkConfig.model.js index fe45ddd..39d82a3 100644 --- a/server/model/uoLinkConfig/uoLinkConfig.model.js +++ b/server/model/uoLinkConfig/uoLinkConfig.model.js @@ -11,16 +11,28 @@ const { secretBox } = require('../../core') // Only used before an admin has saved anything — the stored row wins once it exists, // and UOLINK_PROTOCOL still overrides for an operator running an older sidecar. // -// This says 5 because this build handles protocol 5's frames: house.decay's `schedule`, -// vendor.listing's `ownerAcct` + `fees`, and the new `account.login.result` kind. +// This says 7 because this build speaks protocol 7: the idempotency key and the +// participation ledger (6), and the world verbs plus the targeted lease planes (7). // -// It said 4 before that, and 3 for a while after protocol 4 shipped — which is the bug -// this constant is now the fix for. A FRESH install pinned 3, the sidecar answered +// It said 4 before 5, and 3 for a while after protocol 4 shipped — which is the bug this +// constant was introduced to fix. A FRESH install pinned 3, the sidecar answered // `409 protocol version mismatch` to every REST call, and a new deployment read nothing -// from its shard until an admin edited the number by hand in Admin → Shard. Bumping it -// in the SAME change as the emitters is the discipline that prevents a repeat; see the -// matching cutover in db/schema.sql. -const DEFAULT_PROTOCOL = Number(process.env.UOLINK_PROTOCOL) || 5 +// from its shard until an admin edited the number by hand in Admin → Shard. +// +// **And it happened again, twice, in Phases 11a and 12a** — this constant and the two in +// `db/schema.sql` all sat at 5 while the wire went to 6 and then 7, so every sidecar call +// on a real deployment would have been refused. Both live walks set the column by hand +// while standing the rig up, which is exactly what makes a migration nobody runs +// invisible. Phase 12b carries all three to 7. +// +// **Nothing in this repo can check this against the wire**, and that is worth knowing +// before trusting the test that guards it: `schemaFragment.test.js` asserts the three +// declarations agree WITH EACH OTHER, which is a real check — they drifted apart once — +// but all three being equally stale passes it. The wire's version lives in `link` +// (`PROTOCOL_VERSION`) and the overlay's in `servuo-plugins/overlay.toml`; the thing that +// actually pairs them is the installer's bundle check, at deploy time. So bumping this in +// the same change as the emitters is still the discipline, and no test here replaces it. +const DEFAULT_PROTOCOL = Number(process.env.UOLINK_PROTOCOL) || 7 function toSafe(row) { if (!row) { diff --git a/server/test/entry.test.js b/server/test/entry.test.js index 7efa3f3..523c991 100644 --- a/server/test/entry.test.js +++ b/server/test/entry.test.js @@ -67,11 +67,13 @@ test('registers exactly what module.json declares', () => { 'uo.creature.spawn', 'uo.decor.place', 'uo.gate.open', + 'uo.item.grant', 'uo.news.post', 'uo.npc.place', 'uo.participation.collect', 'uo.participation.open', 'uo.towncrier.post', + 'uo.world.save', ], ) // Phase 12a's five are all the MODULE's dimensions, never core's (org lead, @@ -85,18 +87,45 @@ test('registers exactly what module.json declares', () => { 'uo.npcs', 'uo.decor', 'uo.gate.minutes', + 'uo.rewards', ]) // Phase 11b. One key, because ServUO has almost no others: of the 158 non-Bridge // `Config.Get` call sites in `Scripts/`, roughly eight are read live, and a lease // on any of the rest applies cleanly and does nothing. - assert.deepStrictEqual(api.record.eventLeases.map((l) => l.id), ['uo.playercaps.skillcap']) + // Phase 12b adds five TARGETED leases beside it -- a key that names a capability + // over many things, with the target supplied per step. Four spawner properties + // (`MaxCount`, not the `Amount` EVENTS_PLAN.md named: there is no such property + // on ServUO 57.4) and the seasonal status, which is a three-value enum over eight + // events rather than the nine-value one section G described. + assert.deepStrictEqual(api.record.eventLeases.map((l) => l.id), [ + 'uo.playercaps.skillcap', + 'uo.spawner.maxcount', + 'uo.spawner.mindelay', + 'uo.spawner.maxdelay', + 'uo.spawner.running', + 'uo.seasonal.status', + ]) + // Only the targeted ones declare a target, and every one of them names a source: + // a target field with no list behind it is the free-text box the option-source + // contract exists to replace. + for (const lease of api.record.eventLeases) { + if (lease.id === 'uo.playercaps.skillcap') { + assert.strictEqual(lease.target, undefined, 'a config lease has no target') + continue + } + assert.ok(lease.target && lease.target.label, `${lease.id} has no target label`) + assert.ok(lease.target.source, `${lease.id} has no target source`) + } assert.deepStrictEqual( api.record.eventOptionSources.map((s) => s.id).sort(), [ 'uo.options.creatures', 'uo.options.decor', + 'uo.options.items', 'uo.options.landmarks', 'uo.options.regions', + 'uo.options.seasonal', + 'uo.options.spawners', ], ) assert.ok(api.record.streams.length > 0) diff --git a/server/test/spawnAtlas.parse.test.js b/server/test/spawnAtlas.parse.test.js index ffe3de6..5db10a6 100644 --- a/server/test/spawnAtlas.parse.test.js +++ b/server/test/spawnAtlas.parse.test.js @@ -145,9 +145,14 @@ test('parsePoints: reads the kept fields and drops the rest', () => { assert.equal(covetous.minDelay, 300) assert.equal(covetous.maxDelay, 600) assert.deepEqual(covetous.types, [{ type: 'Lizardman', max: 3 }]) - // Dropped fields must not survive into the artifact — this is what keeps it - // under 1 MB. - assert.equal(covetous.uniqueId, undefined) + // **The UniqueId is KEPT from Phase 12b**, having been dropped since the atlas + // shipped. It is `XmlSpawner.UniqueId` — carried in the spawn files and on the + // live spawner — so it is the only name for one particular spawner that exists + // off the shard, and a property lease targets by it. A serial cannot do that + // job: serials are assigned when the world is built and nothing here knows one. + assert.equal(covetous.uniqueId, '001a34e5-0efa-46de-9c93-b6a163d96370') + // The rest of the dropped fields still are. Triggering, refractory windows, + // proximity and sounds are what the site has no use for. assert.equal(covetous.proximityTriggerSound, undefined) }) diff --git a/server/test/uoEventActions.test.js b/server/test/uoEventActions.test.js index fbd3b9a..1067166 100644 --- a/server/test/uoEventActions.test.js +++ b/server/test/uoEventActions.test.js @@ -159,12 +159,24 @@ test('the declarations satisfy the shape core validates them with', () => { test('every dimension a cost names is one this module declares', () => { const declared = new Set(actions.BUDGETS.map((b) => b.id)) - // Phase 12a. All six are the MODULE's (org lead, 2026-09-07): core meters what - // a module declares and holds no UO knowledge, so a `uo.` dimension core knew - // about would be a leak of this game into the engine. + // Phase 12a's six and Phase 12b's seventh are all the MODULE's (org lead, + // 2026-09-07): core meters what a module declares and holds no UO knowledge, so + // a `uo.` dimension core knew about would be a leak of this game into the engine. + // + // `uo.rewards` counts ITEMS rather than grants: a step giving 500 gold to forty + // people and one giving a candle to forty people are not the same imposition, and + // a count of grants would price them identically. assert.deepEqual( [...declared], - ['uo.broadcasts', 'uo.creatures', 'uo.bosses', 'uo.npcs', 'uo.decor', 'uo.gate.minutes'], + [ + 'uo.broadcasts', + 'uo.creatures', + 'uo.bosses', + 'uo.npcs', + 'uo.decor', + 'uo.gate.minutes', + 'uo.rewards', + ], ) for (const b of actions.BUDGETS) { assert.ok(b.id.startsWith('uo.'), 'a budget dimension must be namespaced') diff --git a/server/test/uoEventBorrowed.test.js b/server/test/uoEventBorrowed.test.js new file mode 100644 index 0000000..d620c2a --- /dev/null +++ b/server/test/uoEventBorrowed.test.js @@ -0,0 +1,349 @@ +// module-uo's half of protocol 7 part b (EVENTS_PLAN.md Phase 12b). +// +// What an event BORROWS — five targeted leases over two planes — and the two +// one-shots that are neither borrowed nor owned. +// +// The tests below are the places where the obvious implementation is subtly the +// wrong one and nothing would fail if it were written the other way: +// +// • every callable of a targeted lease must PASS THE TARGET ON. A read that +// dropped it would answer about the wrong spawner, and a restore that +// dropped it would write a baseline onto one +// • a target the shard can no longer read is a REFUSAL at apply time, never a +// value: taking the lease anyway records a fictional baseline and later +// writes it onto whatever next holds that id +// • a target that vanished mid-run is a SUCCESSFUL restore, not a failure — +// there is nothing to give back, and reporting it failed leaves a ledger row +// unresolved for ever over an object that is gone +// • `inForce()` reads the frame's `holds`, which is the only thing that can +// answer for a targeted key: there is no list of spawners to walk +// • a grant that reached NOBODY is a success, because an event nobody attended +// still happened — while a run the shard was never told to count is a 404 +// • a non-stackable granted in quantity is refused at BOTH ends + +const { test, beforeEach, afterEach } = require('node:test') +const assert = require('node:assert/strict') + +const uoLinkClient = require('../utils/uoLinkClient') +const shardAtlas = require('../model/shardAtlas/shardAtlas.model') +require('./_setup') +const actions = require('../config/uoEventActions') + +const byId = (id) => actions.ACTIONS.find((a) => a.id === id) +const leaseById = (id) => actions.LEASES.find((l) => l.id === id) + +const STUBBED = ['getLeases', 'applyLease', 'releaseLease', 'grantItem', 'saveWorld'] + +let calls +let frame +const saved = {} + +beforeEach(() => { + calls = { leases: [], apply: [], release: [], grant: [], save: [] } + frame = { + leases: [{ key: 'Spawner.MaxCount', kind: 'property', current: '3', held: false }], + holds: [], + } + for (const name of STUBBED) saved[name] = uoLinkClient[name] + saved.listSpawners = shardAtlas.listSpawners + + uoLinkClient.getLeases = async (q) => { + calls.leases.push(q) + return { ok: true, status: 200, data: frame } + } + uoLinkClient.applyLease = async (b) => { calls.apply.push(b); return { ok: true, status: 200, data: {} } } + uoLinkClient.releaseLease = async (b) => { calls.release.push(b); return { ok: true, status: 200, data: {} } } + uoLinkClient.grantItem = async (b) => { + calls.grant.push(b) + return { ok: true, status: 200, data: { granted: 2, missed: [] } } + } + uoLinkClient.saveWorld = async (b) => { calls.save.push(b); return { ok: true, status: 200, data: {} } } + shardAtlas.listSpawners = async (opts) => { + calls.spawners = opts + return [ + { uniqueId: 'uid-1', name: 'fel orc fort', facet: 'Felucca', region: 'Britain', maxCount: 9 }, + { uniqueId: 'uid-2', name: null, facet: 'Trammel', region: null, landmark: null, maxCount: 1 }, + ] + } +}) + +afterEach(() => { + for (const name of STUBBED) uoLinkClient[name] = saved[name] + shardAtlas.listSpawners = saved.listSpawners +}) + +// ── The targeted leases ──────────────────────────────────────────────────── + +test('every callable carries the target through to the shard', async () => { + // The one thing that cannot be got wrong quietly. Core composes the ledger ref + // as `#` and hands the target back on every call; a callable + // that ignored it would read, apply to and restore whichever spawner the shard + // happened to answer about, and nothing here or there would report an error. + const lease = leaseById('uo.spawner.maxcount') + const target = '003f11b8-9bfa-4587-991e-ca263004efe6' + + const read = await lease.read({ target }) + assert.deepEqual(read, { ok: true, value: '3' }) + assert.deepEqual(calls.leases[0], { key: 'Spawner.MaxCount', target }) + + await lease.apply('30', new Date(Date.now() + 600_000), { target }) + assert.equal(calls.apply[0].key, 'Spawner.MaxCount') + assert.equal(calls.apply[0].target, target) + // A DURATION, not the deadline — 11b's rule, unchanged by targeting. A shard + // whose clock runs fast would restore an absolute deadline the instant it + // took it. + assert.ok(calls.apply[0].holdMs > 0 && calls.apply[0].holdMs <= 600_000) + + await lease.restore('3', { expected: '30', target }) + assert.deepEqual(calls.release[0], { + key: 'Spawner.MaxCount', + target, + expected: '30', + baseline: '3', + }) +}) + +test('a target the shard cannot read refuses the lease rather than defaulting', async () => { + // The failure this guards is silent and permanent: a lease taken over a + // spawner that is not there records whatever came back as the baseline, and + // teardown then WRITES that baseline onto whatever next holds the id. + frame.leases = [{ key: 'Spawner.MaxCount', unreadable: "nothing on this shard has serial 0x99" }] + const refused = await leaseById('uo.spawner.maxcount').read({ target: '0x99' }) + assert.equal(refused.ok, false) + assert.match(refused.error, /nothing on this shard has serial/) + + // A row with neither a value nor a reason is refused too. The shard should + // always send one of them, and "it sent neither" must not read as zero. + frame.leases = [{ key: 'Spawner.MaxCount' }] + const empty = await leaseById('uo.spawner.maxcount').read({ target: 'uid-1' }) + assert.equal(empty.ok, false) + assert.match(empty.error, /could not read/) +}) + +test('a target that vanished mid-run is a successful restore, not a failure', async () => { + // 12a's `gone` in the lease plane's vocabulary. Somebody deleted the spawner + // while the run held it: there is nothing to give back and nothing is owed. + // Reported as a failure it would sit in the ledger unresolved for ever, over + // an object that no longer exists — and every sweep would try again. + uoLinkClient.releaseLease = async () => ({ + ok: true, + status: 200, + data: { kind: 'lease.ok', released: true, targetGone: true, reason: 'that object has been deleted' }, + }) + const done = await leaseById('uo.spawner.maxcount').restore('3', { expected: '30', target: 'uid-1' }) + assert.deepEqual(done, { ok: true }) +}) + +test('drift is still drift, and is still not an error', async () => { + // Unchanged from 11b and asserted again because targeting rewrote the whole + // callable: core records drift as a distinct SUCCESSFUL outcome, so an error + // here would put the row on the retry ladder and eventually report the lease + // as vanished rather than as somebody having moved it. + uoLinkClient.releaseLease = async () => ({ + ok: true, + status: 200, + data: { kind: 'lease.drifted', current: '12' }, + }) + const drifted = await leaseById('uo.spawner.maxcount').restore('3', { expected: '30', target: 'uid-1' }) + assert.deepEqual(drifted, { ok: false, drifted: true, current: '12' }) +}) + +test('inForce reads the holds list, which is the only thing that can answer', async () => { + // A catalog walk can enumerate the KEYS but never the holds on a targeted one + // — there is no list of spawners to walk — so the frame carries every hold the + // shard has, and this is what reads it. + const lease = leaseById('uo.spawner.maxcount') + + assert.deepEqual(await lease.inForce({ target: 'uid-1' }), { ok: true, held: false }) + + frame.holds = [{ key: 'Spawner.MaxCount', target: 'uid-1', runId: '7' }] + assert.deepEqual(await lease.inForce({ target: 'uid-1' }), { ok: true, held: true }) + // ...and it is the hold on THIS target, not any hold on the key. A run holding + // one spawner must not make every other spawner look leased. + assert.deepEqual(await lease.inForce({ target: 'uid-2' }), { ok: true, held: false }) +}) + +test('a shard that cannot answer is never read as "the lease is gone"', async () => { + // Core's posture everywhere: "I could not ask" must not be recorded as "it is + // gone", because the second orphans the row and stops teardown ever trying. + uoLinkClient.getLeases = async () => ({ ok: false, status: 503, data: null }) + const answer = await leaseById('uo.spawner.maxcount').inForce({ target: 'uid-1' }) + assert.equal(answer.ok, false) +}) + +test('the seasonal lease is a three-value enum over eight events', () => { + // §G called `SeasonalEventSystem.GetEntry(type).Status` "a nine-value enum" and + // had it backwards: `EventStatus` has three values, `EventType` has nine + // entries — and one of those nine is excluded, so it is eight. + const lease = leaseById('uo.seasonal.status') + assert.equal(lease.type, 'string') + assert.deepEqual(lease.values, ['Inactive', 'Active', 'Seasonal']) + assert.equal(actions.SEASONAL_EVENTS.length, 8) + // TreasuresOfTokuno reads its own era rather than this status, so leasing it + // would apply cleanly and change nothing — §N10's "a capability that lies", + // and the one instance no runtime probe can catch. + assert.ok(!actions.SEASONAL_EVENTS.includes('TreasuresOfTokuno')) +}) + +test('every targeted lease bounds what it can hold', () => { + // §F requires a range on the numeric types because, unlike a cap, a bad lease + // value is in force the moment it is applied. Restated over the five because + // they are built by a shared factory: one missing bound would be missing in a + // way no single declaration shows. + for (const lease of actions.LEASES) { + if (lease.id === 'uo.playercaps.skillcap') continue + assert.ok(lease.maxDurationMs > 0, `${lease.id} has no duration bound`) + if (lease.type === 'int' || lease.type === 'float') { + assert.ok(Number.isFinite(lease.min) && Number.isFinite(lease.max), `${lease.id} has no range`) + assert.ok(lease.min <= lease.max, `${lease.id} has min above max`) + } + if (lease.type === 'string') { + assert.ok(Array.isArray(lease.values) && lease.values.length, `${lease.id} has no value set`) + } + } +}) + +// ── The spawner source ───────────────────────────────────────────────────── + +test('the spawner source searches, and says so', async () => { + // The first source with more entries than a dropdown holds: 6,707 spawn points + // against MAX_OPTIONS' 2,000. A flat list would drop two thirds of the world + // and say nothing about which two thirds. + const source = actions.OPTION_SOURCES.find((s) => s.id === 'uo.options.spawners') + assert.equal(source.searchable, true) + + const rows = await source.resolve({ q: 'orc' }) + assert.equal(calls.spawners.q, 'orc') + assert.equal(calls.spawners.limit, actions.SPAWNER_OPTIONS) + + // The value is the UniqueId, because it is the only name for one particular + // spawner that exists off the shard. + assert.deepEqual(rows[0], { value: 'uid-1', label: 'fel orc fort', group: 'Britain' }) + // A nameless spawner still answers, labelled by its id. It is still a spawner + // somebody may need to turn down, and dropping it would be a dropdown quietly + // missing rows again. + assert.deepEqual(rows[1], { value: 'uid-2', label: 'uid-2', group: 'Trammel' }) +}) + +// ── The one-shots ────────────────────────────────────────────────────────── + +test('a grant sends a run and never a recipient list', async () => { + // The shard has held this run's participation ledger since it opened, keyed by + // the same serials core stores as `member_key`. Sending a list would put it on + // the wire twice with a window in which the two disagree — and would have + // needed a core surface handing a module core's own participants. + const out = await byId('uo.item.grant').perform({ + runId: 7, + idempotencyKey: 'k', + params: { item: 'gold', amount: 500, where: 'bank' }, + }) + assert.equal(out.ok, true) + assert.deepEqual(calls.grant[0], { + runId: 7, + item: 'gold', + amount: 500, + hue: undefined, + name: undefined, + where: 'bank', + idempotencyKey: 'k', + }) + assert.equal(out.detail.granted, 2) +}) + +test('a grant that reached nobody is a success', async () => { + // An event nobody attended still happened. Reported as a failure the run would + // retry against a ledger that will be just as empty next time, and pause. The + // shard draws the distinction that matters: a run it was never told to count + // is a 404, which fails below. + uoLinkClient.grantItem = async () => ({ ok: true, status: 200, data: { granted: 0, missed: [] } }) + const out = await byId('uo.item.grant').perform({ + runId: 7, + idempotencyKey: 'k', + params: { item: 'gold', amount: 1 }, + }) + assert.equal(out.ok, true) + assert.equal(out.detail.granted, 0) + + uoLinkClient.grantItem = async () => ({ + ok: false, + status: 404, + data: { reason: 'run 7 has no participation ledger open on this shard' }, + }) + const missing = await byId('uo.item.grant').perform({ + runId: 7, + idempotencyKey: 'k', + params: { item: 'gold', amount: 1 }, + }) + assert.equal(missing.ok, false) + // 404 is permanent: the ledger will not appear because we asked again. + assert.equal(missing.retry, false) +}) + +test('a non-stackable granted in quantity is refused before the wire', async () => { + // Five cloaks would be five items — five chances to overflow a backpack + // halfway through with no way to say which half landed. Refused here so the + // author sees it on the form, and refused again on the shard because this copy + // of the allowlist is the one that can be wrong. + const out = await byId('uo.item.grant').perform({ + runId: 7, + idempotencyKey: 'k', + params: { item: 'cloak', amount: 3 }, + }) + assert.equal(out.ok, false) + assert.equal(out.retry, false) + assert.match(out.error, /does not stack/) + assert.equal(calls.grant.length, 0) + + const unknown = await byId('uo.item.grant').perform({ + runId: 7, + idempotencyKey: 'k', + params: { item: 'castle', amount: 1 }, + }) + assert.equal(unknown.ok, false) + assert.equal(unknown.retry, false) + assert.equal(calls.grant.length, 0) +}) + +test('a grant is retryable, and protocol 6 is the reason', async () => { + // §G called a grant un-retryable because a lost acknowledgement and a grant + // that never applied were the same event — the argument that made + // `uo.broadcast` answer `retry: false` in Phase 9. An idempotency key closes + // it: a repeat is answered by the original reply, so a retried grant cannot be + // one winner receiving two. + uoLinkClient.grantItem = async () => ({ ok: false, status: 503, data: null }) + const out = await byId('uo.item.grant').perform({ + runId: 7, + idempotencyKey: 'k', + params: { item: 'gold', amount: 1 }, + }) + assert.equal(out.ok, false) + assert.notEqual(out.retry, false) + // And the action declares itself irreversible, which is the honest class: the + // world is altered and cannot be put back. + assert.equal(byId('uo.item.grant').risk, 'irreversible') + assert.equal(byId('uo.item.grant').reversible, 'none') +}) + +test('a save refused for coming too soon is retried, not abandoned', async () => { + // 429 is the shard's rate limit and is the one refusal on this plane that + // waiting fixes. It is deliberately not in PERMANENT_STATUSES, so a phase + // boundary is retried rather than dropped. + assert.ok(!actions.PERMANENT_STATUSES.has(429)) + uoLinkClient.saveWorld = async () => ({ + ok: false, + status: 429, + data: { reason: 'this shard saves at most every 300 seconds, and the last save was 12 seconds ago' }, + }) + const out = await byId('uo.world.save').perform({ idempotencyKey: 'k' }) + assert.equal(out.ok, false) + assert.notEqual(out.retry, false) +}) + +test('a save reports only that it started', async () => { + // What actually happened rides `world.save.before`/`after` on the event stream. + // Asserting anything more here would be asserting something the reply does not + // know. + const out = await byId('uo.world.save').perform({ idempotencyKey: 'k' }) + assert.deepEqual(out, { ok: true, detail: { started: true } }) + assert.deepEqual(calls.save[0], { idempotencyKey: 'k' }) +}) diff --git a/server/utils/spawnAtlasParse.js b/server/utils/spawnAtlasParse.js index 6bdd393..d7d852f 100644 --- a/server/utils/spawnAtlasParse.js +++ b/server/utils/spawnAtlasParse.js @@ -351,10 +351,17 @@ function tagValue(block, name) { * ~40 fields on every one of ~6,500 records to keep 14 of them. The records are * flat, so a per-record regex sweep is both correct and cheap. * - * Only the fields the site can actually show are kept. Everything to do with - * triggering, refractory windows, proximity, sequential spawning, sounds and - * `UniqueId` is dropped here rather than downstream — that is what holds the - * committed artifact under 1 MB. + * Only the fields the site can actually use are kept. Everything to do with + * triggering, refractory windows, proximity, sequential spawning and sounds is + * dropped here rather than downstream, which is what keeps the parsed atlas + * small. + * + * **`UniqueId` was on that list until Phase 12b and is now kept**, because a + * property lease has to name one particular spawner and this is the only name + * for one that exists off-shard. The line that justified dropping it cited a + * committed artifact; there is no committed artifact — `spawnAtlasSource.js` + * says so in its own header ("nothing is precomputed and committed") — so the + * only real cost was ~37 bytes a row in a table, and it bought a dropdown. * * NOTE: the facet comes from each record's own ``, never from the file * name. `Eodon.xml`, `GravewaterLake.xml` and the other named-area files all @@ -389,6 +396,14 @@ function parsePoints(source) { points.push({ name: tagValue(block, 'Name'), + // **Kept from Phase 12b, having been discarded since the atlas shipped.** + // It is `XmlSpawner.UniqueId` — the shard writes it into the spawn files + // and carries it on the live spawner — so it is the ONE way an authoring + // form can name a particular spawner without the shard being up. A serial + // cannot do that job: serials are assigned when the world is built and + // nothing off-shard knows them, which is why a property lease that could + // only be addressed by serial could have no dropdown at all. + uniqueId: tagValue(block, 'UniqueId'), facet, x: toInt(tagValue(block, 'X')), y: toInt(tagValue(block, 'Y')), diff --git a/server/utils/spawnAtlasSource.js b/server/utils/spawnAtlasSource.js index 6e19e73..78ef0b2 100644 --- a/server/utils/spawnAtlasSource.js +++ b/server/utils/spawnAtlasSource.js @@ -175,8 +175,13 @@ function hashSources(root) { * * 2 — respawn delays normalised to seconds (they are per-record minutes OR * seconds in the source, decided by `DelayInSec`). + * 3 — the decoration index, from `Data/Decoration/**\/*.cfg`. + * 4 — a spawn point keeps its `UniqueId`, which is what a property lease + * targets (Phase 12b). The bump is what re-reads a tree the boot path + * would otherwise skip on an unchanged hash — the source files have not + * changed, only what is kept from them. */ -const PARSER_VERSION = 3 +const PARSER_VERSION = 4 /** True when two source fingerprints describe the same tree. */ function sameSources(a, b) { diff --git a/server/utils/uoLinkClient.js b/server/utils/uoLinkClient.js index aeff836..16d4cd6 100644 --- a/server/utils/uoLinkClient.js +++ b/server/utils/uoLinkClient.js @@ -236,28 +236,45 @@ const adminBroadcast = ({ actor, text, hue, idempotencyKey }) => // 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') +// **A targeted lease must name its target here** (protocol 7 part b). A key like +// `Spawner.MaxCount` is one capability over thousands of spawners, so it has no +// single `current` and the catalog walk cannot fill one in — while `read()` needs +// exactly one value for exactly one target before it applies anything. Naming both +// narrows the frame to that row and fills it. +// +// The frame also carries `holds`: every hold this shard has, whatever key or +// target. A catalog walk enumerates the KEYS but can never enumerate the holds on +// a targeted one — there is no list of spawners to walk — so `inForce()` reads +// that rather than the row's `held` flag. +const getLeases = ({ key, target } = {}) => { + const params = new URLSearchParams() + if (key) params.set('key', key) + if (target) params.set('target', target) + const query = params.toString() + return call(query ? `/lease?${query}` : '/lease') +} // `holdMs` is authoritative and `untilMs` is display only. An absolute deadline // computed here and honoured there is a deadline measured against two clocks, and // a shard running ten minutes fast would restore a ten-minute lease the moment it // took it. Values cross as TEXT whatever the lease's declared type: `1200` and // `1200.0` are one number to a JSON parser and two strings to a compare-and-set. -const applyLease = ({ key, value, holdMs, untilMs, runId, idempotencyKey }) => +const applyLease = ({ key, target, value, holdMs, untilMs, runId, idempotencyKey }) => call('/lease', { method: 'POST', - body: { key, value: String(value), holdMs, untilMs, runId, idempotencyKey }, + body: { key, target, value: String(value), holdMs, untilMs, runId, idempotencyKey }, }) // `expected` is what this run applied and `baseline` is what to put back, both out // of core's ledger rather than the shard's memory — so a release still works after // a reconnect, and a shard that has forgotten the lease entirely (a restart, which // reverts every config lease by design) answers honestly instead of refusing. -const releaseLease = ({ key, expected, baseline, idempotencyKey }) => +const releaseLease = ({ key, target, expected, baseline, idempotencyKey }) => call('/lease/release', { method: 'POST', body: { key, + target, expected: expected == null ? undefined : String(expected), baseline: baseline == null ? undefined : String(baseline), idempotencyKey, @@ -324,6 +341,35 @@ const respondPage = (pageId, { message, close }) => call(`/pages/${encodeURIComponent(pageId)}/respond`, { method: 'POST', body: { message, close } }) const closePage = (pageId) => call(`/pages/${encodeURIComponent(pageId)}/close`, { method: 'POST' }) +// ── The one-shots (protocol 7 part b, EVENTS_PLAN.md Phase 12b) ──────────── +// +// Neither owned nor borrowed: done is done. Both are gated on the shard by the +// same `Bridge.EventsEnabled` as the rest of the plane. + +// What this shard will actually build, with the bounds it will build within. The +// module holds the same allowlist for its dropdown, so the form still works with +// the shard down; this is what is true when that copy is wrong. +const getGrantCatalog = () => call('/items') + +// **The recipients are not sent.** The shard has held this run's participation +// ledger since it opened, keyed by the same character serials core stores as +// `member_key`, so the grant names a run and the shard resolves who was there. +// Sending a list would put the same list on the wire twice with a window in which +// the two disagree — and would have needed a core surface handing a module core's +// own participants. +const grantItem = ({ runId, item, amount, hue, name, where, idempotencyKey }) => + call('/items/grant', { + method: 'POST', + body: { runId: String(runId), item, amount, hue, name, where, idempotencyKey }, + }) + +// Starts a save. What actually happened rides `world.save.before`/`after` on the +// event stream, which have been there since protocol 2 — so this asserts only that +// the save was started, and a caller that needs the completion watches the feed it +// is already connected to. +const saveWorld = ({ idempotencyKey } = {}) => + call('/world/save', { method: 'POST', body: { idempotencyKey } }) + module.exports = { TIMEOUT_MS, invalidateConfig, @@ -361,6 +407,9 @@ module.exports = { spawnWorld, ownedWorld, despawnWorld, + getGrantCatalog, + grantItem, + saveWorld, adminKick, adminBan, adminUnban, -- 2.49.1 From 8def6e19f485d0b4a3a1d32435bea8ba95b155a4 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 9 Sep 2026 08:27:50 -0500 Subject: [PATCH 7/8] fix(events): the atlas import, and a teardown that was a no-op (Phase 16a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects the acceptance walk found in shipped code, both invisible to the suites that were green on either side of them. **The spawn atlas cannot import on a stock ServUO tree.** `spawnAtlasSource.js` dedupes decoration types with a case-SENSITIVE `Map`, but `shard_decor_types.type` is a PRIMARY KEY under MariaDB's default `..._ai_ci` collation, which folds case. Stock 57.4's own `Data/Decoration/` names four types under two spellings each (CheckerBoard/Checkerboard, ChessBoard/Chessboard, MetalChest/Metalchest, SpinningWheelEastAddon/SpinningwheelEastAddon), and in every pair exactly one is a real class. The second row raised `1062 Duplicate entry` and took the WHOLE import transaction down. The blast radius is not decoration: with no atlas, EVERY option source answers empty and no Phase 12 world verb can be authored at all. The shard end already knew — `BridgeWorld.cs` resolves a decor type with `FindTypeByName(name, ignoreCase: true)` and its comment says the atlas and the decoration files disagree about casing. Folding here is the two ends agreeing. **Teardown of every world verb was a no-op that reported success.** `revertOwned` forwarded core's `idempotencyKey` as the despawn's OWN key — and core's key is the step's, the one `placeOwned` spawned under. `BridgeIdempotency` keys on the key alone, so the despawn was taken for a repeat and answered with the SPAWN's stored reply; `OnDespawn` never ran. Core read `ok` with no `refused` and marked every row `reverted` while the shard still held every object. Measured on the rig: ledger `world | reverted | 21`, shard `world.owned` 21 alive with `pruned: 0`, and the identical despawn re-sent with a fresh key removed all 21. It affected all five world verbs, so an invasion's creatures, boss, oracle, gate and decoration stayed in the world for ever while the console reported a clean teardown. `MODULE_API.md` says what that key is for and it is not this: it identifies a dispatch core never learned the outcome of, so the module can ask about it. No key is needed on a despawn — a repeat answers `gone`, which both ends already treat as success — and dropping it also makes the documented empty-`resources` case work, since no serials means "everything this run owns". The parameter is removed from `despawnWorld`'s signature rather than left optional. Both fixes are verified end to end against a real ServUO + sidecar + website rig: the import now yields 309 decor types (was failing at 313 with 4 collisions), 6,455 spawn points, 800 creatures, 558 landmarks; and a full four-phase run's teardown left the shard owning 0 objects. Each new test was confirmed to FAIL without its fix. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4 --- server/config/uoEventActions.js | 21 +++++++++++-- server/test/spawnAtlas.source.test.js | 36 +++++++++++++++++++++ server/test/uoEventActions.test.js | 45 +++++++++++++++++++++++++++ server/utils/spawnAtlasSource.js | 22 +++++++++++-- server/utils/uoLinkClient.js | 12 +++++-- 5 files changed, 130 insertions(+), 6 deletions(-) diff --git a/server/config/uoEventActions.js b/server/config/uoEventActions.js index 674cedb..02dd32b 100644 --- a/server/config/uoEventActions.js +++ b/server/config/uoEventActions.js @@ -481,12 +481,29 @@ async function placeOwned({ runId, idempotencyKey, what, body }) { * shard denies this run ever owned that serial — nothing will ever delete it * through this path, so the row must land unresolved with a reason rather than * be quietly marked reverted. + * + * **The despawn carries NO idempotency key, and that is the whole point.** This + * function used to forward core's `idempotencyKey` as the despawn's own — which + * is the step's key, the very key `placeOwned` spawned under. The shard's + * at-most-once store is keyed on the key ALONE (`BridgeIdempotency.Intercept` + * does `_byKey.TryGetValue(key, …)`, not a lookup by key AND command), so the + * despawn was recognised as a repeat and answered with the SPAWN's stored reply. + * `OnDespawn` never ran, core saw `ok` with no `refused`, and every row was + * marked `reverted` while the shard still held every object. Teardown of all + * five world verbs was a no-op that reported success. + * + * No key is needed here. A repeat despawn is already safe by the handler's own + * three-answer design: the second pass finds the serial gone and answers `gone`, + * which is a success on both ends. `MODULE_API.md` says what core's key is FOR, + * and it is not this — it identifies a dispatch core never learned the outcome + * of, so the module can ask about it. That case arrives here as an EMPTY + * `resources` list, and it is answered correctly by the same call: no serials + * means "everything this run owns", which is exactly the right sweep. */ -async function revertOwned({ runId, resources, idempotencyKey }) { +async function revertOwned({ runId, resources }) { const result = await uoLinkClient.despawnWorld({ runId: String(runId), serials: resources.map((resource) => resource.ref), - idempotencyKey, }) if (!result.ok) return { ok: false, error: sidecarReason(result, 'despawn') } diff --git a/server/test/spawnAtlas.source.test.js b/server/test/spawnAtlas.source.test.js index b22bdd3..efc5ffb 100644 --- a/server/test/spawnAtlas.source.test.js +++ b/server/test/spawnAtlas.source.test.js @@ -440,6 +440,42 @@ test('decoration is read recursively and rolled up per type', () => { } }) +test('two spellings of one decoration type fold into one row', () => { + // The Phase 16 acceptance walk's blocking finding. Stock ServUO 57.4's own + // `Data/Decoration/` names four types under two casings each — + // CheckerBoard/Checkerboard, ChessBoard/Chessboard, MetalChest/Metalchest, + // SpinningWheelEastAddon/SpinningwheelEastAddon — and in every pair exactly one + // is a real class; the other is a mis-cased line the shard's own loader resolves + // anyway. + // + // A case-SENSITIVE Map keeps both. `shard_decor_types.type` is a PRIMARY KEY + // under MariaDB's default `..._ai_ci` collation, which folds case, so the second + // row raised `1062 Duplicate entry` and took the WHOLE atlas import transaction + // down with it. The blast radius is not decoration: with no atlas, EVERY option + // source answers empty and no world verb can be authored at all. + // + // Asserted on the count as well as the row, because the failure mode was two + // rows that a database — not this function — would later refuse. + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'atlas-decorcase-')) + try { + writeTree(root) + fs.writeFileSync( + path.join(root, 'Data', 'Decoration', 'miscased.cfg'), + 'checkerboard 0x0FA6\n600 600 0\nCheckerBoard 0x0FA6\n700 700 0\n', + ) + const atlas = buildAtlas(root) + + const boards = atlas.decor.filter((d) => d.type.toLowerCase() === 'checkerboard') + assert.equal(boards.length, 1, 'two casings of one type must not be two rows') + // First spelling seen wins, exactly as the first item id does. Which one + // survives is cosmetic — the shard resolves either. + assert.equal(boards[0].type, 'checkerboard') + assert.equal(boards[0].uses, 2, 'both lines still count as uses of the one type') + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } +}) + test('a tree with no decoration at all still builds', () => { // Optional, like the champion file. A shard that has stripped its decoration // has a perfectly good atlas; the decoration verb simply has nothing to offer. diff --git a/server/test/uoEventActions.test.js b/server/test/uoEventActions.test.js index 1067166..dabf238 100644 --- a/server/test/uoEventActions.test.js +++ b/server/test/uoEventActions.test.js @@ -766,6 +766,51 @@ test('teardown reports a refused serial as failed, and a killed creature as done assert.equal((await actions.revertOwned({ runId: 7, resources })).ok, false) }) +test('the despawn carries NO idempotency key, whatever core hands revert()', async () => { + // The Phase 16 acceptance walk's critical finding, as the test that would have + // caught it. `revertOwned` used to forward core's `idempotencyKey` onto the + // despawn — and core's key is the STEP's, the one `placeOwned` spawned under. + // The shard's at-most-once store is keyed on the key ALONE + // (`BridgeIdempotency.Intercept` does `_byKey.TryGetValue(key, …)`, with no + // reference to which command carried it), so the despawn was taken for a repeat + // and answered with the SPAWN's stored reply. `OnDespawn` never ran. Core read + // `ok` with no `refused` and marked every row `reverted` while the shard still + // held every object — teardown of all five world verbs was a no-op that + // reported success. + // + // Every other stub in this file ignores the body, which is why the suite was + // green throughout. This one asserts on the body, and it asserts ABSENCE — the + // property that matters — rather than pinning the rest of the shape. + let sent = null + uoLinkClient.despawnWorld = async (body) => { + sent = body + return { ok: true, status: 200, data: { removed: ['0x40000000'], gone: [], refused: [] } } + } + + await actions.revertOwned({ + runId: 7, + resources: [{ kind: 'world', ref: '0x40000000', payload: {} }], + // Core passes this on every call (MODULE_API.md), and it must not reach the wire. + idempotencyKey: 'the-step-key-the-spawn-went-out-under', + }) + + assert.ok(sent, 'despawnWorld was not called') + assert.equal( + Object.prototype.hasOwnProperty.call(sent, 'idempotencyKey'), + false, + 'the despawn must not carry an idempotency key — the shard would replay the spawn', + ) + + // MODULE_API.md: revert is sometimes called with the key and an EMPTY list, + // meaning "a command went out under this key and core never learned what it + // did". No serials is the shard's own idiom for "everything this run owns", + // which is the correct sweep for exactly that case. + sent = null + await actions.revertOwned({ runId: 7, resources: [], idempotencyKey: 'lost-dispatch' }) + assert.deepEqual(sent.serials, []) + assert.equal(Object.prototype.hasOwnProperty.call(sent, 'idempotencyKey'), false) +}) + test('reconcile ASKS the shard, because these resources survive a restart', async () => { // The one property that separates this from every other resource in the file. // A crier line lives in shard memory, so a changed `bootId` IS proof it is diff --git a/server/utils/spawnAtlasSource.js b/server/utils/spawnAtlasSource.js index 78ef0b2..5b9364e 100644 --- a/server/utils/spawnAtlasSource.js +++ b/server/utils/spawnAtlasSource.js @@ -336,11 +336,29 @@ function buildAtlas(root, options = {}) { // Decoration: what this shard already calls scenery, which is what makes the // authoring dropdown the operator's own vocabulary rather than our taste. + // + // **Keyed case-INSENSITIVELY, because the decoration files disagree with + // themselves about casing.** Stock 57.4 names four types under two spellings + // each — `CheckerBoard`/`Checkerboard`, `ChessBoard`/`Chessboard`, + // `MetalChest`/`Metalchest`, `SpinningWheelEastAddon`/`SpinningwheelEastAddon` + // — and in every pair exactly one is a real class, the other a mis-cased line + // the shard's own loader resolves anyway. A case-sensitive Map keeps both, and + // then `shard_decor_types.type` (a PRIMARY KEY under MariaDB's default + // `..._ai_ci` collation, which folds case) rejects the second row and takes the + // WHOLE import transaction down with it. That is not a decoration bug: with no + // atlas, every option source answers empty and no world verb can be authored at + // all. The shard end of this feature already knew — `BridgeWorld.cs` resolves a + // decor type with `FindTypeByName(name, ignoreCase: true)` and says why — so + // folding here is the two ends agreeing rather than a new rule. + // + // The first spelling seen wins, exactly as the first item id does. Either + // spelling resolves on the shard, so which one survives is cosmetic. const decorUses = new Map() for (const file of files) { if (!file.label.startsWith('Data/Decoration/')) continue for (const entry of parseDecoration(file.text)) { - const seen = decorUses.get(entry.type) + const key = entry.type.toLowerCase() + const seen = decorUses.get(key) if (seen) { seen.uses += 1 continue @@ -348,7 +366,7 @@ function buildAtlas(root, options = {}) { // The FIRST item id wins, and it is only a preview: a type appears under // as many ids as it has facings or variants, and picking one arbitrarily // is honest in a way that picking "the most used" would not be. - decorUses.set(entry.type, { type: entry.type, itemId: entry.itemId, uses: 1 }) + decorUses.set(key, { type: entry.type, itemId: entry.itemId, uses: 1 }) } } const decor = [...decorUses.values()].sort((a, b) => a.type.localeCompare(b.type)) diff --git a/server/utils/uoLinkClient.js b/server/utils/uoLinkClient.js index 16d4cd6..c9f5aed 100644 --- a/server/utils/uoLinkClient.js +++ b/server/utils/uoLinkClient.js @@ -330,10 +330,18 @@ const ownedWorld = ({ runId }) => call(`/world/${encodeURIComponent(runId)}`) // teardown makes. The reply splits three ways: `removed` was deleted, `gone` was // already absent (a player killed it — an ordinary success), and `refused` was never // this run's to delete. -const despawnWorld = ({ runId, serials, idempotencyKey }) => +// +// **It takes no idempotency key, and the parameter is gone rather than optional.** +// It used to accept one, and `revertOwned` passed the step's — the key the SPAWN +// went out under. The shard's at-most-once store is keyed on the key alone, so the +// despawn was answered with the spawn's stored reply and nothing was ever deleted. +// A repeat despawn needs no key: the second pass answers `gone`, which both ends +// already treat as a success. Removed from the signature so it cannot be handed +// one again by accident. +const despawnWorld = ({ runId, serials }) => call(`/world/${encodeURIComponent(runId)}/despawn`, { method: 'POST', - body: { serials, idempotencyKey }, + body: { serials }, }) // ── Help-page (support) queue commands (§6) ──────────────────────────────── -- 2.49.1 From ea63ad019c5f221acd34dfff5accbe61bd7668c0 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 9 Sep 2026 19:48:29 -0500 Subject: [PATCH 8/8] chore(ci): the core pin comes home to main (Phase 16b cutover) `ci/core-ref.json` pointed at a website `edge` sha for the length of the Event System window (org lead, 2026-09-04), because `api.registerEventActions` exists only from MODULE_API 1.10.0: under the old `main` pin the frozen-manifest job's `register()` threw and this module did not load at all, so the job would have been red by construction for eight phases while a real regression hid behind it. The cutover put 1.10.0 on `main` (website#199, 655fbf3f), so the pin returns to a `main` sha -- and this is the same move that turns the Integration kit green, since `checkCoreApi` asserts equality against whatever core this pin names. `routes.manifest.json` needed NO regeneration. The frozen-manifest job's own steps were run against this exact ref -- core's manifest alone, the module installed, core's manifest again, then `frozenManifest.js --check` -- and it answered `routes.manifest.json is current, 73 routes, all documented`. So the file's own "commit both together" instruction had nothing to pair with this time. website's `main` and `edge` are the identical tree (930422ff), which is why the measurement taken on the branch holds for the merge. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4 --- ci/core-ref.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ci/core-ref.json b/ci/core-ref.json index d71a373..2afa537 100644 --- a/ci/core-ref.json +++ b/ci/core-ref.json @@ -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. **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.", + "$comment": "The core this module is proved against. MODULE_API.md §5.3: the frozen-manifest job clones RunicGateway/website at this exact ref, drops this module in as modules/uo and runs CORE's own routeManifest.js — nothing else can answer whether the URLs the module claims are the URLs it actually serves. Pinned rather than tracking a branch on purpose: core moves for reasons that have nothing to do with this module, and a bump is then a deliberate commit saying which core the module was last proved against, instead of an unexplained red X on someone else's PR. Bump it, regenerate routes.manifest.json, and commit both together. **It pointed at `edge` for the length of the Event System window** (org lead, 2026-09-04), and this commit ends that: `api.registerEventActions` exists only from MODULE_API 1.10.0, so under the previous `main` pin `register()` threw and the module did not load at all — the job would have been red by construction for eight phases and would have proved nothing while a real regression hid behind it. The Phase 16b cutover put 1.10.0 on `main`, so the pin comes home, and this is the same move that turns the Integration kit green again. **routes.manifest.json needed NO regeneration**: the job's own steps were run against this exact ref and answered `routes.manifest.json is current — 73 routes, all documented`, so the \"commit both together\" instruction above had nothing to pair with this time.", "repo": "https://gitea.whitlocktech.com/RunicGateway/website.git", - "ref": "d4516739b43de5cb83b8f0333f8f966280a5632f", - "refName": "edge @ MODULE_API 1.10.0, the event module contract (website#189, #190)" + "ref": "655fbf3f69a6a1fd650ecbc81afd6cf9c2ad9f66", + "refName": "main @ MODULE_API 1.10.0, the Event System cutover (website#199)" } -- 2.49.1