MODULE_API 1.10.0. Four names forwarded on the module-facing `api` -- registerEventActions, registerEventBudgets, registerEventLeases and registerEventOptionSources -- one new route, and one rule made real: a `cost()` naming a dimension no module declared is refused. Only one of the four is new machinery. The action registry has staged core's three actions on every boot since Phase 1; what it never had was a way in, because loader.js builds its own `api` facade and had no method that delegated to it. So the registry a module now reaches is one that has been exercised on every boot for six phases. Four decisions, settled 2026-09-03, all as recommended: - Option sources are their own registration, modelled on registerAudiences, because a catalog has more than one consumer. - An undeclared dimension is refused -- at save, at the dry run and at dispatch -- with its own code, because the fix is a module's declaration and not a deployment's cap. - A lease is declared here and acquired by nothing; the ledger is Phase 8. - Core registers core.options.legs, so an announce leg is a dropdown rather than the free-text box whose typo Phase 6's walk caught mid-run. Proved with a throwaway module through the real loader, not with module-uo: eventModuleContract.test.js writes a module to a real directory and lets the loader scan it, covering all five envelope failure shapes, verify: true, the four id spaces and dormancy on uninstall. The live walk found the one defect nothing else could: the option-source loader wrote its "already asked?" guard inside a setState updater and read it on the next line, so the request was never made and the field sat on "Reading the list..." for ever. It is a useRef now. Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6t8mrAWhZU5vnyYgZTMtL
384 lines
16 KiB
JavaScript
384 lines
16 KiB
JavaScript
// ── The event action registry (EVENTS.md §F, Phase 1) ──────────────────────
|
|
//
|
|
// Phase 1's acceptance criteria for the registry half, one test apiece:
|
|
//
|
|
// • core's three actions register on every boot and appear in the catalog
|
|
// • the catalog never carries a callable — no `perform`, `revert` or `cost`
|
|
// • a module registering an un-namespaced action fails, with the holder named
|
|
// • the closed sets are closed: an invented risk or reversibility is refused
|
|
// • `reversible: 'ledger'` without a `revert()` is refused AT REGISTRATION,
|
|
// not discovered at teardown when something has already been created
|
|
// • an action id and a trigger id are DIFFERENT namespaces, so one id may
|
|
// legitimately be both — the property the audience registry established and
|
|
// this one inherits
|
|
//
|
|
// Point the DB at a closed port BEFORE requiring anything: registries.js reaches
|
|
// utils/discordAnnounce, which reaches the pool at require time.
|
|
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 coreEventActions = require('../src/config/coreEventActions')
|
|
const db = require('../src/utils/db')
|
|
|
|
after(() => db.close())
|
|
|
|
beforeEach(() => registries._reset())
|
|
afterEach(() => registries._reset())
|
|
|
|
const ok = (over = {}) => ({
|
|
id: 'demo.thing.do',
|
|
label: 'Do the thing',
|
|
risk: 'change',
|
|
reversible: 'none',
|
|
perform: async () => ({ ok: true }),
|
|
...over,
|
|
})
|
|
|
|
const register = (owner, entries) => {
|
|
const api = registries.stage(owner)
|
|
api.registerEventActions(entries)
|
|
registries.apply(api.staged)
|
|
}
|
|
|
|
test('core registers its three actions on every boot', () => {
|
|
registries.registerCore()
|
|
const ids = registries.allEventActions().map((a) => a.id)
|
|
assert.deepEqual(ids, ['core.announce', 'core.wait', 'core.cue'])
|
|
assert.equal(ids.length, coreEventActions.ACTIONS.length)
|
|
})
|
|
|
|
test('the catalog carries no callable', () => {
|
|
registries.registerCore()
|
|
for (const action of registries.allEventActions()) {
|
|
assert.equal(action.perform, undefined, `${action.id} leaked perform`)
|
|
assert.equal(action.revert, undefined, `${action.id} leaked revert`)
|
|
assert.equal(action.cost, undefined, `${action.id} leaked cost`)
|
|
}
|
|
// …and the runner's own lookup still has it, which is the half that makes the
|
|
// stripping a boundary rather than a deletion.
|
|
assert.equal(typeof registries.eventAction('core.wait').perform, 'function')
|
|
})
|
|
|
|
test('core.announce refuses an unregistered leg terminally, and never claims success', async () => {
|
|
// Phase 1's version of this test asserted that all three core actions REFUSED,
|
|
// because none of them was wired yet. Phase 2 gave them real bodies, so what
|
|
// survives is the half that was never about the placeholder: `ok: true` on an
|
|
// action that did nothing is a recorded world change that did not occur.
|
|
//
|
|
// `core.announce` is the one that can still legitimately refuse. A leg nobody
|
|
// registers will not appear between two attempts a minute apart, so the answer
|
|
// is terminal rather than transient — a human has to fix it.
|
|
registries.registerCore()
|
|
const answer = await registries.eventAction('core.announce').perform({
|
|
params: { leg: 'nowhere', body: 'hello' },
|
|
})
|
|
assert.equal(answer.ok, false)
|
|
assert.equal(answer.retry, false)
|
|
assert.match(answer.error, /nowhere/)
|
|
})
|
|
|
|
test('a dry run validates and reports, but dispatches nothing', async () => {
|
|
// §I's dry run: `verify === true` means validate and report, change nothing.
|
|
// `core.announce` is the only core action with an outside effect to suppress.
|
|
//
|
|
// **The leg is resolved BEFORE `verify` is honoured, and that ordering is the
|
|
// point rather than an oversight.** A dry run exists to report what would
|
|
// happen, and "this step names a leg nobody registers" is the most useful thing
|
|
// it can find. Answering `ok: true` first would make the dry run pass on
|
|
// exactly the definition that cannot work.
|
|
registries.registerCore()
|
|
const announce = registries.eventAction('core.announce')
|
|
|
|
const bad = await announce.perform({ params: { leg: 'nowhere', body: 'hello' }, verify: true })
|
|
assert.equal(bad.ok, false, 'a dry run must surface a leg that does not exist')
|
|
|
|
// A registered leg: reported good, and its transport never touched.
|
|
const leg = registries.announceLeg('discord')
|
|
const dispatch = leg.dispatch
|
|
let dispatched = 0
|
|
leg.dispatch = async () => {
|
|
dispatched += 1
|
|
return { ok: true }
|
|
}
|
|
try {
|
|
const good = await announce.perform({ params: { leg: 'discord', body: 'hello' }, verify: true })
|
|
assert.equal(good.ok, true)
|
|
assert.equal(dispatched, 0, 'a dry run sends nothing')
|
|
} finally {
|
|
leg.dispatch = dispatch
|
|
}
|
|
})
|
|
|
|
test('core.wait defers the next step rather than sleeping, and core.cue parks', async () => {
|
|
// Both answer through ordinary envelope members, which is what lets the runner
|
|
// honour them without knowing what either action is. A `perform` that slept
|
|
// would hold its claim for the duration and turn a five-minute pause into a
|
|
// five-minute lease.
|
|
registries.registerCore()
|
|
assert.deepEqual(await registries.eventAction('core.wait').perform({ params: { seconds: 300 } }), {
|
|
ok: true,
|
|
holdFor: 300,
|
|
})
|
|
assert.deepEqual(await registries.eventAction('core.cue').perform({ params: {} }), {
|
|
ok: true,
|
|
await: 'human',
|
|
})
|
|
})
|
|
|
|
test('an action must be namespaced to its owner, and the holder is named', () => {
|
|
assert.throws(() => register('demo', [ok({ id: 'other.thing.do' })]), /not namespaced "demo\."/)
|
|
|
|
register('demo', [ok()])
|
|
assert.throws(
|
|
() => register('rival', [ok({ id: 'demo.thing.do' })]),
|
|
/already registered by "demo"/,
|
|
)
|
|
})
|
|
|
|
test('the same id twice in one batch is refused', () => {
|
|
assert.throws(() => register('demo', [ok(), ok()]), /registered twice/)
|
|
})
|
|
|
|
test('risk and reversibility are closed sets with no default', () => {
|
|
assert.throws(() => register('demo', [ok({ risk: undefined })]), /needs a risk class/)
|
|
assert.throws(() => register('demo', [ok({ risk: 'world-write' })]), /needs a risk class/)
|
|
assert.throws(
|
|
() => register('demo', [ok({ reversible: undefined })]),
|
|
/needs a reversible class/,
|
|
)
|
|
assert.throws(() => register('demo', [ok({ reversible: 'maybe' })]), /needs a reversible class/)
|
|
})
|
|
|
|
test("reversible: 'ledger' without revert() is refused at registration", () => {
|
|
assert.throws(
|
|
() => register('demo', [ok({ reversible: 'ledger' })]),
|
|
/is reversible: 'ledger' but has no revert\(\)/,
|
|
)
|
|
// And the mirror: a revert() nothing will ever call is a promise core does not
|
|
// keep, so it is refused just as loudly.
|
|
assert.throws(
|
|
() => register('demo', [ok({ reversible: 'none', revert: async () => ({ ok: true }) })]),
|
|
/declares revert\(\) but is reversible: 'none'/,
|
|
)
|
|
register('demo', [ok({ reversible: 'ledger', revert: async () => ({ ok: true }) })])
|
|
assert.equal(typeof registries.eventAction('demo.thing.do').revert, 'function')
|
|
})
|
|
|
|
test('perform() is required and cost must be a function', () => {
|
|
assert.throws(() => register('demo', [ok({ perform: undefined })]), /has no perform\(\)/)
|
|
assert.throws(() => register('demo', [ok({ cost: { 'demo.things': 1 } })]), /cost must be a function/)
|
|
})
|
|
|
|
test('every param needs a type and an example', () => {
|
|
const withParams = (params) => ok({ params })
|
|
assert.throws(() => register('demo', [withParams([{ name: 'x' }])]), /unsupported type/)
|
|
assert.throws(
|
|
() => register('demo', [withParams([{ name: 'x', type: 'int' }])]),
|
|
/needs an example/,
|
|
)
|
|
assert.throws(
|
|
() => register('demo', [withParams([{ name: '9bad', type: 'int', example: 1 }])]),
|
|
/bad param name/,
|
|
)
|
|
assert.throws(
|
|
() =>
|
|
register('demo', [
|
|
withParams([
|
|
{ name: 'x', type: 'int', example: 1 },
|
|
{ name: 'x', type: 'int', example: 2 },
|
|
]),
|
|
]),
|
|
/declared twice/,
|
|
)
|
|
register('demo', [withParams([{ name: 'x', type: 'int', example: 12, source: 'demo.options.x' }])])
|
|
const [param] = registries.eventAction('demo.thing.do').params
|
|
assert.deepEqual(param, {
|
|
name: 'x',
|
|
type: 'int',
|
|
required: false,
|
|
example: 12,
|
|
source: 'demo.options.x',
|
|
description: '',
|
|
})
|
|
})
|
|
|
|
test('budgetMs defaults, and is bounded', () => {
|
|
register('demo', [ok()])
|
|
assert.equal(registries.eventAction('demo.thing.do').budgetMs, registries.DEFAULT_BUDGET_MS)
|
|
registries._reset()
|
|
assert.throws(() => register('demo', [ok({ budgetMs: 0 })]), /budgetMs must be/)
|
|
assert.throws(() => register('demo', [ok({ budgetMs: 3_600_001 })]), /budgetMs must be/)
|
|
})
|
|
|
|
test('actions and triggers are different namespaces, so one id may be both', () => {
|
|
// The property §F states and the audience registry established first. A verb
|
|
// called `demo.raid.start` and an event called `demo.raid.start` are two
|
|
// unrelated declarations, and forbidding the pair would forbid the most
|
|
// natural names a module will ever want.
|
|
const api = registries.stage('demo')
|
|
api.registerEventTriggers([
|
|
{ id: 'demo.raid.start', label: 'A raid started', ceiling: 'everyone' },
|
|
])
|
|
api.registerEventActions([ok({ id: 'demo.raid.start', label: 'Start a raid' })])
|
|
registries.apply(api.staged)
|
|
|
|
assert.equal(registries.eventTrigger('demo.raid.start').label, 'A raid started')
|
|
assert.equal(registries.eventAction('demo.raid.start').label, 'Start a raid')
|
|
})
|
|
|
|
test('a whole batch is refused or taken, never half', () => {
|
|
assert.throws(
|
|
() => register('demo', [ok(), ok({ id: 'demo.other.do', risk: 'nope' })]),
|
|
/needs a risk class/,
|
|
)
|
|
// The shape check throws at the CALL, before anything is staged, so nothing
|
|
// from the batch is visible.
|
|
assert.equal(registries.eventAction('demo.thing.do'), null)
|
|
})
|
|
|
|
test('_reset() hands the process back', () => {
|
|
registries.registerCore()
|
|
assert.equal(registries.allEventActions().length, 3)
|
|
registries._reset()
|
|
assert.equal(registries.allEventActions().length, 0)
|
|
assert.equal(registries.isEventAction('core.wait'), false)
|
|
})
|
|
|
|
// ── Budgets, leases and option sources (§F, Phase 7) ───────────────────────
|
|
//
|
|
// The three declarations that arrive WITH the module-facing seam. What is worth
|
|
// a test here is the same thing that was worth one for actions: the closed sets
|
|
// are closed, the required members are required, and each id space is its own.
|
|
// The seam being reachable by a module at all is `eventModuleContract.test.js`,
|
|
// against a real loader; these are the shape rules, at the call.
|
|
|
|
const registerBudgets = (owner, entries) => {
|
|
const api = registries.stage(owner)
|
|
api.registerEventBudgets(entries)
|
|
registries.apply(api.staged)
|
|
}
|
|
|
|
const registerLeases = (owner, entries) => {
|
|
const api = registries.stage(owner)
|
|
api.registerEventLeases(entries)
|
|
registries.apply(api.staged)
|
|
}
|
|
|
|
const lease = (over = {}) => ({
|
|
id: 'demo.rate.gain',
|
|
label: 'Gain rate',
|
|
type: 'float',
|
|
min: 0.5,
|
|
max: 5,
|
|
maxDurationMs: 3_600_000,
|
|
read: async () => ({ ok: true, value: 1 }),
|
|
apply: async () => ({ ok: true }),
|
|
restore: async () => ({ ok: true }),
|
|
...over,
|
|
})
|
|
|
|
test('a budget needs a unit, because a number on a cap box is ambiguous without one', () => {
|
|
assert.throws(
|
|
() => registerBudgets('demo', [{ id: 'demo.wisps', label: 'Wisps' }]),
|
|
/has no unit/,
|
|
)
|
|
// Open vocabulary, deliberately: core never interprets a unit, it renders it.
|
|
// Closing the set would make "kilometres" a MODULE_API bump for a noun core
|
|
// does not read.
|
|
registerBudgets('demo', [{ id: 'demo.road', label: 'Road laid', unit: 'kilometres' }])
|
|
assert.equal(registries.eventBudget('demo.road').unit, 'kilometres')
|
|
})
|
|
|
|
test('a budget and an action are different id spaces, so one name may be both', () => {
|
|
// §F says it in one line — an action names a VERB and a budget names a
|
|
// RESOURCE — and this is the pair every module will actually write. Reading a
|
|
// collision here would forbid the most natural set of names there is.
|
|
const api = registries.stage('demo')
|
|
api.registerEventBudgets([{ id: 'demo.creatures', label: 'Creatures', unit: 'count' }])
|
|
api.registerEventActions([ok({ id: 'demo.creatures' })])
|
|
registries.apply(api.staged)
|
|
assert.equal(registries.eventBudget('demo.creatures').label, 'Creatures')
|
|
assert.equal(registries.eventAction('demo.creatures').label, 'Do the thing')
|
|
})
|
|
|
|
test('a budget is claimed once, and the second claim names who holds it', () => {
|
|
registerBudgets('demo', [{ id: 'demo.wisps', label: 'Wisps', unit: 'count' }])
|
|
assert.throws(
|
|
() => registerBudgets('other', [{ id: 'demo.wisps', label: 'Theirs', unit: 'count' }]),
|
|
/already registered by "demo"/,
|
|
)
|
|
})
|
|
|
|
test('a lease declares all three callables, and restore is not optional', () => {
|
|
// `read` could stand in for `restore`, and that is exactly why it may not:
|
|
// `read` answers "what is it now" and `restore` answers "put this back, and
|
|
// tell me if someone else has moved it". The drift check is the one thing a
|
|
// module must not be allowed to skip — a restore that writes blindly silently
|
|
// reverts an operator's manual fix.
|
|
for (const missing of ['read', 'apply', 'restore']) {
|
|
assert.throws(
|
|
() => registerLeases('demo', [lease({ [missing]: undefined })]),
|
|
new RegExp(`has no ${missing}\(\)`),
|
|
)
|
|
}
|
|
})
|
|
|
|
test('a numeric lease is bounded, and an unbounded one is refused', () => {
|
|
// A lease on a rate multiplier with no range is an operator one keystroke away
|
|
// from setting a shard's skill gain to 5000 — and unlike a cap, a bad lease
|
|
// value is in force the moment it is applied.
|
|
assert.throws(() => registerLeases('demo', [lease({ min: undefined })]), /numeric min and max/)
|
|
assert.throws(() => registerLeases('demo', [lease({ min: 9, max: 2 })]), /min 9 above max 2/)
|
|
assert.throws(
|
|
() => registerLeases('demo', [lease({ type: 'int', min: 0.5, max: 5 })]),
|
|
/whole-number min and max/,
|
|
)
|
|
// A bool holds no range and is not asked for one.
|
|
registerLeases('demo', [lease({ id: 'demo.seasonal', type: 'bool', min: undefined, max: undefined })])
|
|
assert.equal(registries.eventLease('demo.seasonal').min, null)
|
|
})
|
|
|
|
test('a lease may not be held longer than core is willing to promise', () => {
|
|
assert.throws(
|
|
() => registerLeases('demo', [lease({ maxDurationMs: registries.MAX_LEASE_MS + 1 })]),
|
|
/maxDurationMs must be 1\.\./,
|
|
)
|
|
assert.throws(() => registerLeases('demo', [lease({ maxDurationMs: 0 })]), /maxDurationMs must be/)
|
|
assert.throws(() => registerLeases('demo', [lease({ maxDurationMs: 1.5 })]), /maxDurationMs must be/)
|
|
})
|
|
|
|
test('an unknown lease type is refused, because core validates operator input against it', () => {
|
|
assert.throws(() => registerLeases('demo', [lease({ type: 'colour' })]), /needs a type, one of/)
|
|
assert.deepEqual(registries.LEASE_TYPES, ['int', 'float', 'bool', 'string'])
|
|
})
|
|
|
|
test('an option source needs a resolver, and core registers one of its own', () => {
|
|
const api = registries.stage('demo')
|
|
assert.throws(
|
|
() => api.registerEventOptionSources([{ id: 'demo.options.hues', label: 'Hues' }]),
|
|
/has no resolve\(\)/,
|
|
)
|
|
|
|
// Core through the same door (Phase 7): `core.announce`'s `leg` param names a
|
|
// source, and the announce legs are already a registry with labels in them.
|
|
registries.registerCore()
|
|
assert.deepEqual(registries.allEventOptionSources().map((s) => s.id), ['core.options.legs'])
|
|
const leg = registries.eventAction('core.announce').params.find((p) => p.name === 'leg')
|
|
assert.equal(leg.source, 'core.options.legs')
|
|
})
|
|
|
|
test('_reset() hands back the three new registries too', () => {
|
|
registries.registerCore()
|
|
registerBudgets('demo', [{ id: 'demo.wisps', label: 'Wisps', unit: 'count' }])
|
|
registerLeases('demo', [lease()])
|
|
assert.equal(registries.allEventBudgets().length, 1)
|
|
registries._reset()
|
|
assert.deepEqual(registries.allEventBudgets(), [])
|
|
assert.deepEqual(registries.allEventLeases(), [])
|
|
assert.deepEqual(registries.allEventOptionSources(), [])
|
|
})
|