feat(events): UO wave 1 — the verbs that need no protocol change (Phase 9)

module-uo registers its first event actions: `uo.broadcast`,
`uo.towncrier.post` and `uo.news.post`, plus the `uo.broadcasts` budget
dimension and the three spawn-atlas option sources. The write plane they use
has existed since protocol 2.1; what is new is the declaration that lets the
event engine drive it unattended.

Three things the tree corrected about the plan:

- The plan's `on_failure: 'skip'` for `uo.broadcast` is already the default for
  `risk: 'notify'`, and `on_failure` is what happens AFTER the retries. The
  lever a module actually has is the failure envelope, so the action answers
  `retry: false` to everything — and every action declares `budgetMs: 15000`,
  because core's 10s default deadline fires before `uoLinkClient`'s 12s timeout
  and `classify()` answers `retry` for a timeout without asking the module.
  Without the budget the retry refusal is unreachable.
- `reconcile()` needs no protocol work. A shard restart wipes both the crier
  lines and an event's news article, so `perform()` stamps the shard `bootId`
  into the resource payload and `reconcile()` reports in force exactly the rows
  whose stamp still matches — correct for the module's own trigger and for
  core's boot sweep alike. `shardIngest` fires `ctx.events.reconcile()` on a
  changed `bootId`, after `recordStatus` so the comparison reads the new boot.
- Event articles post under `evt-<idempotencyKey>`, because `newsGump.js` uses
  the bare website post id and re-pushes that set on every reconnect.

`ci/core-ref.json` moves to a website `edge` sha for the length of this
workstream: `registerEventActions` exists only from MODULE_API 1.10.0, so under
the old `main` pin the module does not load at all. Verified locally — the
frozen-manifest rig passes against the new pin.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-09-04 07:23:03 -05:00
parent 144242fe8f
commit 57419111e6
11 changed files with 1136 additions and 11 deletions

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

View File

@@ -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, /<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], [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')
})