Two defects the Phase 16b re-verify found in the released v1.2.1 bundle, both of which make a shipped feature unusable and neither of which any test saw. ## The aggregator discarded the UniqueId `shard_spawn_points.unique_id` was NULL on all 6,455 rows of a stock 57.4 tree. `listSpawners` filters `unique_id IS NOT NULL`, so `uo.options.spawners` was an empty dropdown -- and it is the ONLY option source for the Phase 12b object-property leases, so no `Spawner.MaxCount` / `MinDelay` / `MaxDelay` lease could be authored at all, with nothing on the form to say why. Every part of the path was already right except one line. The spawn files carry `<UniqueId>` (~6,374 of them), `parsePoints` returns it, the column exists and the insert passes `p.uniqueId || null`. `buildAtlas` rebuilds each point from an explicit field list and `uniqueId` was not on it -- the word appears nowhere in that file. `PARSER_VERSION = 4`'s own note says "a spawn point keeps its UniqueId, which is what a property lease targets", so the intent shipped as a comment while the code dropped the field one function later. `PARSER_VERSION` goes to 5 because the bump is the only thing that re-reads an already-imported tree: `sameSources` compares the tree's hashes, which have not changed -- only what is kept from them. Confirmed on the rig, where the boot after the fix logged `spawn atlas refreshed` on an unchanged tree and the manual import then correctly answered `unchanged`. ## A landmark option value named 23 places at once A stock tree has 558 landmarks under 320 distinct `facet/name` pairs. `Trammel/Entrance` is 23 different dungeons -- Blighted Grove, Covetous, Deceit, Despise, Destard and so on -- and `landmarkPoint` resolved with `.find()`, so 22 of the 23 were unreachable. An author who picked "Entrance - Destard" got Blighted Grove, and the run succeeded with no warning. The group was already the disambiguator: it was shown in the dropdown and left out of the value. The value is now `facet/group/name`, which is distinct across all 558. `landmarkPoint` tries that form first and keeps the two-part read as a fallback, because every event published before this fix stores `facet/name` and a published version is immutable -- refusing to parse those would break runs rather than correct them. The fallback keeps the old first-match behaviour deliberately: it is imprecise in exactly the way it always was, and silently relocating a live event's spawn point is worse than repeating a known imprecision. A three-part value whose group is gone REFUSES rather than falling back to the name, because it asked for one particular place. ## Verification On the released-artefact rig (installer -> bundle 2026.09.10 -> stock 57.4 tree -> protocol-7 sidecar -> core at main with this module): spawn points 6455 rows, 6364 with a unique_id (was 0) uo.options.spawners 100 options, and `?q=orc` searches them (was 0) uo.options.landmarks 558 options, 558 distinct values (was 320) suite 625 pass, 0 fail Each new test was confirmed to FAIL without its fix. The atlas one asserts the field on the AGGREGATOR's output rather than the parser's, which is the whole point of it -- and the test fixture had no `<UniqueId>` at all until now, which is exactly why a green suite said nothing. The landmark one asserts an INEQUALITY between two resolved points rather than a literal value string, so it survives another change of format as long as two options still address two places. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
958 lines
44 KiB
JavaScript
958 lines
44 KiB
JavaScript
// 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, /<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 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')
|
|
})
|
|
|
|
// ── A landmark option value names ONE landmark (Phase 16b) ────────────────
|
|
|
|
test('two landmarks sharing a name are two different options, and both resolve', async () => {
|
|
// A stock 57.4 tree has 558 landmarks under 320 distinct `facet/name` pairs:
|
|
// `Trammel/Entrance` is 23 different dungeons. The source emitted `facet/name`
|
|
// and `landmarkPoint` resolved with `.find()`, so 22 of the 23 were unreachable
|
|
// — an author who picked "Entrance — Destard" got Blighted Grove, with a
|
|
// successful run and no warning. The group was already the disambiguator and it
|
|
// was shown to the eye while being left out of the value.
|
|
//
|
|
// Asserted as an INEQUALITY between two resolved points rather than against a
|
|
// literal value string, so it survives someone changing the value's format
|
|
// again as long as the two options still address two places.
|
|
shardAtlas.listLandmarks = async () => [
|
|
{ facet: 'Felucca', name: 'Entrance', group: 'Blighted Grove', x: 586, y: 1643, z: 0 },
|
|
{ facet: 'Felucca', name: 'Entrance', group: 'Destard', x: 1176, y: 2637, z: 0 },
|
|
]
|
|
|
|
const source = actions.OPTION_SOURCES.find((s) => s.id === 'uo.options.landmarks')
|
|
const options = await source.resolve({})
|
|
assert.equal(options.length, 2)
|
|
assert.equal(new Set(options.map((o) => o.value)).size, 2, 'both options must be addressable')
|
|
|
|
const points = []
|
|
for (const option of options) {
|
|
const result = await byId('uo.creature.spawn').perform({
|
|
runId: 41,
|
|
idempotencyKey: `L${option.value}`.padEnd(40, 'x'),
|
|
params: { place: option.value, creature: 'Orc', count: 1 },
|
|
verify: true,
|
|
})
|
|
assert.equal(result.ok, true, `${option.value} must resolve`)
|
|
points.push(option.value)
|
|
}
|
|
assert.notEqual(points[0], points[1])
|
|
})
|
|
|
|
test('a place published before the group was carried still resolves', async () => {
|
|
// Every event published before the fix stores `facet/name`, and a published
|
|
// version is immutable — so a parse that stopped understanding the two-part
|
|
// form would break those runs rather than correct them. It keeps the old
|
|
// first-match read, which is imprecise in exactly the way it always was.
|
|
shardAtlas.listLandmarks = async () => [
|
|
{ facet: 'Felucca', name: 'Entrance', group: 'Blighted Grove', x: 586, y: 1643, z: 0 },
|
|
{ facet: 'Felucca', name: 'Entrance', group: 'Destard', x: 1176, y: 2637, z: 0 },
|
|
// A name carrying a slash reads as three parts too; the two-part read is what
|
|
// resolves it, which is why the three-part attempt must not answer for it.
|
|
{ facet: 'Felucca', name: 'Odd/Name', group: null, x: 10, y: 20, z: 0 },
|
|
]
|
|
|
|
for (const place of ['Felucca/Entrance', 'Felucca/Odd/Name']) {
|
|
const result = await byId('uo.creature.spawn').perform({
|
|
runId: 42,
|
|
idempotencyKey: `P${place}`.padEnd(40, 'x'),
|
|
params: { place, creature: 'Orc', count: 1 },
|
|
verify: true,
|
|
})
|
|
assert.equal(result.ok, true, `${place} must still resolve`)
|
|
}
|
|
|
|
// And a three-part value whose group is gone REFUSES rather than silently
|
|
// landing somewhere else. That is the honest answer: it asked for one place.
|
|
const gone = await byId('uo.creature.spawn').perform({
|
|
runId: 42,
|
|
idempotencyKey: 'G'.repeat(40),
|
|
params: { place: 'Felucca/Renamed/Entrance', creature: 'Orc', count: 1 },
|
|
verify: true,
|
|
})
|
|
assert.equal(gone.ok, false)
|
|
assert.match(gone.error, /no landmark called/)
|
|
})
|
|
|
|
// ── 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)
|
|
})
|