Files
website/server/test/eventModuleContract.test.js
wtclaude 809426ad73
Some checks failed
PR Checks / bot-tests (pull_request) Successful in 36s
PR Checks / client-build (pull_request) Successful in 42s
PR Checks / server-tests (pull_request) Failing after 5m48s
fix(events): give a lease's ledger row a reconcile path (Phase 11b)
A lease row had no reconcile path at all, and nothing failed to say so.
`cleanup.js` resolves a resource to the action of the step that made it, and for
a lease that action is `core.lease` -- a CORE action, on a path a module cannot
register anything on. So every `override` row came back `unanswered` for the life
of the run, and a lease the shard had quietly dropped (a config lease is
memory-only there, so a restart reverts it by design) stayed in the ledger as
live until teardown went hunting a baseline nobody was holding.

`core.lease` gains a `reconcile()`, and `registerEventLeases` gains an optional
`inForce()`: "does the game side still have any record of this hold?"

Deliberately not `read()` plus a comparison. A value that differs from what the
run applied is DRIFT, which teardown must deliver through `restore()` so the row
lands `drifted` with the current value beside it; a reconcile that inferred
absence from a changed value would orphan the row first and tell the operator the
lease vanished rather than that somebody moved it. Only an explicit
`{ ok: true, held: false }` takes a row out -- a throw, a timeout, an
unrecognised shape and a lease with no `inForce()` all leave the ledger alone.

MODULE_API_VERSION stays 1.10.0, amended in place.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-04 19:31:44 -05:00

798 lines
35 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── The module contract, proved with a module (EVENTS_PLAN.md Phase 7) ─────
//
// §F is the seam this phase opens: `registerEventActions`, `registerEventBudgets`,
// `registerEventLeases` and `registerEventOptionSources`, at MODULE_API 1.10.0.
// Every one of them was reachable only by `registerCore()` before this phase, and
// the plan is explicit about how to prove they are reachable now:
//
// > **Prove it with a throwaway module, not with module-uo.** A contract
// > validated only against the module it was carved out of has not been
// > validated, and P9 should be the *second* consumer of this seam.
//
// So every test below writes a real module to a real directory, points
// MODULES_DIR at it and lets the real loader scan, validate, `register()` and
// commit it. Nothing here stubs the loader or calls `registries.stage()` by hand:
// a test that staged directly would pass just as happily against the code before
// this phase, when `buildApi` forwarded none of these four names.
//
// **The half this file cares most about is the failure half.** §F's load-bearing
// envelope rule is that *no shape a failure can take may read as success* — a
// rejected promise, a throw, a timeout, a non-object and a missing `ok` are all
// `{ ok: false, retry: true }`, which is `registerTeamProvider`'s default
// inverted, because the expensive mistake here is recording a world change that
// did not happen. Those five shapes are dispatched from a module below, not
// constructed as literals, so what is under test is the path a module actually
// takes.
//
// The pool points at a closed port before anything is required: the loader's
// `buildCtx` pulls in the models, which build a mariadb pool at require time. No
// query is ever run.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const fs = require('fs')
const os = require('os')
const path = require('path')
const { test, beforeEach, after } = require('node:test')
const assert = require('node:assert/strict')
const express = require('express')
const db = require('../src/utils/db')
const registries = require('../src/modules/registries')
const dispatch = require('../src/events/dispatch')
const authorize = require('../src/events/authorize')
const { MODULE_API_VERSION } = require('../src/modules/version')
after(() => db.close())
let tmpRoot
const emptyTiers = () => ({
public: express.Router(),
admin: express.Router(),
player: express.Router(),
})
/**
* Write a module and let the real loader scan it.
*
* MODULES_DIR is read into a const at require time — it has to be, the scan is
* synchronous and happens while app.js is being required — so busting the cache
* is the only honest way to point the loader somewhere else.
*/
function loadModule(id, source, manifest = {}) {
const dir = path.join(tmpRoot, id)
fs.mkdirSync(dir, { recursive: true })
fs.writeFileSync(
path.join(dir, 'module.json'),
JSON.stringify({ id, name: id, version: '1.0.0', coreApi: '^1.10.0', server: 'index.js', ...manifest }),
)
fs.writeFileSync(path.join(dir, 'index.js'), source)
process.env.MODULES_DIR = tmpRoot
registries._reset()
delete require.cache[require.resolve('../src/modules/loader')]
// eslint-disable-next-line global-require
const loader = require('../src/modules/loader')
loader.load(emptyTiers())
return loader.list().find((m) => m.id === id)
}
/** The state a module that loaded cleanly is in, with its reason if it did not. */
const assertRegistered = (record) => {
assert.equal(record.state, 'registered', record.reason || 'expected the module to register')
}
const RUN = { id: 1, scope: '' }
const step = (actionId, params = {}) => ({
id: 1,
action_id: actionId,
params,
idempotency_key: 'k-1',
})
beforeEach(() => {
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-events-module-'))
})
// ── The seam is open ───────────────────────────────────────────────────────
test('the version a module declares against is 1.10.0', () => {
// Not decoration. `coreApi: "^1.10.0"` on every module below is what makes
// these tests fail loudly rather than quietly if the bump is ever reverted —
// the loader would refuse the manifest and every assertion would become "the
// module did not register", which is the same failure the seam closing would
// produce. Asserting the number here says which of the two it was.
assert.equal(MODULE_API_VERSION, '1.10.0')
})
test('a module registers actions, budgets, leases and option sources', () => {
const record = loadModule('demo', `module.exports = (ctx, api) => {
api.registerEventBudgets([
{ id: 'demo.wisps', label: 'Wisps summoned', unit: 'count' },
])
api.registerEventActions([{
id: 'demo.wisp.summon',
label: 'Summon wisps',
risk: 'change',
reversible: 'ledger',
cost: (p) => ({ 'demo.wisps': p.count }),
params: [{ name: 'count', type: 'int', required: true, example: 3 }],
async perform() { return { ok: true, resources: [{ kind: 'wisp', ref: '0x1' }] } },
async revert() { return { ok: true } },
}])
api.registerEventLeases([{
id: 'demo.rate.gain',
label: 'Gain rate',
type: 'float',
min: 0.5,
max: 5,
maxDurationMs: 3600000,
async read() { return { ok: true, value: 1 } },
async apply() { return { ok: true } },
async restore() { return { ok: true } },
}])
api.registerEventOptionSources([{
id: 'demo.options.hues',
label: 'Hues',
async resolve() { return [{ value: '1157', label: 'Blood', group: 'Reds' }] },
}])
}`)
assertRegistered(record)
assert.equal(registries.eventAction('demo.wisp.summon').owner, 'demo')
assert.deepEqual(registries.eventBudget('demo.wisps'), {
owner: 'demo', id: 'demo.wisps', label: 'Wisps summoned', unit: 'count', description: '',
})
assert.equal(registries.eventLease('demo.rate.gain').maxDurationMs, 3600000)
assert.deepEqual(registries.allEventOptionSources().map((s) => s.id), ['demo.options.hues'])
})
test('the catalog never carries a callable, whichever registration it came from', async () => {
// The rule every registry in this file already keeps, restated for four new
// shapes at once: these objects LEAVE THE PROCESS, and the browser's whole
// relationship with any of them is naming one by id. A `perform` or a
// `resolve` riding out would make §F's "a module registers actions server-side
// and adds no routes for them" false in the one direction nobody would notice.
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
api.registerEventActions([{
id: 'demo.a', label: 'A', risk: 'notify', reversible: 'none',
async perform() { return { ok: true } },
}])
api.registerEventLeases([{
id: 'demo.l', label: 'L', type: 'bool', maxDurationMs: 1000,
async read() { return { ok: true } },
async apply() { return { ok: true } },
async restore() { return { ok: true } },
}])
api.registerEventOptionSources([
{ id: 'demo.o', label: 'O', async resolve() { return [] } },
])
}`))
for (const a of registries.allEventActions()) {
assert.equal(a.perform, undefined)
assert.equal(a.revert, undefined)
assert.equal(a.cost, undefined)
}
for (const l of registries.allEventLeases()) {
assert.equal(l.read, undefined)
assert.equal(l.apply, undefined)
assert.equal(l.restore, undefined)
assert.equal(l.inForce, undefined)
}
for (const s of registries.allEventOptionSources()) assert.equal(s.resolve, undefined)
})
test('a second call is a module changing its mind, and it fails the module alone', () => {
// `once`, on all four, for the reason every batch registration takes it: a
// batch is a module's complete statement about what it declares. And the
// failure is the loader's §4.4 guarantee — recorded against the module, the
// site still up, nothing committed.
const record = loadModule('demo', `module.exports = (ctx, api) => {
api.registerEventBudgets([{ id: 'demo.a', label: 'A', unit: 'count' }])
api.registerEventBudgets([{ id: 'demo.b', label: 'B', unit: 'count' }])
}`)
assert.equal(record.state, 'startup_failed')
assert.match(record.reason, /registerEventBudgets\(\) called twice/)
// Validate-then-commit, per registrant: the FIRST batch is gone too.
assert.equal(registries.eventBudget('demo.a'), null)
})
test('a module may not name a budget outside its own prefix', () => {
const record = loadModule('demo', `module.exports = (ctx, api) => {
api.registerEventBudgets([{ id: 'uo.creatures', label: 'Creatures', unit: 'count' }])
}`)
assert.equal(record.state, 'startup_failed')
assert.match(record.reason, /not namespaced "demo\."/)
})
test('a module that registers nothing at all is a module, not a failure', () => {
// §F states it once because it governs every member: everything a module
// registers is optional, and core with none of it is still an event engine
// that can announce, wait, cue a human and publish results.
const record = loadModule('quiet', 'module.exports = () => {}')
assertRegistered(record)
assert.deepEqual(registries.allEventBudgets(), [])
assert.deepEqual(registries.allEventActions().map((a) => a.id), [])
})
// ── The envelope: no failure shape reads as success ────────────────────────
test('every shape a module failure can take is dispatched as a retry, not a success', async () => {
// The five §F names, answered by a real module through the real dispatcher.
// Written as one module with five verbs rather than five modules, because what
// is under test is the CLASSIFIER and a module per shape would be four extra
// loader scans saying nothing.
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
const verb = (id, perform) => ({
id, label: id, risk: 'notify', reversible: 'none', budgetMs: 200, perform,
})
api.registerEventActions([
verb('demo.rejects', async () => { return Promise.reject(new Error('the socket went away')) }),
verb('demo.throws', async () => { throw new Error('a typo in the module') }),
verb('demo.hangs', () => new Promise(() => {})),
verb('demo.lies', async () => 'fine'),
verb('demo.forgets', async () => ({ resources: [] })),
])
}`))
for (const id of ['demo.rejects', 'demo.throws', 'demo.hangs', 'demo.lies', 'demo.forgets']) {
const result = await dispatch.dispatchStep(step(id), { run: RUN })
assert.equal(result.outcome, 'retry', `${id} must not read as success`)
assert.ok(result.error, `${id} must say what went wrong`)
}
})
test('a module that means "never" says so, and only then is it terminal', async () => {
// The inverse of the rule above, and the reason `retry` is opted OUT of rather
// than into: an envelope that forgot to say anything gets the benefit of the
// doubt on the transient question, so only a module that states `retry: false`
// gets a step marked as never going to work.
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
api.registerEventActions([
{ id: 'demo.never', label: 'Never', risk: 'notify', reversible: 'none',
async perform() { return { ok: false, retry: false, error: 'there is no such gate' } } },
{ id: 'demo.later', label: 'Later', risk: 'notify', reversible: 'none',
async perform() { return { ok: false, error: 'the shard is restarting' } } },
])
}`))
const never = await dispatch.dispatchStep(step('demo.never'), { run: RUN })
assert.equal(never.outcome, 'terminal')
assert.equal(never.error, 'there is no such gate')
const later = await dispatch.dispatchStep(step('demo.later'), { run: RUN })
assert.equal(later.outcome, 'retry')
})
test('budgetMs is the contracts deadline, and a wedged module does not hold the tick', async () => {
// Declared by the module, bounded by the registry, enforced by the dispatcher.
// Without it a `perform()` awaiting a socket that never answers holds the
// step's claim until the lease expires and the reclaim re-dispatches it, which
// is how one wedged sidecar becomes an infinite loop rather than a failed step.
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
api.registerEventActions([{
id: 'demo.hangs', label: 'Hangs', risk: 'notify', reversible: 'none',
budgetMs: 120,
perform: () => new Promise(() => {}),
}])
}`))
const started = Date.now()
const result = await dispatch.dispatchStep(step('demo.hangs'), { run: RUN })
assert.equal(result.outcome, 'retry')
assert.match(result.error, /exceeded its 120ms budget/)
assert.ok(Date.now() - started < 2000, 'the runner stopped waiting long before any lease would expire')
})
test('the two success shapes that mean "not finished" reach a module through the same door', async () => {
// §F, and the org lead's 2026-09-02 decision: both are ordinary envelope
// members rather than special cases keyed on an action id, so the runner never
// names a verb — and a module's own long-running action gets `await: 'human'`
// and `holdFor` for free, exactly as `core.cue` and `core.wait` do.
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
api.registerEventActions([
{ id: 'demo.parks', label: 'Parks', risk: 'notify', reversible: 'none',
async perform() { return { ok: true, await: 'human' } } },
{ id: 'demo.holds', label: 'Holds', risk: 'notify', reversible: 'none',
async perform() { return { ok: true, holdFor: 300 } } },
])
}`))
const parked = await dispatch.dispatchStep(step('demo.parks'), { run: RUN })
assert.equal(parked.outcome, 'parked')
const held = await dispatch.dispatchStep(step('demo.holds'), { run: RUN })
assert.equal(held.outcome, 'done')
assert.equal(held.holdSeconds, 300)
})
// ── verify: true is a parameter a module must honour ───────────────────────
test('verify rides through to the module unchanged, and a dry run changes nothing', async () => {
// §F: *"`verify: true` must change nothing and must answer honestly"*. Core
// cannot enforce the first half — only the module knows what its own writes
// are — so what IS testable is that the flag arrives, and that it arrives down
// the same dispatcher the real run uses. A dry run down a second code path is
// a dry run of the second path.
const written = []
global.__rgDemoWrites = written
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
api.registerEventActions([{
id: 'demo.writes', label: 'Writes', risk: 'change', reversible: 'none',
async perform({ verify, params }) {
if (verify) return { ok: true, wouldWrite: params.what }
global.__rgDemoWrites.push(params.what)
return { ok: true }
},
}])
}`))
const dry = await dispatch.dispatchStep(step('demo.writes', { what: 'a gate' }), {
run: RUN,
verify: true,
})
assert.equal(dry.outcome, 'done')
assert.deepEqual(written, [], 'a dry run wrote something')
await dispatch.dispatchStep(step('demo.writes', { what: 'a gate' }), { run: RUN })
assert.deepEqual(written, ['a gate'])
delete global.__rgDemoWrites
})
test('the whole envelope reaches the module, idempotency key included', async () => {
// The key is the module's half of a retry that is safe on the game side, and a
// module cannot pass it down its own wire if core does not hand it over. It is
// asserted here rather than in the runner's tests because THIS is the surface
// a module author reads.
global.__rgDemoEnvelope = null
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
api.registerEventActions([{
id: 'demo.echo', label: 'Echo', risk: 'notify', reversible: 'none',
async perform(envelope) { global.__rgDemoEnvelope = envelope; return { ok: true } },
}])
}`))
await dispatch.dispatchStep(step('demo.echo', { a: 1 }), { run: { id: 7, scope: 'atlantic' } })
const envelope = global.__rgDemoEnvelope
assert.deepEqual(envelope, {
runId: 7,
stepId: 1,
idempotencyKey: 'k-1',
scope: 'atlantic',
params: { a: 1 },
actor: null,
verify: false,
})
delete global.__rgDemoEnvelope
})
// ── Budgets: a module cannot spend what it did not declare ─────────────────
test('a cost naming a dimension the module never declared is refused', async () => {
// §F, fail closed (org lead, 2026-09-03). The module here is not malicious and
// not exotic — it is one that declared two budgets and priced a third, which is
// what a rename looks like. The refusal has its own code because the fix is a
// module's declaration, not a deployment's cap.
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
api.registerEventBudgets([{ id: 'demo.wisps', label: 'Wisps', unit: 'count' }])
api.registerEventActions([{
id: 'demo.summon', label: 'Summon', risk: 'notify', reversible: 'none',
cost: () => ({ 'demo.wraiths': 2 }),
async perform() { return { ok: true } },
}])
}`))
// `settings: null` because this file has no database: passing it explicitly is
// what tells `mayInvoke` not to go and read the switchboard row, and the
// enablement layer answers before the cap layer either way.
const verdict = await authorize.mayInvoke({
action: registries.eventAction('demo.summon'),
params: {},
settings: null,
})
assert.equal(verdict.ok, false)
assert.equal(verdict.code, 'undeclared')
assert.match(verdict.reason, /demo\.wraiths/)
})
test('a declared dimension is priced, named and unbounded until an operator says otherwise', async () => {
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
api.registerEventBudgets([{ id: 'demo.wisps', label: 'Wisps summoned', unit: 'count' }])
api.registerEventActions([{
id: 'demo.summon', label: 'Summon', risk: 'notify', reversible: 'none',
cost: (p) => ({ 'demo.wisps': p.count }),
params: [{ name: 'count', type: 'int', required: true, example: 4 }],
async perform() { return { ok: true } },
}])
}`))
// What the switchboard renders: the id the action prices, dressed with what the
// module called it. Discovered by pricing the declared example, which is why
// §F makes `example` required on every param.
assert.deepEqual(authorize.budgetsOf(registries.eventAction('demo.summon')), [
{ id: 'demo.wisps', label: 'Wisps summoned', unit: 'count', registered: true },
])
// And with no settings row and no run, the answer is yes: a declared budget is
// a dimension that can be counted, not a bound that has been set.
const verdict = await authorize.mayInvoke({
action: registries.eventAction('demo.summon'),
params: { count: 4 },
settings: null,
})
assert.deepEqual(verdict, { ok: true, cost: { 'demo.wisps': 4 } })
})
// ── Option sources ─────────────────────────────────────────────────────────
test('a module answers its own option source, and core normalises what comes back', async () => {
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
api.registerEventOptionSources([{
id: 'demo.options.hues', label: 'Hues',
async resolve() {
return [
{ value: 1157, label: 'Blood', group: 'Reds' },
{ value: '2213', label: 'Ice' },
{ value: '', label: 'a blank nobody can pick' },
'not an option at all',
]
},
}])
}`))
const answer = await registries.resolveOptionSource('demo.options.hues')
assert.equal(answer.ok, true)
assert.equal(answer.owner, 'demo')
// Coerced to strings, because this array is rendered into a `<select>` and
// submitted back as text; and the two entries that could not become an option
// are dropped rather than becoming one that submits "undefined".
assert.deepEqual(answer.options, [
{ value: '1157', label: 'Blood', group: 'Reds' },
{ value: '2213', label: 'Ice' },
])
})
test('every way an option source can fail degrades the field rather than breaking it', async () => {
// §F: a refusal degrades the field to free text with a visible warning. So none
// of these throws, and each says something an operator can act on — an
// authoring form a shard outage can make unusable is a worse failure than the
// typo the dropdown exists to prevent.
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
api.registerEventOptionSources([
{ id: 'demo.options.throws', label: 'Throws', async resolve() { throw new Error('sidecar down') } },
{ id: 'demo.options.lies', label: 'Lies', async resolve() { return { nope: true } } },
{ id: 'demo.options.empty', label: 'Empty', async resolve() { return [] } },
])
}`))
const threw = await registries.resolveOptionSource('demo.options.throws')
assert.equal(threw.ok, false)
assert.match(threw.reason, /could not be read/)
const lied = await registries.resolveOptionSource('demo.options.lies')
assert.equal(lied.ok, false)
assert.match(lied.reason, /no option list/)
// An EMPTY list is not a failure. A source that legitimately has nothing to
// offer today — no landmarks configured yet — must not be reported as broken,
// because the two have different fixes.
const empty = await registries.resolveOptionSource('demo.options.empty')
assert.equal(empty.ok, true)
assert.deepEqual(empty.options, [])
const missing = await registries.resolveOptionSource('demo.options.gone')
assert.equal(missing.ok, false)
assert.match(missing.reason, /no module registers/)
})
// ── Uninstall is dormancy, never an error ──────────────────────────────────
test('an action whose module is gone goes dormant, and a step naming it fails terminal with the module named', async () => {
// §F and §L, verbatim: *"a step naming one fails terminal with the module named
// and the run degrades — never a silent skip"*. The scenario is real and
// ordinary: a module was uninstalled between the publish that pinned the
// version and the run that dispatches it.
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
api.registerEventActions([{
id: 'demo.summon', label: 'Summon', risk: 'notify', reversible: 'none',
async perform() { return { ok: true } },
}])
}`))
assert.equal(registries.isEventAction('demo.summon'), true)
// The uninstall: a fresh scan of a directory the module is no longer in.
fs.rmSync(path.join(tmpRoot, 'demo'), { recursive: true, force: true })
loadModule('other', 'module.exports = () => {}')
assert.equal(registries.isEventAction('demo.summon'), false)
const result = await dispatch.dispatchStep(step('demo.summon'), { run: RUN })
assert.equal(result.outcome, 'terminal')
assert.equal(result.dormant, true)
assert.match(result.error, /no module registers "demo\.summon"/)
})
// ── Phase 8: the ledger's two callables, from a module ─────────────────────
//
// `revert` was already required at registration for `reversible: 'ledger'` —
// Phase 1 put that check in. What Phase 8 added is a caller for it, and
// `reconcile` beside it. Both are proved here through the REAL loader for the
// same reason the four registrations are: a `revert` a test called directly is a
// `revert` core might still have no way to reach.
test('a module\'s revert is reached by the cleanup sweep, resources and key in hand', async () => {
const record = loadModule('demo', `
let seen = null
module.exports = (ctx, api) => {
api.registerEventActions([{
id: 'demo.spawn',
label: 'Spawn',
risk: 'change',
reversible: 'ledger',
params: [],
async perform() { return { ok: true, resources: [{ kind: 'creature', ref: '0xA' }] } },
async revert(arg) { seen = arg; return { ok: true } },
async reconcile() { return { ok: true, inForce: [] } },
}])
api.registerEventOptionSources([
{ id: 'demo.options.seen', label: 'seen', async resolve() { return [{ value: JSON.stringify(seen), label: 'seen' }] } },
])
}
`)
assertRegistered(record)
const action = registries.eventAction('demo.spawn')
// Both callables survived the registration copy — which is not a given: that
// copy is explicit rather than a spread, precisely so nothing rides along, and
// a member added to the contract without being added to it is a member that
// silently does not exist.
assert.equal(typeof action.revert, 'function')
assert.equal(typeof action.reconcile, 'function')
assert.equal(action.owner, 'demo')
const answer = await action.revert({
runId: 3,
resources: [{ kind: 'creature', ref: '0xA', payload: null, memberKey: null }],
idempotencyKey: 'k-1',
})
assert.deepEqual(answer, { ok: true })
// Read back through the module's own option source rather than out of a
// closure this file holds: the point is that what core PASSED is what the
// module SAW, across the seam.
const seen = JSON.parse((await registries.resolveOptionSource('demo.options.seen')).options[0].value)
assert.equal(seen.runId, 3)
assert.equal(seen.idempotencyKey, 'k-1')
assert.deepEqual(seen.resources, [{ kind: 'creature', ref: '0xA', payload: null, memberKey: null }])
})
test('reconcile is optional, and a module without one still registers', () => {
// The asymmetry with `revert`, from the loader's side. A module that cannot say
// what the game still has is not broken — core keeps believing its own ledger,
// which is the behaviour before this phase — whereas one that creates something
// and cannot undo it has made a promise core has no way to keep.
const withNone = loadModule('quiet', `module.exports = (ctx, api) => {
api.registerEventActions([{
id: 'quiet.spawn',
label: 'Spawn',
risk: 'change',
reversible: 'ledger',
params: [],
async perform() { return { ok: true } },
async revert() { return { ok: true } },
}])
}`)
assertRegistered(withNone)
assert.equal(registries.eventAction('quiet.spawn').reconcile, null)
const withoutRevert = loadModule('broken', `module.exports = (ctx, api) => {
api.registerEventActions([{
id: 'broken.spawn',
label: 'Spawn',
risk: 'change',
reversible: 'ledger',
params: [],
async perform() { return { ok: true } },
}])
}`)
assert.equal(withoutRevert.state, 'startup_failed')
assert.match(withoutRevert.reason, /reversible: 'ledger' but has no revert\(\)/)
})
test('a module cannot claim core\'s reserved resource kind', async () => {
// `@step` is the placeholder's kind, and the placeholder is the row whose
// survival is the safety property: a module able to write one could make its
// own step look already accounted for. Refused at recording, and the STEP still
// succeeds — because it did.
const record = loadModule('sneaky', `module.exports = (ctx, api) => {
api.registerEventActions([{
id: 'sneaky.spawn',
label: 'Spawn',
risk: 'change',
reversible: 'ledger',
params: [],
async perform() { return { ok: true, resources: [{ kind: '@step', ref: 'anything' }] } },
async revert() { return { ok: true } },
}])
}`)
assertRegistered(record)
// eslint-disable-next-line global-require
const ledger = require('../src/events/ledger')
const result = await dispatch.dispatchStep(step('sneaky.spawn'), { run: RUN })
assert.equal(result.outcome, 'done')
const parsed = ledger.normalise(result.resources[0], 'sneaky.spawn')
assert.equal(parsed.ok, false)
assert.match(parsed.reason, /reserved kind/)
})
test('core registers the lease VERB and a module registers the lease', async () => {
// The seam working the way round it is meant to (Phase 8). A module ships the
// three callables; the verb an author puts in a step is `core.lease`, so the
// duration bound and the two-events-one-target conflict check live in one place
// rather than being re-implemented once per module and advisory everywhere.
const record = loadModule('demo', `module.exports = (ctx, api) => {
api.registerEventLeases([{
id: 'demo.rate.gain',
label: 'Gain rate',
type: 'float',
min: 0.5,
max: 5,
maxDurationMs: 3600000,
async read() { return { ok: true, value: 1 } },
async apply() { return { ok: true } },
async restore() { return { ok: true } },
}])
}`)
assertRegistered(record)
registries.registerCore()
// The module registers no ACTION at all, and its lease is still reachable.
assert.equal(registries.eventAction('demo.lease'), null)
assert.equal(registries.eventAction('core.lease').reversible, 'override')
// And the dropdown behind `core.lease`'s first param is answered by what the
// module declared — resolved per request, so a module that booted later is
// still in the list.
const options = await registries.resolveOptionSource('core.options.leases')
assert.equal(options.ok, true)
assert.deepEqual(options.options, [{ value: 'demo.rate.gain', label: 'Gain rate', group: 'demo' }])
})
test('inForce() is optional, and a fourth question rather than a fourth spelling of read()', () => {
// Phase 11b. `read` is "what is it now", `apply` is "hold it here", `restore` is
// "put it back and tell me if somebody moved it" -- and none of them answers
// "does the game side still have any record of this hold?", which is what a
// reconcile after an outage needs. A config lease is reverted by a shard restart
// by design, so the answer changes without the value ever being written by us.
//
// Optional, because the fallback is core's posture everywhere: a lease that
// cannot say leaves its ledger row alone.
//
// Three module IDs rather than three reloads of one: `loadModule` rewrites
// `index.js` in place and clears the LOADER from the require cache, but not the
// module file it goes on to require. A second load of the same id silently
// re-registers the first source.
const lease = (id, extra) => `module.exports = (ctx, api) => {
api.registerEventLeases([{
id: '${id}.rate.gain', label: 'Gain rate', type: 'float', min: 0.5, max: 5,
maxDurationMs: 3600000,
async read() { return { ok: true, value: 1 } },
async apply() { return { ok: true } },
async restore() { return { ok: true } },
${extra}
}])
}`
const without = loadModule('leasea', lease('leasea', ''))
assertRegistered(without)
assert.equal(registries.eventLease('leasea.rate.gain').inForce, null)
const withIt = loadModule('leaseb', lease('leaseb', 'async inForce() { return { ok: true, held: true } },'))
assertRegistered(withIt)
assert.equal(typeof registries.eventLease('leaseb.rate.gain').inForce, 'function')
// And a declaration that is present but not callable fails the module rather
// than being ignored -- the same posture the other three take. A module that
// meant to answer and cannot is a module whose leases would silently never be
// reconciled.
const broken = loadModule('leasec', lease('leasec', "inForce: 'yes',"))
assert.equal(broken.state, 'startup_failed')
assert.match(broken.reason, /inForce must be a function/)
})
test('core refuses a lease held longer than the module allows', async () => {
// The bound is the MODULE's number and the enforcement is CORE's, which is the
// §F split stated as one assertion. `retry: false` because a duration that is
// too long will still be too long in sixty seconds: it is an authoring error,
// not an outage.
const record = loadModule('demo', `module.exports = (ctx, api) => {
api.registerEventLeases([{
id: 'demo.rate.gain',
label: 'Gain rate',
type: 'float',
min: 0.5,
max: 5,
maxDurationMs: 3600000,
async read() { return { ok: true, value: 1 } },
async apply() { return { ok: true } },
async restore() { return { ok: true } },
}])
}`)
assertRegistered(record)
registries.registerCore()
const tooLong = await dispatch.dispatchStep(
step('core.lease', { lease: 'demo.rate.gain', value: '3', minutes: 120 }),
{ run: RUN },
)
assert.equal(tooLong.outcome, 'terminal')
assert.match(tooLong.error, /at most 60 minutes, not 120/)
// The same for a value outside the declared range. Unlike a cap, a bad lease
// value is in force the moment it is applied, which is why min/max are required
// on the numeric types rather than advisory.
const tooBig = await dispatch.dispatchStep(
step('core.lease', { lease: 'demo.rate.gain', value: '9', minutes: 10 }),
{ run: RUN },
)
assert.equal(tooBig.outcome, 'terminal')
assert.match(tooBig.error, /accepts 0\.5 to 5/)
// And a lease nobody registers, which is the dormancy rule one registry along.
const missing = await dispatch.dispatchStep(
step('core.lease', { lease: 'demo.nope', value: '3', minutes: 10 }),
{ run: RUN },
)
assert.equal(missing.outcome, 'terminal')
assert.match(missing.error, /no module registers the lease "demo\.nope"/)
})
test('a dry run of core.lease checks everything and takes nothing', async () => {
// `verify: true` must change nothing and must answer honestly (§F). A verify
// that reserved the target would be a dry run that changed something — and it
// would then refuse the real run that followed it, which is the worst of both.
let applied = 0
const record = loadModule('demo', `
let applied = 0
module.exports = (ctx, api) => {
api.registerEventLeases([{
id: 'demo.rate.gain',
label: 'Gain rate',
type: 'float',
min: 0.5,
max: 5,
maxDurationMs: 3600000,
async read() { return { ok: true, value: 1 } },
async apply() { applied += 1; return { ok: true } },
async restore() { return { ok: true } },
}])
api.registerEventOptionSources([
{ id: 'demo.options.applied', label: 'applied', async resolve() { return [{ value: String(applied), label: 'n' }] } },
])
}
`)
assertRegistered(record)
registries.registerCore()
void applied
const ok = await dispatch.dispatchStep(
step('core.lease', { lease: 'demo.rate.gain', value: '3', minutes: 10 }),
{ run: RUN, verify: true },
)
assert.equal(ok.outcome, 'done')
assert.equal((await registries.resolveOptionSource('demo.options.applied')).options[0].value, '0')
// A dry run that is still a real check: the bad duration is caught with
// `verify: true` as well, which is the whole value of the switchboard's
// "find out before you schedule it".
const bad = await dispatch.dispatchStep(
step('core.lease', { lease: 'demo.rate.gain', value: '3', minutes: 999 }),
{ run: RUN, verify: true },
)
assert.equal(bad.outcome, 'terminal')
})