An action answering verify: true with { ok: true, detail: { warnings } } has
each line shown as a warning finding (code `advice`) that never fails the
dry run. Strings only, at most ten per step, each cut at 500 characters;
anything else under warnings is ignored, and a refused step carries none.
Rust PLAN_REDESIGNS §11.4, D391: a raid base's spot is checked against the
world as it stands now (a player on it, a rock beside it), which may change
before the run, so the editor shows it as advice. One optional reading of an
existing member, so minor; Module-uo's ^1.10.0 still resolves and says no
warnings. STEAM_SIGNIN.md's claim moves to 1.17.0.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
303 lines
13 KiB
JavaScript
303 lines
13 KiB
JavaScript
// ── The dry run (EVENTS_PLAN.md Phase 6, EVENTS.md §I) ─────────────────────
|
||
//
|
||
// *"Materialise the steps, dispatch each with `verify: true`, report what would
|
||
// happen and what it would cost against the caps."*
|
||
//
|
||
// **The finding worth the most is the one no other path can make.** Every
|
||
// per-step check here is also made at save or at dispatch; the TOTAL is not.
|
||
// Three steps each spawning 15 under a cap of 30 pass every individual check and
|
||
// breach the cap on the third — at two in the morning, unattended, with the world
|
||
// half-changed. Adding the costs up across the whole version is the thing only a
|
||
// look at the plan as a whole can do, and it is why a dry run is worth more than
|
||
// the sum of its step checks.
|
||
//
|
||
// The second property this file holds is the one §I states outright: **`verify:
|
||
// true` must change nothing and must answer honestly.** So the actions here
|
||
// record what they were asked and assert that the flag arrived — a dry run that
|
||
// silently dispatched for real is the single worst bug this feature could ship,
|
||
// and it would look exactly like a passing test otherwise.
|
||
|
||
process.env.DB_HOST = '127.0.0.1'
|
||
process.env.DB_PORT = '59999'
|
||
|
||
const { test, beforeEach, afterEach, after } = require('node:test')
|
||
const assert = require('node:assert/strict')
|
||
|
||
const registries = require('../src/modules/registries')
|
||
const { verifySpec } = require('../src/events/verify')
|
||
const settingsDb = require('../src/model/events/eventActionSettings.db')
|
||
const db = require('../src/utils/db')
|
||
|
||
after(() => db.close())
|
||
|
||
const originals = { ...settingsDb }
|
||
let store
|
||
let seen
|
||
|
||
beforeEach(() => {
|
||
registries._reset()
|
||
store = new Map()
|
||
seen = []
|
||
settingsDb.byIds = async (ids) =>
|
||
new Map([...new Set(ids || [])].filter((i) => store.has(i)).map((i) => [i, store.get(i)]))
|
||
settingsDb.get = async (id) => store.get(id) || null
|
||
})
|
||
|
||
afterEach(() => {
|
||
Object.assign(settingsDb, originals)
|
||
registries._reset()
|
||
})
|
||
|
||
/**
|
||
* Declare the budget dimensions a batch of actions prices.
|
||
*
|
||
* §F, fail closed (Phase 7): a `cost()` naming a dimension no module registered
|
||
* is refused BEFORE any cap arithmetic runs. Without this, every cap test below
|
||
* would pass for the WRONG reason — refused by the layer above the one under
|
||
* test. Discovered the way the switchboard discovers them, by pricing the
|
||
* action's own declared examples, so a test never has to keep a second list of
|
||
* its dimensions in step with its `cost()`.
|
||
*
|
||
* Written out here rather than borrowed from `authorize.dimensionsOf` so this
|
||
* file keeps stubbing exactly what it means to stub. The undeclared case is not
|
||
* an omission; it has its own test.
|
||
*/
|
||
const declaredBudgets = (entries, owner) => {
|
||
const dimensions = new Set()
|
||
for (const e of entries) {
|
||
if (typeof e.cost !== 'function') continue
|
||
const params = {}
|
||
for (const p of e.params || []) if (p.example !== undefined) params[p.name] = p.example
|
||
let priced
|
||
try {
|
||
priced = e.cost(params)
|
||
} catch {
|
||
continue
|
||
}
|
||
for (const d of Object.keys(priced || {})) dimensions.add(d)
|
||
}
|
||
return [...dimensions]
|
||
.filter((id) => id.startsWith(`${owner}.`))
|
||
.map((id) => ({ id, label: id, unit: 'count' }))
|
||
}
|
||
|
||
const register = (entries, owner = 'test') => {
|
||
const api = registries.stage(owner)
|
||
api.registerEventActions(entries)
|
||
api.registerEventBudgets(declaredBudgets(entries, owner))
|
||
registries.apply(api.staged)
|
||
}
|
||
|
||
const setSetting = (id, enabled, caps = {}) =>
|
||
store.set(id, { action_id: id, enabled: enabled ? 1 : 0, caps })
|
||
|
||
/** An action that records every envelope it is handed and then answers `answer`. */
|
||
const recorder = (id, over = {}) => ({
|
||
id,
|
||
label: over.label || id,
|
||
risk: over.risk || 'notify',
|
||
reversible: 'none',
|
||
version: over.version || 1,
|
||
params: over.params || [],
|
||
...(over.cost ? { cost: over.cost } : {}),
|
||
async perform(envelope) {
|
||
seen.push({ id, ...envelope })
|
||
if (over.throws) throw new Error(over.throws)
|
||
return over.answer || { ok: true }
|
||
},
|
||
})
|
||
|
||
const spec = (steps) => ({ phases: [{ key: 'one', steps }] })
|
||
const step = (actionId, params = {}, extra = {}) => ({ actionId, params, ...extra })
|
||
|
||
const ADMIN = { id: 1, role: 'admin' }
|
||
const EDITOR = { id: 2, role: 'editor' }
|
||
|
||
test('every step is dispatched with verify true, and nothing is asked to act', async () => {
|
||
// §I's promise, held as an assertion about the envelope each action received.
|
||
register([recorder('test.tell'), recorder('test.wait', { risk: 'inspect' })])
|
||
const report = await verifySpec(spec([step('test.tell', { body: 'x' }), step('test.wait')]), { user: ADMIN })
|
||
|
||
assert.equal(report.ok, true)
|
||
assert.equal(report.steps, 2)
|
||
assert.deepEqual(report.findings, [])
|
||
assert.equal(seen.length, 2)
|
||
for (const envelope of seen) assert.equal(envelope.verify, true)
|
||
})
|
||
|
||
test('the cost of the whole plan is added up across steps, and a total over the cap is a finding', async () => {
|
||
// The one check that only exists here. Each of the three steps fits under 30 on
|
||
// its own; together they do not.
|
||
register([recorder('test.spawn', { risk: 'change', cost: (p) => ({ 'test.creatures': p.count }) })])
|
||
setSetting('test.spawn', true, { 'test.creatures': 30 })
|
||
|
||
const report = await verifySpec(
|
||
spec([
|
||
step('test.spawn', { count: 15 }),
|
||
step('test.spawn', { count: 15 }),
|
||
step('test.spawn', { count: 15 }),
|
||
]),
|
||
{ user: ADMIN },
|
||
)
|
||
|
||
assert.equal(report.ok, false)
|
||
const total = report.findings.find((f) => f.code === 'cap-total')
|
||
assert.ok(total, 'the whole-plan total must be its own finding')
|
||
assert.match(total.message, /asks for 45 of "test.creatures" across all its steps, and this deployment allows 30/)
|
||
assert.deepEqual(report.cost, [
|
||
{ dimension: 'test.creatures', total: 45, cap: 30, from: 'test.spawn', over: true },
|
||
])
|
||
})
|
||
|
||
test('a plan that fits reports its cost without a finding', async () => {
|
||
register([recorder('test.spawn', { risk: 'change', cost: (p) => ({ 'test.creatures': p.count }) })])
|
||
setSetting('test.spawn', true, { 'test.creatures': 30 })
|
||
const report = await verifySpec(spec([step('test.spawn', { count: 12 }), step('test.spawn', { count: 8 })]), {
|
||
user: ADMIN,
|
||
})
|
||
assert.equal(report.ok, true)
|
||
assert.deepEqual(report.cost, [{ dimension: 'test.creatures', total: 20, cap: 30, from: 'test.spawn', over: false }])
|
||
})
|
||
|
||
test('an uncapped dimension is reported with its total and no cap', async () => {
|
||
// Worth showing rather than hiding: "this event will spawn 40 creatures and
|
||
// nothing bounds that" is exactly what an operator opening the switchboard
|
||
// wants to have seen first.
|
||
register([recorder('test.spawn', { risk: 'change', cost: () => ({ 'test.creatures': 40 }) })])
|
||
setSetting('test.spawn', true, {})
|
||
const report = await verifySpec(spec([step('test.spawn')]), { user: ADMIN })
|
||
assert.equal(report.ok, true)
|
||
assert.deepEqual(report.cost, [{ dimension: 'test.creatures', total: 40, cap: null, from: null, over: false }])
|
||
})
|
||
|
||
test('a step naming an action nobody registers is a dormant finding, and the rest are still checked', async () => {
|
||
// Reported rather than thrown, so an author sees EVERY problem in one pass. A
|
||
// verification that stopped at the first finding would make fixing a
|
||
// twelve-step definition twelve round trips.
|
||
register([recorder('test.tell')])
|
||
const report = await verifySpec(spec([step('gone.away'), step('test.tell')]), { user: ADMIN })
|
||
|
||
assert.equal(report.ok, false)
|
||
assert.equal(report.findings.length, 1)
|
||
assert.equal(report.findings[0].code, 'dormant')
|
||
assert.equal(report.findings[0].actionId, 'gone.away')
|
||
assert.equal(seen.length, 1, 'the registered step is still dispatched')
|
||
})
|
||
|
||
test('the caller’s own role is what the report answers against', async () => {
|
||
// The value of doing it here: an editor is told a step needs an administrator
|
||
// at the moment they can still do something about it, rather than at the moment
|
||
// it does not run.
|
||
register([recorder('test.change', { risk: 'change', label: 'Change things' })])
|
||
setSetting('test.change', true)
|
||
|
||
const asEditor = await verifySpec(spec([step('test.change')]), { user: EDITOR })
|
||
assert.equal(asEditor.ok, false)
|
||
assert.equal(asEditor.findings[0].code, 'role')
|
||
|
||
const asAdmin = await verifySpec(spec([step('test.change')]), { user: ADMIN })
|
||
assert.equal(asAdmin.ok, true)
|
||
})
|
||
|
||
test('a disabled action is a finding, and its step is never dispatched', async () => {
|
||
// The order matters: dispatching a disabled action under `verify: true` would
|
||
// change nothing, but it would call code the deployment has switched off, and
|
||
// "we only ran it to ask whether we could run it" is not a defence anyone wants
|
||
// to make.
|
||
register([recorder('test.change', { risk: 'change' })])
|
||
const report = await verifySpec(spec([step('test.change')]), { user: ADMIN })
|
||
assert.equal(report.findings[0].code, 'disabled')
|
||
assert.equal(seen.length, 0)
|
||
})
|
||
|
||
test('the module’s own refusal is a finding in the module’s words', async () => {
|
||
// The half core cannot compute: whether the landmark exists, whether the
|
||
// creature is on the allowlist, whether the shard is reachable at all.
|
||
register([recorder('test.spawn', { answer: { ok: false, retry: false, error: 'no such landmark "Bratain"' } })])
|
||
const report = await verifySpec(spec([step('test.spawn')]), { user: ADMIN })
|
||
assert.equal(report.ok, false)
|
||
assert.equal(report.findings[0].code, 'refused')
|
||
assert.match(report.findings[0].message, /no such landmark/)
|
||
})
|
||
|
||
test('an action that throws under verify is a finding, not a 500 on the author’s screen', async () => {
|
||
register([recorder('test.spawn', { throws: 'exploded' })])
|
||
const report = await verifySpec(spec([step('test.spawn')]), { user: ADMIN })
|
||
assert.equal(report.ok, false)
|
||
assert.equal(report.findings[0].code, 'refused')
|
||
assert.match(report.findings[0].message, /exploded/)
|
||
})
|
||
|
||
test('a step authored against an older action version is a WARNING, not an error', async () => {
|
||
// §F: a bump makes the editor render a warning rather than refusing to run. The
|
||
// level is the whole point — a warning does not hold a scheduled start, and an
|
||
// error does.
|
||
register([recorder('test.tell', { version: 3 })])
|
||
const report = await verifySpec(spec([step('test.tell', {}, { actionVersion: 1 })]), { user: ADMIN })
|
||
assert.equal(report.ok, true, 'a drift warning must not fail the dry run')
|
||
assert.equal(report.findings[0].level, 'warning')
|
||
assert.equal(report.findings[0].code, 'version-drift')
|
||
assert.match(report.findings[0].message, /authored against version 1/)
|
||
})
|
||
|
||
test('a module’s advice is a WARNING finding per line, and the dry run still passes (MODULE_API 1.16.0)', async () => {
|
||
// Rust PLAN_REDESIGNS §11.4, D391: a raid base's spot is checked against the
|
||
// world as it stands now. A player standing on it may walk off before the run,
|
||
// so the check is advice, never a reason the plan fails.
|
||
register([
|
||
recorder('test.spawn', {
|
||
answer: { ok: true, detail: { warnings: ['the spot is refused now: a player is within 50 m', ' ', 'at the spot now: Rock is too large', 7] } },
|
||
}),
|
||
])
|
||
const report = await verifySpec(spec([step('test.spawn')]), { user: ADMIN })
|
||
assert.equal(report.ok, true, 'advice must not fail the dry run')
|
||
assert.deepEqual(
|
||
report.findings.map((f) => [f.level, f.code, f.message, f.actionId]),
|
||
[
|
||
['warning', 'advice', 'the spot is refused now: a player is within 50 m', 'test.spawn'],
|
||
['warning', 'advice', 'at the spot now: Rock is too large', 'test.spawn'],
|
||
],
|
||
)
|
||
})
|
||
|
||
test('advice is bounded: at most ten lines, each cut at 500 characters, and anything but strings ignored', async () => {
|
||
const { adviceOf } = require('../src/events/verify')
|
||
assert.deepEqual(adviceOf(null), [])
|
||
assert.deepEqual(adviceOf({ warnings: 'one line' }), [])
|
||
assert.deepEqual(adviceOf({ note: 'no warnings here' }), [])
|
||
assert.equal(adviceOf({ warnings: Array.from({ length: 30 }, (_, i) => `line ${i}`) }).length, 10)
|
||
const long = adviceOf({ warnings: ['x'.repeat(900)] })[0]
|
||
assert.equal(long.length, 500)
|
||
assert.ok(long.endsWith('…'))
|
||
})
|
||
|
||
test('a refused step carries no advice: the refusal is the finding', async () => {
|
||
register([recorder('test.spawn', { answer: { ok: false, retry: false, error: 'no such base', detail: { warnings: ['ignored'] } } })])
|
||
const report = await verifySpec(spec([step('test.spawn')]), { user: ADMIN })
|
||
assert.equal(report.ok, false)
|
||
assert.deepEqual(report.findings.map((f) => f.code), ['refused'])
|
||
})
|
||
|
||
test('a finding says which phase and which step it is about', async () => {
|
||
// A twelve-step definition needs the finding anchored, or the author is left
|
||
// reading the message and counting rows.
|
||
register([recorder('test.tell')])
|
||
const twoPhases = {
|
||
phases: [
|
||
{ key: 'open', steps: [step('test.tell')] },
|
||
{ key: 'close', steps: [step('test.tell'), step('gone.away')] },
|
||
],
|
||
}
|
||
const report = await verifySpec(twoPhases, { user: ADMIN })
|
||
assert.equal(report.steps, 3)
|
||
assert.deepEqual(
|
||
report.findings.map((f) => ({ phase: f.phase, seq: f.seq })),
|
||
[{ phase: 'close', seq: 1 }],
|
||
)
|
||
})
|
||
|
||
test('an empty spec verifies clean and costs nothing', async () => {
|
||
const report = await verifySpec({ phases: [] }, { user: ADMIN })
|
||
assert.deepEqual(report, { ok: true, steps: 0, findings: [], cost: [] })
|
||
})
|