Merge pull request 'feat(events): UO wave 1 — the verbs that need no protocol change (Phase 9)' (#28) from feature/events-phase-9 into edge

Reviewed-on: #28
This commit is contained in:
2026-09-04 12:56:13 +00:00
11 changed files with 1257 additions and 11 deletions

View File

@@ -1,6 +1,6 @@
{
"$comment": "The core this module is proved against. MODULE_API.md §5.3: the frozen-manifest job clones RunicGateway/website at this exact ref, drops this module in as modules/uo and runs CORE's own routeManifest.js — nothing else can answer whether the URLs the module claims are the URLs it actually serves. Pinned rather than tracking `edge` on purpose: core moves for reasons that have nothing to do with this module, and a bump is then a deliberate commit saying which core the module was last proved against, instead of an unexplained red X on someone else's PR. Bump it, regenerate routes.manifest.json, and commit both together.",
"$comment": "The core this module is proved against. MODULE_API.md §5.3: the frozen-manifest job clones RunicGateway/website at this exact ref, drops this module in as modules/uo and runs CORE's own routeManifest.js — nothing else can answer whether the URLs the module claims are the URLs it actually serves. Pinned rather than tracking `edge` on purpose: core moves for reasons that have nothing to do with this module, and a bump is then a deliberate commit saying which core the module was last proved against, instead of an unexplained red X on someone else's PR. Bump it, regenerate routes.manifest.json, and commit both together. **It points at `edge` for the length of the Event System window** (org lead, 2026-09-04), and that is the one line here a reader should not tidy back. This module registers event actions from EVENTS_PLAN.md Phase 9, and `api.registerEventActions` exists only from MODULE_API 1.10.0 -- under the previous `main` pin `register()` throws and the module does not load at all, so the job would be red by construction for eight phases and would prove nothing while a real regression hid behind it. Phase 16's cutover re-pins it to `main`, which is the same commit that turns the Integration kit green again.",
"repo": "https://gitea.whitlocktech.com/RunicGateway/website.git",
"ref": "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)"
}

View File

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

View File

@@ -0,0 +1,574 @@
// ── 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
}
}
// 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, 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),
})
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.
//
// **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)`,
}
},
},
{
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: `<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),
})
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,
}

View File

@@ -104,7 +104,16 @@ module.exports = {
// do with a storage failure of core's. `inbox.push` additionally does not report
// "the user has this switched off", because a module that could see that would
// be a module that could enumerate people's preferences one write at a time.
events: { emit: (...args) => need().events.emit(...args) },
events: {
emit: (...args) => need().events.emit(...args),
// MODULE_API 1.10.0 (EVENTS.md F, Phase 8). "Ask every action of mine which
// of its ledgered resources the game still has." Core cannot know when to
// ask -- it has no concept of the game being up -- so the module says when,
// and `shardIngest` says it on a changed `bootId`. Fire-and-forget like
// `emit`, and for the same reason: core owns what happens next and there is
// nothing a game-event handler could correctly do with the answer.
reconcile: (...args) => need().events.reconcile(...args),
},
inbox: { push: (...args) => need().inbox.push(...args) },
secretBox: {
encrypt: (...args) => need().secretBox.encrypt(...args),

View File

@@ -47,6 +47,7 @@ module.exports = function register(ctx, api) {
const shardTriggers = require('./config/shardTriggers')
const shardAudiences = require('./config/shardAudiences')
const engagementSeeds = require('./config/engagementSeeds')
const uoEventActions = require('./config/uoEventActions')
const townCrierLeg = require('./utils/shardAnnounce')
const teamProvider = require('./model/teamProvider/teamProvider.model')
const guildCommand = require('./commands/guild.command')
@@ -157,6 +158,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,
})
}

View File

@@ -50,7 +50,7 @@ function fakeCtx(overrides = {}) {
// MODULE_API 1.7.0. Both are fire-and-forget and return undefined by
// contract — a module gets no delivery answer back, deliberately — so the
// spies return undefined rather than a promise, which is what core does.
events: { emit: spy(undefined) },
events: { emit: spy(undefined), reconcile: spy(undefined) },
inbox: { push: spy(undefined) },
secretBox: { encrypt: spy('enc'), decrypt: spy('dec') },
middleware: {
@@ -104,6 +104,9 @@ function fakeApi() {
slashCommands: [],
triggers: null,
audiences: null,
eventActions: null,
eventBudgets: null,
eventOptionSources: null,
hooks: {},
}
const called = new Set()
@@ -134,6 +137,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 },
}

View File

@@ -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')

View File

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

View File

@@ -0,0 +1,447 @@
// 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, 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`)
// 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)
assert.equal(calls.broadcast[0].actor, 'event:42')
assert.equal(calls.broadcast[0].hue, 1153)
})
test('an over-long broadcast is refused by the DRY RUN, before anything is sent', async () => {
const broadcast = byId('uo.broadcast')
const text = 'x'.repeat(actions.MAX_BROADCAST_LEN + 1)
const dry = await broadcast.perform({ runId: 1, params: { text }, verify: true })
assert.equal(dry.ok, false)
assert.equal(dry.retry, false)
assert.match(dry.error, new RegExp(String(actions.MAX_BROADCAST_LEN)))
const live = await broadcast.perform({ runId: 1, params: { text }, verify: false })
assert.equal(live.ok, false)
assert.deepEqual(calls.broadcast, [], 'nothing may reach the shard once the cap is breached')
})
test('a dry run sends nothing at all', async () => {
for (const action of actions.ACTIONS) {
const params = {}
for (const p of action.params) if (p.required) params[p.name] = p.example
const result = await action.perform({ runId: 1, stepId: 1, idempotencyKey: 'k'.repeat(40), params, verify: true })
assert.equal(result.ok, true, `${action.id} refused its own example params`)
assert.equal(result.resources, undefined, `${action.id} reported a resource it never created`)
}
assert.deepEqual(
[calls.broadcast.length, calls.crier.length, calls.news.length],
[0, 0, 0],
'a dry run reached the shard',
)
})
// ── The keyed verbs: one id, stable across a retry ─────────────────────────
test('the crier and the news gump post under a run-stable id a retry replaces', async () => {
const key = 'a1b2c3'.padEnd(40, '0')
await byId('uo.towncrier.post').perform({ runId: 3, idempotencyKey: key, params: { lines: 'hear ye' }, verify: false })
await byId('uo.towncrier.post').perform({ runId: 3, idempotencyKey: key, params: { lines: 'hear ye' }, verify: false })
assert.equal(calls.crier.length, 2)
assert.equal(calls.crier[0].id, calls.crier[1].id, 'a retry must replace, not stack')
assert.equal(calls.crier[0].id, `evt-${key}`)
// The sidecar's own cap on the id column.
assert.ok(calls.crier[0].id.length <= 64)
})
test('an event article cannot collide with a website post in the news gump', async () => {
// `newsGump.js` posts site articles under the bare post id and re-pushes that
// whole set on every reconnect. An event article numbered into the same space
// would silently be a collision with a post, in whichever direction wrote last.
await byId('uo.news.post').perform({
runId: 9,
idempotencyKey: 'f'.repeat(40),
params: { title: 'The Fair', body: 'Merchants gather.' },
verify: false,
})
assert.equal(calls.news.length, 1)
assert.doesNotMatch(calls.news[0].id, /^\d+$/, 'an event article must not be numbered like a post')
assert.match(calls.news[0].id, /^evt-/)
assert.match(calls.news[0].body, /<CENTER>The Fair<\/CENTER>/)
assert.equal(calls.news[0].announce, true, 'announce defaults on, as the gump does')
})
test('the keyed verbs DO retry, because a repeat replaces', async () => {
for (const [id, stub] of [['uo.towncrier.post', 'postTownCrier'], ['uo.news.post', 'postNews']]) {
const params = { lines: 'hear ye', title: 'The Fair', body: 'Merchants gather.' }
// The announce leg's own classification of this transport, reused rather
// than re-decided: a config or data problem is terminal, the rest transient.
for (const [status, retry] of [[400, false], [401, false], [403, false], [409, false], [503, true], [504, true], [0, true]]) {
uoLinkClient[stub] = async () => ({ ok: false, status, error: `status ${status}` })
const result = await byId(id).perform({ runId: 1, idempotencyKey: 'k'.repeat(40), params, verify: false })
assert.equal(result.ok, false)
assert.equal(result.retry, retry, `${id} misclassified a ${status}`)
}
}
})
test('a crier post is refused before it is sent when it is not eight short lines', async () => {
const crier = byId('uo.towncrier.post')
const cases = [
['', /empty/],
[' \n ', /empty/],
[Array.from({ length: actions.MAX_CRIER_LINES + 1 }, (_, i) => `line ${i}`).join('\n'), /criers carry/],
['x'.repeat(actions.MAX_CRIER_LINE_LEN + 1), /capped at/],
]
for (const [lines, expected] of cases) {
const result = await crier.perform({ runId: 1, idempotencyKey: 'k'.repeat(40), params: { lines }, verify: false })
assert.equal(result.ok, false)
assert.equal(result.retry, false, 'a badly shaped message is just as badly shaped next minute')
assert.match(result.error, expected)
}
assert.deepEqual(calls.crier, [])
})
test('blank lines are dropped rather than counted against the cap', () => {
// A textarea an operator has pressed enter in twice still holds two lines.
const parsed = actions.crierLines('hear ye\n\n \nseek the herald\n')
assert.equal(parsed.ok, true)
assert.deepEqual(parsed.lines, ['hear ye', 'seek the herald'])
})
test('a crier duration is taken in minutes and bounded at the sidecar cap', async () => {
const crier = byId('uo.towncrier.post')
const base = { runId: 1, idempotencyKey: 'k'.repeat(40), verify: false }
await crier.perform({ ...base, params: { lines: 'hear ye', durationMinutes: 90 } })
assert.equal(calls.crier[0].durationSec, 5400)
await crier.perform({ ...base, params: { lines: 'hear ye', durationMinutes: 60 * 48 } })
assert.equal(calls.crier[1].durationSec, 86400, 'a duration past the sidecar cap is clamped, not refused')
// Left out entirely, so the sidecar applies its own default rather than the
// module inventing one.
await crier.perform({ ...base, params: { lines: 'hear ye' } })
assert.equal(calls.crier[2].durationSec, undefined)
const bad = await crier.perform({ ...base, params: { lines: 'hear ye', durationMinutes: 'soon' } })
assert.equal(bad.ok, false)
assert.equal(bad.retry, false)
})
// ── Giving it back ─────────────────────────────────────────────────────────
test('a resource that is already gone is a successful revert', async () => {
// §L: "gone, and that is fine". A crier line whose duration ran out is a 404,
// and it is the outcome teardown wanted.
uoLinkClient.deleteTownCrier = async () => ({ ok: false, status: 404 })
uoLinkClient.deleteNews = async () => ({ ok: false, status: 404 })
for (const id of ['uo.towncrier.post', 'uo.news.post']) {
const result = await byId(id).revert({ runId: 1, resources: [{ kind: 'x', ref: 'evt-1' }] })
assert.equal(result.ok, true)
assert.ok(!result.failed || !result.failed.length)
}
})
test('a revert names the resources that did not come back', async () => {
uoLinkClient.deleteTownCrier = async (id) => {
calls.crierDel.push(id)
return id === 'evt-bad' ? { ok: false, status: 503 } : { ok: true, status: 200 }
}
const result = await byId('uo.towncrier.post').revert({
runId: 1,
resources: [{ ref: 'evt-ok' }, { ref: 'evt-bad' }],
})
// `ok: true` with a `failed` list, not `ok: false`: the group was worked, and
// one member of it is outstanding. Core keeps the row and tries it again.
assert.equal(result.ok, true)
assert.deepEqual(result.failed, ['evt-bad'])
assert.deepEqual(calls.crierDel, ['evt-ok', 'evt-bad'], 'one failure must not stop the group')
})
// ── reconcile: the boot stamp ──────────────────────────────────────────────
test('a resource stamped with the current boot is still in force', async () => {
const resources = [
{ kind: 'towncrier', ref: 'evt-a', payload: { bootId: 'boot-1' } },
{ kind: 'towncrier', ref: 'evt-b', payload: { bootId: 'boot-0' } },
]
const result = await actions.reconcileByBootId({ resources })
assert.equal(result.ok, true)
// Only the row from the boot that is still running. Core orphans the other —
// which is the honest sentence: it vanished while nobody was looking, rather
// than core having put it back.
assert.deepEqual(result.inForce, ['evt-a'])
})
test('a resource with no stamp is reported in force, because "I do not know" is not "it is gone"', async () => {
const result = await actions.reconcileByBootId({
resources: [{ ref: 'evt-old', payload: null }, { ref: 'evt-older', payload: {} }],
})
assert.deepEqual(result.inForce, ['evt-old', 'evt-older'])
})
test('with no shard boot to compare against, reconcile declines rather than orphaning everything', async () => {
uoLinkConfig.getSafe = async () => ({ bootId: null })
const result = await actions.reconcileByBootId({ resources: [{ ref: 'evt-a', payload: { bootId: 'boot-1' } }] })
// Core treats anything that is not an explicit answer as unanswered and leaves
// the ledger alone. An `ok: true, inForce: []` here would abandon every live row
// on a website that came up before its sidecar did.
assert.equal(result.ok, false)
})
test('a write with an unreadable config still happens, and simply carries no stamp', async () => {
uoLinkConfig.getSafe = async () => { throw new Error('pool is down') }
const result = await byId('uo.towncrier.post').perform({
runId: 1,
idempotencyKey: 'k'.repeat(40),
params: { lines: 'hear ye' },
verify: false,
})
assert.equal(result.ok, true, 'a config read must not fail a world write')
assert.equal(result.resources[0].payload.bootId, null)
})
// ── Option sources ─────────────────────────────────────────────────────────
const source = (id) => actions.OPTION_SOURCES.find((s) => s.id === id)
test('every option source is namespaced and answers', () => {
for (const s of actions.OPTION_SOURCES) {
assert.ok(s.id.startsWith('uo.options.'), `${s.id} must be namespaced`)
assert.ok(s.label && s.description)
assert.equal(typeof s.resolve, 'function')
}
})
test('a place is named by its facet, because two facets both have a Britain', async () => {
shardAtlas.listRegions = async () => [
{ facet: 'Felucca', name: 'Britain' },
{ facet: 'Trammel', name: 'Britain' },
]
const options = await source('uo.options.regions').resolve()
assert.equal(new Set(options.map((o) => o.value)).size, 2, 'two different places must not share a value')
assert.deepEqual(options[0], { value: 'Felucca/Britain', label: 'Britain', group: 'Felucca' })
})
test('a landmark groups by the atlas grouping where it has one, the facet otherwise', async () => {
shardAtlas.listLandmarks = async () => [
{ facet: 'Felucca', name: 'Despise', group: 'Dungeons' },
{ facet: 'Felucca', name: 'Cove', group: null },
]
const options = await source('uo.options.landmarks').resolve()
assert.deepEqual(options.map((o) => o.group), ['Dungeons', 'Felucca'])
})
test('a creature needs no qualifier — the slug is the same type wherever it spawns', async () => {
shardAtlas.searchCreatures = async ({ limit }) => {
assert.equal(limit, actions.MAX_OPTIONS, 'the source must bound what it asks the atlas for')
return { creatures: [{ slug: 'orc-brute', name: 'Orc Brute' }] }
}
assert.deepEqual(await source('uo.options.creatures').resolve(), [
{ value: 'orc-brute', label: 'Orc Brute' },
])
})
test('an atlas larger than the dropdown bound is truncated and said so', async () => {
const { ctx } = require('./_setup')
shardAtlas.listRegions = async () =>
Array.from({ length: actions.MAX_OPTIONS + 5 }, (_, i) => ({ facet: 'Felucca', name: `Region ${i}` }))
const options = await source('uo.options.regions').resolve()
assert.equal(options.length, actions.MAX_OPTIONS)
// Silently serving 2000 of 2005 is the defect the bound would otherwise
// introduce: an author cannot find the landmark they are looking for and
// nothing anywhere says why.
const warned = ctx.logs
.filter((l) => l.namespace === 'uo-events')
.flatMap((l) => l.log.warn.calls)
.some(([message]) => /truncated/.test(message))
assert.ok(warned, 'a truncated source must leave a log line naming itself')
})

View File

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

View File

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