feat(events): open the event contract to modules (Phase 7)
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
This commit is contained in:
@@ -247,3 +247,137 @@ test('_reset() hands the process back', () => {
|
||||
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(), [])
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user