Files
Module-uo/server/config/uoEventActions.js
wtclaude dc13515927
All checks were successful
PR Checks / client-build (pull_request) Successful in 20s
PR Checks / server-tests (pull_request) Successful in 26s
PR Checks / frozen-manifest (pull_request) Successful in 39s
feat(events): send the idempotency key, and declare champ.boss.killed (Phase 11a)
The website's half of protocol 6.

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-04 14:57:26 -05:00

606 lines
26 KiB
JavaScript

// ── module-uo's event verbs, wave 1 ────────────────────────────────────────
//
// EVENTS.md §F, EVENTS_PLAN.md Phase 9. The first three actions an event author
// can put in a step that reach the game, plus the budget dimension that bounds
// one of them and the three option sources the atlas answers.
//
// **Nothing here is new plumbing.** `uoLinkClient` has carried `adminBroadcast`,
// `postTownCrier`/`deleteTownCrier` and `postNews`/`deleteNews` since protocol
// 2.1; the admin screens have driven all three by hand for months. What this file
// adds is the declaration that lets the event engine drive them unattended —
// which is a different question, and the reason most of this file is about what
// happens when a call does NOT come back.
//
// ── The three rules that shape every declaration below ─────────────────────
//
// **1. `budgetMs` must exceed the client's own timeout, or the module never gets
// to classify its own failure.** `dispatch.classify()` answers `retry` for a
// budget timeout unconditionally and a module cannot override that — the module
// is not asked, because it is still awaiting a socket. `uoLinkClient.TIMEOUT_MS`
// is 12s and core's `DEFAULT_BUDGET_MS` is 10s, so on default settings core's
// deadline fires FIRST on every slow shard and the step is retried. Every action
// here therefore declares `budgetMs: 15000`: the client always answers first, and
// what the runner acts on is this file's judgement rather than a race.
//
// That is not a tuning detail. It is the whole of what makes rule 2 true.
//
// **2. A broadcast is retried, and protocol 6 is what changed that.** Wave 1
// shipped `uo.broadcast` answering `retry: false` to everything, because a
// retried broadcast was a second announcement to everyone online and nothing on
// the wire could make the shard refuse the repeat. A lost announcement was
// cheaper than a doubled one, and that was the whole argument.
//
// Protocol 6 removes its premise. Every write below now carries the step's
// `idempotencyKey`; the shard executes a key at most once and answers a repeat
// with the ORIGINAL reply rather than re-running it. So a retry of a broadcast
// whose acknowledgement was lost cannot announce twice — it collects the answer
// the first attempt never delivered. A shard restarting mid-run is now recovered
// from rather than written off, which is the case rule 2 used to throw away
// knowingly.
//
// Rule 1 is what keeps this true rather than merely intended: if core's deadline
// fired first the module would never be asked, and the retry would be core's
// unconditional one — carrying the same key, so still safe, but classified
// without the module's judgement.
// **2a. The one status that is new here.** A repeat arriving while the original
// is still in flight on the shard is answered `bridge.busy`, which the sidecar
// maps to **425**. It is transient by construction: the work is happening. It is
// not in `PERMANENT_STATUSES` and `classify()` falls through to retry, so it
// needs no arm of its own — but it is named so that a future tightening of that
// list has to decide about it deliberately.
//
// **3. What a shard restart wipes, `reconcile()` reports gone — and it knows
// which restart it was without asking.** There is no "list the town-crier lines"
// or "list the news articles" on the wire, and adding one would be protocol work
// for a question the module can already answer: a town-crier line and an
// event-owned news article both live in shard memory, so a restart is
// definitionally the loss of both. `perform()` stamps the shard's `bootId` into
// the resource payload and `reconcile()` reports in force exactly the rows whose
// stamp still matches. That is correct for BOTH callers — the module's own
// `ctx.events.reconcile()` on a changed `bootId`, and core's boot-time sweep,
// where the shard may not have restarted at all and answering "all gone" would
// abandon live rows.
const core = require('../core')
const uoLinkClient = require('../utils/uoLinkClient')
const uoLinkConfig = require('../model/uoLinkConfig/uoLinkConfig.model')
const shardAtlas = require('../model/shardAtlas/shardAtlas.model')
const { classify: classifySidecarWrite } = require('../utils/shardAnnounce')
const log = core.logger('uo-events')
// See rule 1 in the header. Above `uoLinkClient.TIMEOUT_MS` (12s), below core's
// `MAX_BUDGET_MS` (1h) by a mile.
const BUDGET_MS = 15000
// The sidecar's own caps, mirrored from the admin routes that already validate
// against them (`admin/uoLink.router.js` for the crier, `admin/shard.router.js`
// for the broadcast). Pre-checked here so an over-long line is a refusal a DRY
// RUN can show the author, rather than a 400 mid-run.
const MAX_BROADCAST_LEN = 300
const MAX_CRIER_LINES = 8
const MAX_CRIER_LINE_LEN = 200
const MAX_CRIER_DURATION_SEC = 86400
const MAX_NEWS_TITLE = 120
const MAX_NEWS_BODY = 900
// How many options one source will answer with. Real UO facets carry a few
// hundred regions and landmarks and ~800 constructible creature types, so this is
// comfortably above the data rather than a guess at it — and a deployment that
// exceeds it gets a log line naming the source and the counts, because a dropdown
// that silently omits the landmark an author is looking for is the defect this
// bound would otherwise introduce.
const MAX_OPTIONS = 2000
/**
* The id both keyed verbs post under.
*
* The step's idempotency key is `sha256(runId|stepId)` truncated to 40 hex — a
* function of identity and never of attempt — so a retry re-posts the SAME id and
* the sidecar replaces rather than stacks. That is the property that makes the
* crier and the news gump safe to retry and the broadcast not.
*
* **The `evt-` prefix is load-bearing for news.** `newsGump.js` posts articles
* under the bare website post id (`String(post.id)`) and `reassertAll()` re-pushes
* that whole set on every sidecar reconnect. An event article numbered into the
* same space would be a collision with a post — silently, and in whichever
* direction wrote last. 4 + 40 characters, inside the sidecar's 64-char cap.
*/
const resourceId = (idempotencyKey) => `evt-${idempotencyKey}`
/** The shard boot this write belongs to, or null when nothing has connected yet. */
async function currentBootId() {
try {
const config = await uoLinkConfig.getSafe()
return config.bootId || null
} catch (err) {
// Never fatal to a world write. A missing stamp means `reconcile()` cannot
// vouch for the row, which leaves core believing its own ledger — the
// pre-Phase-8 behaviour, and the right way to be wrong.
log.warn('could not read the shard boot id for an event resource', { error: err.message })
return null
}
}
// Statuses that will never succeed however many times they are tried: a data
// refusal, a bad token, a switched-off write plane, a protocol mismatch. Named
// here rather than folded into `shardAnnounce.classify` because 403 is reachable
// only from the `/admin/*` verbs — the announce leg posts to the town crier,
// which the admin write plane does not gate — and widening a shared classifier
// for a case its own caller cannot produce is how a shared rule stops being one.
const PERMANENT_STATUSES = new Set([400, 401, 403, 404, 409])
/**
* What the shard actually said, in its own words.
*
* **The sidecar explains its refusals and `legError` drops the explanation**, and
* this was worth its own helper the moment an event started making these calls
* unattended. A `403` body reads `{"reason":"admin write plane disabled"}`;
* `legError` looks for `data.message`, finds nothing, and falls back to "sidecar
* responded 403". For a staff member clicking a button that is survivable — they
* know what they just switched off. For an event that ran at four in the morning,
* the run log is the only place anyone will ever learn why, and "403" is not an
* answer an operator can act on.
*/
function sidecarReason(result, what) {
const data = (result && result.data) || {}
return data.reason || data.message || (result && result.error) || `the shard refused the ${what}`
}
/**
* The sidecar's answer, as an event outcome.
*
* **The announce leg's classification, not a second opinion.** `shardAnnounce`
* already decides what each status from this transport means — 400 a data
* problem, 401/409 a config problem, everything else transient — and it decides
* it about the same sidecar over the same client. Two copies of that judgement is
* how the two drift, which is the argument `core.announce` makes for deferring to
* a leg's own `classify()`.
*/
function sidecarFailure(result, what) {
const { outcome } = classifySidecarWrite(result)
const permanent = PERMANENT_STATUSES.has(result && result.status)
return {
ok: false,
retry: outcome === 'retry' && !permanent,
error: sidecarReason(result, what),
}
}
/** Split an authored text block into crier lines, and say why it is not one. */
function crierLines(raw) {
const lines = String(raw == null ? '' : raw)
.split(/\r?\n/)
.map((l) => l.trim())
.filter(Boolean)
if (!lines.length) return { ok: false, error: 'the message is empty' }
if (lines.length > MAX_CRIER_LINES) {
return { ok: false, error: `the criers carry ${MAX_CRIER_LINES} lines and this is ${lines.length}` }
}
const over = lines.find((l) => l.length > MAX_CRIER_LINE_LEN)
if (over) {
return {
ok: false,
error: `a crier line is capped at ${MAX_CRIER_LINE_LEN} characters and "${over.slice(0, 40)}…" is ${over.length}`,
}
}
return { ok: true, lines }
}
// ── Budgets ────────────────────────────────────────────────────────────────
//
// One dimension, and only `uo.broadcast` spends it (org lead, 2026-09-04). A run
// that broadcasts forty times is the spam failure mode, and a per-run cap is the
// only thing standing between an authoring mistake and every player online. The
// two keyed verbs get none: they are posted under a run-scoped id and a repeat
// REPLACES, so the thing a cap would guard against does not exist for them.
const BUDGETS = [
{
id: 'uo.broadcasts',
label: 'Global broadcasts',
unit: 'broadcasts',
description: 'System messages this run may put in front of everyone online.',
},
]
// ── Actions ────────────────────────────────────────────────────────────────
const ACTIONS = [
{
id: 'uo.broadcast',
label: 'Broadcast to everyone online',
description:
'Puts one system message in front of every player currently logged in. Sent once and never retried — a repeat would be a second announcement, and until the shard can refuse a duplicate there is no way to take one back.',
// Nothing in the world changes and nothing is created; a message goes out.
// Same class as `core.announce`, and for the same reason — which also gives
// it `skip` as its default disposition, so a run does not stop over an
// announcement that did not go out.
risk: 'notify',
// There is no undo, and declaring `ledger` would put a row in the cleanup
// ledger that teardown could never resolve.
reversible: 'none',
version: 1,
budgetMs: BUDGET_MS,
cost: () => ({ 'uo.broadcasts': 1 }),
params: [
{
name: 'text',
type: 'string',
required: true,
example: 'The gates of Britain open at dusk. Gather at the bank.',
description: `The message, up to ${MAX_BROADCAST_LEN} characters.`,
},
{
name: 'hue',
type: 'int',
required: false,
example: 1153,
description: 'UO colour id for the message. Left out, the shard uses its system colour.',
},
],
async perform({ runId, idempotencyKey, params, verify }) {
const text = String(params.text == null ? '' : params.text).trim()
// Checked here rather than left to the sidecar's 400, so the DRY RUN shows
// the author the refusal — which is the whole point of having one.
if (!text) return { ok: false, retry: false, error: 'the message is empty' }
if (text.length > MAX_BROADCAST_LEN) {
return {
ok: false,
retry: false,
error: `a broadcast is capped at ${MAX_BROADCAST_LEN} characters and this is ${text.length}`,
}
}
if (verify) return { ok: true }
// `event:<runId>` (org lead, 2026-09-04). The shard records an actor on
// every staff write and echoes it back as an `admin.audit` event, so this
// is what an operator reads in the game's own audit trail afterwards. No
// staff member pressed a button — attributing it to one would be a false
// record — and the run id is the thing that makes the line actionable.
const result = await uoLinkClient.adminBroadcast({
actor: `event:${runId}`,
text,
hue: params.hue === undefined || params.hue === null ? undefined : Number(params.hue),
// Protocol 6, and the line rule 2 said would change. The key is the
// step's, so every attempt at this step carries the same one and the
// shard refuses the repeat — which is what makes the retry below safe to
// ask for at all.
idempotencyKey,
})
if (result.ok) return { ok: true }
// **A transient failure is now retried**, where wave 1 gave up on it. What
// used to make a retry unsafe was that the shard could not tell a repeat
// from a fresh command; it can now, so a 503 from a shard that is merely
// restarting is recovered from instead of being written off.
//
// The classification itself is `sidecarFailure`'s — the announce leg's own
// judgement about this transport, deferred to rather than second-guessed,
// exactly as the two keyed verbs below already do. That this action now
// uses the SAME helper as its siblings, instead of a hand-rolled variant
// that forced every outcome terminal, is most of the change here.
return sidecarFailure(result, 'broadcast')
},
},
{
id: 'uo.towncrier.post',
label: 'Post to the town criers',
description:
'Puts up to eight lines in the mouths of the town criers for a set time, and takes them down again when the event ends.',
// `notify` is about what a FAILURE costs — nothing is half-changed and the
// run should carry on — while `ledger` is about what SUCCESS leaves behind.
// The two are independent questions and this is the combination where that
// shows: an announcement that can be withdrawn.
risk: 'notify',
reversible: 'ledger',
version: 1,
budgetMs: BUDGET_MS,
params: [
{
// **One text block, not a list, because the param vocabulary has no
// array type** (`VARIABLE_TYPES` is string/int/float/boolean/datetime/url).
// Splitting on newlines is the honest encoding of eight short lines in a
// textarea, and the caps are checked before anything is sent.
name: 'lines',
type: 'string',
required: true,
example: 'Hear ye! The Britain gates open at dusk.\nSeek the herald by the bank.',
description: `One line per newline. Up to ${MAX_CRIER_LINES} lines of ${MAX_CRIER_LINE_LEN} characters.`,
},
{
name: 'durationMinutes',
type: 'int',
required: false,
example: 60,
description: 'How long the criers keep saying it. Left out, the shard keeps it for an hour.',
},
],
async perform({ runId, idempotencyKey, params, verify }) {
const parsed = crierLines(params.lines)
if (!parsed.ok) return { ok: false, retry: false, error: parsed.error }
let durationSec
if (params.durationMinutes !== undefined && params.durationMinutes !== null) {
const minutes = Number(params.durationMinutes)
if (!Number.isFinite(minutes) || minutes <= 0) {
return { ok: false, retry: false, error: `"${params.durationMinutes}" is not a number of minutes` }
}
durationSec = Math.min(Math.round(minutes * 60), MAX_CRIER_DURATION_SEC)
}
if (verify) return { ok: true }
const id = resourceId(idempotencyKey)
const bootId = await currentBootId()
// Protocol 6. This verb was already safe to retry — a repeat under the same
// `id` REPLACES the crier entry rather than stacking a second one — so the
// key buys no new safety here. It is sent because it costs nothing and
// makes the retry a no-op on the shard rather than a redundant world write,
// and because a write plane where only some commands are keyed is one
// somebody will later have to reason about per verb.
const result = await uoLinkClient.postTownCrier({
id,
lines: parsed.lines,
durationSec,
idempotencyKey,
})
if (!result.ok) return sidecarFailure(result, 'town-crier post')
// The stamp rule 3 rests on. `runId` rides along so a row read out of the
// ledger says which run put it up without a join.
return { ok: true, resources: [{ kind: 'towncrier', ref: id, payload: { bootId, runId } }] }
},
async revert({ resources }) {
const failed = []
for (const resource of resources) {
const result = await uoLinkClient.deleteTownCrier(resource.ref)
// §L: "gone, and that is fine" is a successful revert. A crier line whose
// duration simply ran out is a 404, and it is the outcome we wanted.
if (!result.ok && result.status !== 404) failed.push(resource.ref)
}
if (!failed.length) return { ok: true }
return { ok: true, failed }
},
reconcile: reconcileByBootId,
},
{
id: 'uo.news.post',
label: 'Post an article to the news gump',
description:
"Puts an article in the in-game Town Cryer news window for the life of the event, and pulls it when the event ends. Separate from the site's own news posts, which sync there on their own.",
risk: 'notify',
reversible: 'ledger',
version: 1,
budgetMs: BUDGET_MS,
params: [
{
name: 'title',
type: 'string',
required: true,
example: 'The Britannian Midsummer Fair',
description: `The article heading, up to ${MAX_NEWS_TITLE} characters.`,
},
{
name: 'body',
type: 'string',
required: true,
example: 'Merchants from every city gather in Britain for three days of trade and contest.',
description: `The article, up to ${MAX_NEWS_BODY} characters. Plain text; the gump renders a small HTML subset and this is wrapped for it.`,
},
{
name: 'url',
type: 'url',
required: false,
example: 'https://example.com/site/events',
description: "The article's \"more info\" link. Left out, the gump shows no link.",
},
{
name: 'image',
type: 'int',
required: false,
example: 5013,
description: 'A shard art id to illustrate the article. Left out, the sidecar uses a neutral scroll.',
},
{
name: 'announce',
type: 'boolean',
required: false,
example: true,
description: 'Whether the criers proclaim the title when it goes up. Left out, they do.',
},
],
async perform({ runId, idempotencyKey, params, verify }) {
const title = String(params.title == null ? '' : params.title).replace(/\s+/g, ' ').trim()
const body = String(params.body == null ? '' : params.body).trim()
if (!title) return { ok: false, retry: false, error: 'the article has no title' }
if (title.length > MAX_NEWS_TITLE) {
return {
ok: false,
retry: false,
error: `a news title is capped at ${MAX_NEWS_TITLE} characters and this is ${title.length}`,
}
}
if (!body) return { ok: false, retry: false, error: 'the article has no body' }
if (body.length > MAX_NEWS_BODY) {
return {
ok: false,
retry: false,
error: `a news body is capped at ${MAX_NEWS_BODY} characters and this is ${body.length}`,
}
}
if (verify) return { ok: true }
const id = resourceId(idempotencyKey)
const bootId = await currentBootId()
const result = await uoLinkClient.postNews({
id,
title,
// The same gump-HTML shape `newsGump.buildArticle` uses, so an event
// article and a site article read alike in the window they share.
body: `<CENTER>${title}</CENTER><BR><BR>${body}`,
image:
params.image === undefined || params.image === null ? undefined : Number(params.image),
url: params.url || undefined,
announce: params.announce === undefined || params.announce === null ? true : Boolean(params.announce),
// Protocol 6, for the same reason the crier carries one — except that
// here it does buy something. `announce: true` makes the criers proclaim
// the article's title when it is posted, so a re-post under the same id
// replaces the article silently but proclaims it AGAIN. The key stops the
// second proclamation, which was the one part of this verb that was never
// as idempotent as its `id` made it look.
idempotencyKey,
})
if (!result.ok) return sidecarFailure(result, 'news article')
return { ok: true, resources: [{ kind: 'news', ref: id, payload: { bootId, runId } }] }
},
async revert({ resources }) {
const failed = []
for (const resource of resources) {
const result = await uoLinkClient.deleteNews(resource.ref)
if (!result.ok && result.status !== 404) failed.push(resource.ref)
}
if (!failed.length) return { ok: true }
return { ok: true, failed }
},
reconcile: reconcileByBootId,
},
]
/**
* 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,
PERMANENT_STATUSES,
sidecarReason,
resourceId,
crierLines,
reconcileByBootId,
}