|
|
|
|
@@ -27,24 +27,60 @@ let calls
|
|
|
|
|
const saved = {}
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
calls = { broadcast: [], crier: [], crierDel: [], news: [], newsDel: [] }
|
|
|
|
|
for (const name of ['adminBroadcast', 'postTownCrier', 'deleteTownCrier', 'postNews', 'deleteNews']) {
|
|
|
|
|
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.
|
|
|
|
|
shardAtlas.listLandmarks = async () => [{ facet: 'Felucca', name: 'Britain', x: 1496, y: 1628, z: 10 }]
|
|
|
|
|
// 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(() => {
|
|
|
|
|
@@ -55,6 +91,11 @@ afterEach(() => {
|
|
|
|
|
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 ────────────────────────────────────
|
|
|
|
|
@@ -116,9 +157,15 @@ test('the declarations satisfy the shape core validates them with', () => {
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('a broadcast spends the one budget dimension the module declares', () => {
|
|
|
|
|
test('every dimension a cost names is one this module declares', () => {
|
|
|
|
|
const declared = new Set(actions.BUDGETS.map((b) => b.id))
|
|
|
|
|
assert.deepEqual([...declared], ['uo.broadcasts'])
|
|
|
|
|
// Phase 12a. All six are 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.
|
|
|
|
|
assert.deepEqual(
|
|
|
|
|
[...declared],
|
|
|
|
|
['uo.broadcasts', 'uo.creatures', 'uo.bosses', 'uo.npcs', 'uo.decor', 'uo.gate.minutes'],
|
|
|
|
|
)
|
|
|
|
|
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')
|
|
|
|
|
@@ -134,6 +181,24 @@ test('a broadcast spends the one budget dimension the module declares', () => {
|
|
|
|
|
// 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 ───────────────
|
|
|
|
|
@@ -450,16 +515,26 @@ test('a landmark groups by the atlas grouping where it has one, the facet otherw
|
|
|
|
|
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 () => {
|
|
|
|
|
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: 'orc-brute', name: 'Orc Brute' }] }
|
|
|
|
|
return { creatures: [{ slug: 'orcbrute', name: 'OrcBrute' }] }
|
|
|
|
|
}
|
|
|
|
|
assert.deepEqual(await source('uo.options.creatures').resolve(), [
|
|
|
|
|
{ value: 'orc-brute', label: 'Orc Brute' },
|
|
|
|
|
{ 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 () =>
|
|
|
|
|
@@ -475,3 +550,279 @@ test('an atlas larger than the dropdown bound is truncated and said so', async (
|
|
|
|
|
.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('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)
|
|
|
|
|
})
|
|
|
|
|
|