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>
798 lines
35 KiB
JavaScript
798 lines
35 KiB
JavaScript
// ── 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 contract’s 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')
|
||
})
|