// 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: [], spawn: [], despawn: [], owned: [], } for (const name of [ 'adminBroadcast', 'postTownCrier', 'deleteTownCrier', 'postNews', 'deleteNews', 'spawnWorld', 'ownedWorld', 'despawnWorld', ]) { saved[name] = uoLinkClient[name] } saved.getSafe = uoLinkConfig.getSafe saved.listRegions = shardAtlas.listRegions saved.listLandmarks = shardAtlas.listLandmarks saved.searchCreatures = shardAtlas.searchCreatures saved.listDecorTypes = shardAtlas.listDecorTypes saved.getDecorType = shardAtlas.getDecorType uoLinkClient.adminBroadcast = async (b) => { calls.broadcast.push(b); return { ok: true, status: 200 } } uoLinkClient.postTownCrier = async (b) => { calls.crier.push(b); return { ok: true, status: 200 } } uoLinkClient.deleteTownCrier = async (id) => { calls.crierDel.push(id); return { ok: true, status: 200 } } uoLinkClient.postNews = async (b) => { calls.news.push(b); return { ok: true, status: 200 } } uoLinkClient.deleteNews = async (id) => { calls.newsDel.push(id); return { ok: true, status: 200 } } // Phase 12a. Two serials back by default, so a spawn produces a resource list // longer than one and the per-serial ledger shape is what the suite exercises. uoLinkClient.spawnWorld = async (b) => { calls.spawn.push(b) const n = b.count || 1 return { ok: true, status: 200, data: { serials: Array.from({ length: n }, (_, i) => `0x4000000${i}`) }, } } uoLinkClient.ownedWorld = async (b) => { calls.owned.push(b) return { ok: true, status: 200, data: { owned: [{ serial: '0x40000000', what: 'creature' }] } } } uoLinkClient.despawnWorld = async (b) => { calls.despawn.push(b) return { ok: true, status: 200, data: { removed: b.serials || [], gone: [], refused: [] } } } uoLinkConfig.getSafe = async () => ({ bootId: 'boot-1' }) // Phase 11b. `uo.participation.open` resolves its `place` param against the // atlas, so the dry-run sweep below reaches this rather than the database. // Two landmarks, because Phase 12a's gate verb resolves a SECOND place: its // destination. One would make the dry-run sweep below pass for the wrong // reason, by never exercising the leg that can name a different point. shardAtlas.listLandmarks = async () => [ { facet: 'Felucca', name: 'Britain', x: 1496, y: 1628, z: 10 }, { facet: 'Felucca', name: 'Yew', x: 542, y: 982, z: 0 }, ] shardAtlas.listDecorTypes = async () => [{ type: 'Brazier', itemId: 0x0E31, uses: 42 }] shardAtlas.getDecorType = async (type) => type === 'Brazier' ? { type: 'Brazier', itemId: 0x0E31, uses: 42 } : null }) afterEach(() => { 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 shardAtlas.listDecorTypes = saved.listDecorTypes shardAtlas.getDecorType = saved.getDecorType for (const name of ['spawnWorld', 'ownedWorld', 'despawnWorld']) { uoLinkClient[name] = saved[name] } }) // ── The rule everything else depends on ──────────────────────────────────── 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('every dimension a cost names is one this module declares', () => { const declared = new Set(actions.BUDGETS.map((b) => b.id)) // Phase 12a's six and Phase 12b's seventh are all the MODULE's (org lead, // 2026-09-07): core meters what a module declares and holds no UO knowledge, so // a `uo.` dimension core knew about would be a leak of this game into the engine. // // `uo.rewards` counts ITEMS rather than grants: a step giving 500 gold to forty // people and one giving a candle to forty people are not the same imposition, and // a count of grants would price them identically. assert.deepEqual( [...declared], [ 'uo.broadcasts', 'uo.creatures', 'uo.bosses', 'uo.npcs', 'uo.decor', 'uo.gate.minutes', 'uo.rewards', ], ) 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) // Phase 12a. Asserted across EVERY action rather than one at a time, because // the failure this catches is a typo in one dimension name out of six, which // core answers by refusing the whole registration at load. for (const action of actions.ACTIONS) { if (typeof action.cost !== 'function') continue const params = {} for (const p of action.params) params[p.name] = p.example for (const id of Object.keys(action.cost(params))) { assert.ok(declared.has(id), `${action.id} spends "${id}", which nothing declares`) } } // A gate is priced in MINUTES, not in gates. One standing all day and twelve // standing five minutes each are not the same imposition on a world, and a // count would price them identically. assert.deepEqual(byId('uo.gate.open').cost({ durationMinutes: 120 }), { 'uo.gate.minutes': 120 }) assert.deepEqual(byId('uo.creature.spawn').cost({ count: 8 }), { 'uo.creatures': 8 }) }) // ── uo.broadcast: retried, because protocol 6 made that safe ─────────────── test('a broadcast is retried on a transient failure and never on a permanent one', async () => { const broadcast = byId('uo.broadcast') // Wave 1 asserted the opposite of this — every failure terminal, including the // two that are plainly transient — because nothing on the wire could stop a // retry announcing to everyone twice. Protocol 6 puts an idempotency key on the // command and the shard refuses the repeat, so the trade that test recorded is // no longer one that has to be made. // // 425 is the new status in this list: `bridge.busy`, the shard saying a command // under this key is still in flight. Transient by construction. const TRANSIENT = new Set([0, 425, 503, 504]) for (const status of [0, 400, 401, 403, 409, 425, 503, 504]) { uoLinkClient.adminBroadcast = async () => ({ ok: false, status, error: `status ${status}` }) const result = await broadcast.perform({ runId: 7, params: { text: 'hear ye' }, verify: false }) assert.equal(result.ok, false) assert.equal(result.retry, TRANSIENT.has(status), `a ${status} retries iff it is transient`) } }) test('every write carries the step idempotency key, unchanged', async () => { // The key is what makes the retry above safe, so a verb that dropped it would // silently restore the wave-1 hazard while every other assertion still passed. // Asserted per verb rather than once, because each builds its own body. const KEY = 'a'.repeat(40) const seen = {} uoLinkClient.adminBroadcast = async (body) => { seen.broadcast = body; return { ok: true } } uoLinkClient.postTownCrier = async (body) => { seen.crier = body; return { ok: true } } uoLinkClient.postNews = async (body) => { seen.news = body; return { ok: true } } await byId('uo.broadcast').perform({ runId: 7, idempotencyKey: KEY, params: { text: 'hear ye' }, verify: false, }) await byId('uo.towncrier.post').perform({ runId: 7, idempotencyKey: KEY, params: { lines: 'hear ye' }, verify: false, }) await byId('uo.news.post').perform({ runId: 7, idempotencyKey: KEY, params: { title: 'A thing', body: 'happened' }, verify: false, }) assert.equal(seen.broadcast.idempotencyKey, KEY) assert.equal(seen.crier.idempotencyKey, KEY) assert.equal(seen.news.idempotencyKey, KEY) // The two keyed verbs post under an id DERIVED from the key. Both travel: the // id is what makes a repeat replace, the key is what stops it re-announcing. assert.equal(seen.crier.id, `evt-${KEY}`) assert.equal(seen.news.id, `evt-${KEY}`) }) test("the shard's own words reach the run log, not just a status code", async () => { // **The rig found this.** The sidecar refuses a broadcast with // `{"reason":"admin write plane disabled"}` and `legError` looks for // `data.message`, so the run console read "sidecar responded 403" for a cause // the shard had already explained in a sentence. A staff member clicking a // button knows what they switched off; an event that ran at four in the morning // leaves the run log as the only place anyone will learn why. uoLinkClient.adminBroadcast = async () => ({ ok: false, status: 403, data: { kind: 'admin.error', reason: 'admin write plane disabled' }, error: 'sidecar responded 403', }) const result = await byId('uo.broadcast').perform({ runId: 1, params: { text: 'hear ye' }, verify: false }) assert.match(result.error, /admin write plane disabled/) // And NOT the double-announce clause: a 403 will not succeed on any attempt, so // pointing an operator at a policy decision misdirects them away from the // switch they actually have to flip. assert.doesNotMatch(result.error, /announce twice/) assert.equal(result.retry, false) }) test('a permanent refusal of a keyed verb is not retried either', async () => { // Same distinction on the other side: the keyed verbs DO retry a transient, and // must not burn three attempts on a refusal that cannot change. uoLinkClient.postTownCrier = async () => ({ ok: false, status: 403, data: { reason: 'admin write plane disabled' } }) const result = await byId('uo.towncrier.post').perform({ runId: 1, idempotencyKey: 'k'.repeat(40), params: { lines: 'hear ye' }, verify: false, }) assert.equal(result.retry, false) assert.match(result.error, /admin write plane disabled/) }) test('a broadcast names its run in the shard audit, not a staff member', async () => { await byId('uo.broadcast').perform({ runId: 42, params: { text: 'hear ye', hue: 1153 }, verify: false }) assert.equal(calls.broadcast.length, 1) assert.equal(calls.broadcast[0].actor, 'event:42') assert.equal(calls.broadcast[0].hue, 1153) }) test('an over-long broadcast is refused by the DRY RUN, before anything is sent', async () => { const broadcast = byId('uo.broadcast') const text = 'x'.repeat(actions.MAX_BROADCAST_LEN + 1) const dry = await broadcast.perform({ runId: 1, params: { text }, verify: true }) assert.equal(dry.ok, false) assert.equal(dry.retry, false) assert.match(dry.error, new RegExp(String(actions.MAX_BROADCAST_LEN))) const live = await broadcast.perform({ runId: 1, params: { text }, verify: false }) assert.equal(live.ok, false) assert.deepEqual(calls.broadcast, [], 'nothing may reach the shard once the cap is breached') }) test('a dry run sends nothing at all', async () => { for (const action of actions.ACTIONS) { const params = {} for (const p of action.params) if (p.required) params[p.name] = p.example const result = await action.perform({ runId: 1, stepId: 1, idempotencyKey: 'k'.repeat(40), params, verify: true }) assert.equal(result.ok, true, `${action.id} refused its own example params`) assert.equal(result.resources, undefined, `${action.id} reported a resource it never created`) } assert.deepEqual( [calls.broadcast.length, calls.crier.length, calls.news.length], [0, 0, 0], 'a dry run reached the shard', ) }) // ── The keyed verbs: one id, stable across a retry ───────────────────────── test('the crier and the news gump post under a run-stable id a retry replaces', async () => { const key = 'a1b2c3'.padEnd(40, '0') await byId('uo.towncrier.post').perform({ runId: 3, idempotencyKey: key, params: { lines: 'hear ye' }, verify: false }) await byId('uo.towncrier.post').perform({ runId: 3, idempotencyKey: key, params: { lines: 'hear ye' }, verify: false }) assert.equal(calls.crier.length, 2) assert.equal(calls.crier[0].id, calls.crier[1].id, 'a retry must replace, not stack') assert.equal(calls.crier[0].id, `evt-${key}`) // The sidecar's own cap on the id column. assert.ok(calls.crier[0].id.length <= 64) }) test('an event article cannot collide with a website post in the news gump', async () => { // `newsGump.js` posts site articles under the bare post id and re-pushes that // whole set on every reconnect. An event article numbered into the same space // would silently be a collision with a post, in whichever direction wrote last. await byId('uo.news.post').perform({ runId: 9, idempotencyKey: 'f'.repeat(40), params: { title: 'The Fair', body: 'Merchants gather.' }, verify: false, }) assert.equal(calls.news.length, 1) assert.doesNotMatch(calls.news[0].id, /^\d+$/, 'an event article must not be numbered like a post') assert.match(calls.news[0].id, /^evt-/) assert.match(calls.news[0].body, /
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 option carries the type the shard can build, not the atlas slug', async () => { // Changed in Phase 12a, and the reason is the point of the source existing. // Wave 1 declared it before anything consumed it and used the slug — unique, // stable, and unusable: the shard constructs from a ServUO class name, and // `orc-brute` is not one. The atlas's `name` IS the raw type token from the // spawn files, so the fix was to stop discarding the half that works. shardAtlas.searchCreatures = async ({ limit }) => { assert.equal(limit, actions.MAX_OPTIONS, 'the source must bound what it asks the atlas for') return { creatures: [{ slug: 'orcbrute', name: 'OrcBrute' }] } } assert.deepEqual(await source('uo.options.creatures').resolve(), [ { value: 'OrcBrute', label: 'OrcBrute' }, ]) }) test('decoration options come from the shard\'s own decoration files', async () => { const options = await source('uo.options.decor').resolve() assert.deepEqual(options, [{ value: 'Brazier', label: 'Brazier' }]) }) test('an atlas larger than the dropdown bound is truncated and said so', async () => { const { ctx } = require('./_setup') shardAtlas.listRegions = async () => 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') }) // ── The world verbs (Phase 12a) ─────────────────────────────── test('a spawn files one ledger row per serial, not one per call', async () => { // Per serial, because a group half of which a player killed has to reconcile // per creature. One row per call would make teardown all-or-nothing over eight // orcs of which six are gone, which is neither true nor useful. const result = await byId('uo.creature.spawn').perform({ runId: 7, idempotencyKey: 'c'.repeat(40), params: { place: 'Felucca/Britain', creature: 'Orc', count: 3 }, verify: false, }) assert.equal(result.ok, true) assert.equal(result.resources.length, 3) for (const resource of result.resources) { assert.equal(resource.kind, actions.OWNED_KIND) assert.equal(resource.payload.runId, '7') assert.equal(resource.payload.what, 'creature') assert.equal(resource.payload.type, 'Orc') } // The place is resolved to a point HERE, so the shard is never handed a // facet/name it would have to know how to read. assert.equal(calls.spawn.length, 1) assert.deepEqual( { map: calls.spawn[0].map, x: calls.spawn[0].x, y: calls.spawn[0].y }, { map: 'Felucca', x: 1496, y: 1628 }, ) }) test('a boss is a creature plus multipliers, and is refused above the ceiling', async () => { const boss = byId('uo.boss.spawn') const params = { place: 'Felucca/Britain', creature: 'OrcCaptain', name: 'Gruk the Unbroken', hitsMultiplier: 3, damageMultiplier: 1.5, } assert.equal((await boss.perform({ runId: 7, idempotencyKey: 'b'.repeat(40), params, verify: false })).ok, true) assert.equal(calls.spawn[0].what, 'boss') assert.equal(calls.spawn[0].hitsMultiplier, 3) assert.equal(calls.spawn[0].damageMultiplier, 1.5) // Absent, not zero: a multiplier nobody set must not arrive as a number the // shard would then apply. assert.equal(calls.spawn[0].statMultiplier, undefined) const tooMuch = await boss.perform({ runId: 7, idempotencyKey: 'b'.repeat(40), params: { ...params, hitsMultiplier: actions.MAX_BOSS_MULTIPLIER + 1 }, verify: false, }) assert.equal(tooMuch.ok, false) assert.equal(tooMuch.retry, false, 'a ceiling will not move on a retry') assert.equal(calls.spawn.length, 1, 'nothing may reach the shard once it is refused here') // Named, because an unnamed boss is just a hard orc — and because the name is // what an operator reads in the ledger afterwards. const unnamed = await boss.perform({ runId: 7, idempotencyKey: 'b'.repeat(40), params: { ...params, name: ' ' }, verify: false, }) assert.equal(unnamed.ok, false) }) test('an oracle\'s dialogue is parsed from one textarea, and a bad row is named', async () => { const parsed = actions.oracleLines('fire, flame = It burns beneath the keep.\n gate = At dusk. ') assert.deepEqual(parsed, { ok: true, rows: [ { keywords: 'fire,flame', text: 'It burns beneath the keep.' }, { keywords: 'gate', text: 'At dusk.' }, ], }) // Split on the FIRST `=`, so an answer may contain one. assert.deepEqual(actions.oracleLines('sum = 2 = 2 is four').rows, [ { keywords: 'sum', text: '2 = 2 is four' }, ]) assert.equal(actions.oracleLines('just some prose').ok, false) assert.equal(actions.oracleLines('fire =').ok, false, 'a keyword with nothing to say is a mistake') assert.equal(actions.oracleLines('= something').ok, false, 'something to say with no keyword is too') const tooMany = actions.oracleLines( Array.from({ length: actions.MAX_ORACLE_LINES + 1 }, (_, i) => `w${i} = t${i}`).join('\n'), ) assert.equal(tooMany.ok, false) }) test('an oracle with nothing to say is refused before it is stood up', async () => { // `required: true` on the greeting catches an ABSENT field, at the edge, and // this catches the one holding nothing but spaces — which reaches `perform` // looking exactly like a filled-in form. const result = await byId('uo.npc.place').perform({ runId: 7, idempotencyKey: 'n'.repeat(40), params: { place: 'Felucca/Britain', name: 'Marisa', greeting: ' ' }, verify: false, }) assert.equal(result.ok, false) assert.equal(result.retry, false) assert.match(result.error, /silence/) assert.deepEqual(calls.spawn, []) }) test('a keyword line reaches the shard as keywords and text, and nothing executable', async () => { // The whole argument for not building this on `XmlSpawner2.XmlDialog`, which // implements exactly this vocabulary and one field more: an `Action` string // that runs commands. What crosses here is what an oracle SAYS. const result = await byId('uo.npc.place').perform({ runId: 7, idempotencyKey: 'n'.repeat(40), params: { place: 'Felucca/Britain', name: 'Marisa', greeting: 'You have questions.', lines: 'fire, flame = It burns beneath the keep.', sex: 'female', }, verify: false, }) assert.equal(result.ok, true) assert.deepEqual(calls.spawn[0].lines, [ { keywords: 'fire,flame', text: 'It burns beneath the keep.' }, ]) assert.equal(calls.spawn[0].sex, 'female') for (const key of Object.keys(calls.spawn[0])) { assert.notEqual(key, 'action', 'nothing executable may cross to the shard') } }) test('a gate crosses as a DURATION, and names both ends as points', async () => { const result = await byId('uo.gate.open').perform({ runId: 7, idempotencyKey: 'g'.repeat(40), params: { place: 'Felucca/Britain', destination: 'Felucca/Yew', durationMinutes: 120 }, verify: false, }) assert.equal(result.ok, true) const sent = calls.spawn[0] // A duration, never an absolute time: an absolute deadline computed here and // honoured there is measured against two clocks, and a shard ten minutes fast // would collect the gate the instant it opened. assert.equal(sent.holdMs, 120 * 60_000) assert.equal(sent.untilMs, undefined, 'an absolute deadline must not cross') assert.deepEqual(sent.target, { map: 'Felucca', x: 542, y: 982 }) const tooLong = await byId('uo.gate.open').perform({ runId: 7, idempotencyKey: 'g'.repeat(40), params: { place: 'Felucca/Britain', destination: 'Felucca/Yew', durationMinutes: actions.MAX_GATE_MINUTES + 1, }, verify: false, }) assert.equal(tooLong.ok, false) assert.equal(tooLong.retry, false) }) test('teardown reports a refused serial as failed, and a killed creature as done', async () => { const resources = [ { kind: 'world', ref: '0x40000000', payload: {} }, { kind: 'world', ref: '0x40000001', payload: {} }, ] // `gone` is not a failure. A creature a player killed is the point of having // spawned it, and §L already says "gone, and that is fine" is a successful // revert — so a run does not end `incomplete` because its event worked. uoLinkClient.despawnWorld = async () => ({ ok: true, status: 200, data: { removed: ['0x40000000'], gone: ['0x40000001'], refused: [] }, }) assert.deepEqual(await actions.revertOwned({ runId: 7, resources }), { ok: true }) // `refused` IS. The shard denies this run ever owned it, so nothing will ever // delete it through this path: the row must land unresolved with a reason // rather than be quietly marked reverted. uoLinkClient.despawnWorld = async () => ({ ok: true, status: 200, data: { removed: ['0x40000000'], gone: [], refused: ['0x40000001'] }, }) assert.deepEqual(await actions.revertOwned({ runId: 7, resources }), { ok: true, failed: ['0x40000001'], }) // An unreachable shard has not said anything about anything. uoLinkClient.despawnWorld = async () => ({ ok: false, status: 503, data: null }) assert.equal((await actions.revertOwned({ runId: 7, resources })).ok, false) }) test('the despawn carries NO idempotency key, whatever core hands revert()', async () => { // The Phase 16 acceptance walk's critical finding, as the test that would have // caught it. `revertOwned` used to forward core's `idempotencyKey` onto the // despawn — and core's key is the STEP's, the one `placeOwned` spawned under. // The shard's at-most-once store is keyed on the key ALONE // (`BridgeIdempotency.Intercept` does `_byKey.TryGetValue(key, …)`, with no // reference to which command carried it), so the despawn was taken for a repeat // and answered with the SPAWN's stored reply. `OnDespawn` never ran. Core read // `ok` with no `refused` and marked every row `reverted` while the shard still // held every object — teardown of all five world verbs was a no-op that // reported success. // // Every other stub in this file ignores the body, which is why the suite was // green throughout. This one asserts on the body, and it asserts ABSENCE — the // property that matters — rather than pinning the rest of the shape. let sent = null uoLinkClient.despawnWorld = async (body) => { sent = body return { ok: true, status: 200, data: { removed: ['0x40000000'], gone: [], refused: [] } } } await actions.revertOwned({ runId: 7, resources: [{ kind: 'world', ref: '0x40000000', payload: {} }], // Core passes this on every call (MODULE_API.md), and it must not reach the wire. idempotencyKey: 'the-step-key-the-spawn-went-out-under', }) assert.ok(sent, 'despawnWorld was not called') assert.equal( Object.prototype.hasOwnProperty.call(sent, 'idempotencyKey'), false, 'the despawn must not carry an idempotency key — the shard would replay the spawn', ) // MODULE_API.md: revert is sometimes called with the key and an EMPTY list, // meaning "a command went out under this key and core never learned what it // did". No serials is the shard's own idiom for "everything this run owns", // which is the correct sweep for exactly that case. sent = null await actions.revertOwned({ runId: 7, resources: [], idempotencyKey: 'lost-dispatch' }) assert.deepEqual(sent.serials, []) assert.equal(Object.prototype.hasOwnProperty.call(sent, 'idempotencyKey'), false) }) test('reconcile ASKS the shard, because these resources survive a restart', async () => { // The one property that separates this from every other resource in the file. // A crier line lives in shard memory, so a changed `bootId` IS proof it is // gone; a spawned creature is in the world SAVE and survives the restart the // boot stamp would report it lost by. const resources = [ { kind: 'world', ref: '0x40000000', payload: {} }, { kind: 'world', ref: '0x40000001', payload: {} }, ] assert.deepEqual(await actions.reconcileOwned({ runId: 7, resources }), { ok: true, inForce: ['0x40000000'], }) assert.deepEqual(calls.owned, [{ runId: '7' }]) // "I could not ask" must never be read as "it is gone": an unanswered group // leaves every row alone rather than orphaning the lot. uoLinkClient.ownedWorld = async () => ({ ok: false, status: 504, data: null }) assert.equal((await actions.reconcileOwned({ runId: 7, resources })).ok, false) }) test('every world verb declares the same undo contract', async () => { // Five declarations sharing one spread object, asserted rather than assumed: // a verb that quietly lost its `reconcile` would leave its rows unanswered for // the life of the run, and nothing would report it — which is exactly the hole // Phase 11b found in `core.lease`. for (const id of ['uo.creature.spawn', 'uo.boss.spawn', 'uo.npc.place', 'uo.gate.open', 'uo.decor.place']) { const action = byId(id) assert.equal(action.risk, 'change', `${id} must be a world change`) assert.equal(action.reversible, 'ledger', `${id} owns what it made`) assert.equal(typeof action.revert, 'function', `${id} has no undo`) assert.equal(typeof action.reconcile, 'function', `${id} can never be asked what it still holds`) assert.ok(action.budgetMs > 12000, `${id} must outlast the client's own timeout`) assert.equal(typeof action.cost, 'function', `${id} is capped by nothing`) } }) test('decoration carries the graphic, and a type this shard never decorates with is refused', async () => { const decor = byId('uo.decor.place') const ok = await decor.perform({ runId: 7, idempotencyKey: 'd'.repeat(40), params: { place: 'Felucca/Britain', item: 'Brazier', count: 2 }, verify: false, }) assert.equal(ok.ok, true) assert.equal(ok.resources.length, 2) // **The item id crosses, and it has to.** Measured on ServUO 57.4, `Static` // accounts for 5031 decoration placements under 1992 DIFFERENT graphics, // because for that class the graphic is the identity: a bare `new Static()` // is never the paving stone the author picked. 131 of 313 types carry more // than one id. assert.equal(calls.spawn[0].type, 'Brazier') assert.equal(calls.spawn[0].itemId, 0x0e31) // Resolving through the atlas is also the boundary: the verb places what this // shard's own decoration files name, which is tighter than "any item that is // not a container" and is the rule the decision actually took. const unknown = await decor.perform({ runId: 7, idempotencyKey: 'd'.repeat(40), params: { place: 'Felucca/Britain', item: 'BlackrockCrate', count: 1 }, verify: false, }) assert.equal(unknown.ok, false) assert.equal(unknown.retry, false) assert.match(unknown.error, /never mention/) assert.equal(calls.spawn.length, 1) })