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:
@@ -420,9 +420,41 @@ test('the catalog serves the registry, callables stripped, with its vocabularies
|
||||
for (const action of res.body.actions) assert.equal(action.perform, undefined)
|
||||
assert.deepEqual(res.body.risks, ['notify', 'inspect', 'change', 'irreversible'])
|
||||
assert.deepEqual(res.body.onFailure, ['skip', 'pause', 'abort_run'])
|
||||
// Phase 1 is honest about what it does not have: budget dimensions arrive with
|
||||
// the module contract, so the catalog does not pretend to carry any.
|
||||
assert.equal(res.body.budgets, undefined)
|
||||
// The other three registrations of the module contract arrived in Phase 7, and
|
||||
// they are served BESIDE the actions because the editor needs all four to draw
|
||||
// one step. Core declares no budgets and no leases of its own — its three
|
||||
// actions cost nothing and hold nothing — so those are empty here, and that is
|
||||
// the fact worth asserting: present and empty, not absent.
|
||||
assert.deepEqual(res.body.budgets, [])
|
||||
assert.deepEqual(res.body.leases, [])
|
||||
// One option source, and it is core's: `core.announce`'s leg param. It is here
|
||||
// WITHOUT its resolver — the values are a request of their own.
|
||||
assert.deepEqual(
|
||||
res.body.optionSources.map((s) => s.id),
|
||||
['core.options.legs'],
|
||||
)
|
||||
for (const s of res.body.optionSources) assert.equal(s.resolve, undefined)
|
||||
})
|
||||
|
||||
test('an option source resolves its values, and a refusal is a 200 the form can render', async () => {
|
||||
// §F: a source that cannot answer degrades its field to free text with a
|
||||
// visible warning rather than blocking the form, so BOTH answers are 200s and
|
||||
// the difference is `ok`. A 4xx here would make an authoring screen something a
|
||||
// module's outage can take away.
|
||||
const ok = await call(ctrl.options, { params: { sourceId: 'core.options.legs' }, user: ADMIN })
|
||||
assert.equal(ok.statusCode, 200)
|
||||
assert.equal(ok.body.ok, true)
|
||||
// Whatever legs this boot registered, each is a { value, label } pair — core
|
||||
// renders no game word, so a leg's own label is the only text on the option.
|
||||
for (const option of ok.body.options) {
|
||||
assert.equal(typeof option.value, 'string')
|
||||
assert.equal(typeof option.label, 'string')
|
||||
}
|
||||
|
||||
const missing = await call(ctrl.options, { params: { sourceId: 'nobody.at.all' }, user: ADMIN })
|
||||
assert.equal(missing.statusCode, 200)
|
||||
assert.equal(missing.body.ok, false)
|
||||
assert.match(missing.body.reason, /no module registers/)
|
||||
})
|
||||
|
||||
// ── Create, edit, slug ─────────────────────────────────────────────────────
|
||||
@@ -763,6 +795,10 @@ function registerCosting() {
|
||||
// that is not prefixed with the module registering it, which is what keeps an
|
||||
// action's id space its own (§F).
|
||||
const api = registries.stage('test')
|
||||
// The dimension is DECLARED as well as priced (Phase 7): a `cost()` naming a
|
||||
// dimension no module registers is refused at save and at dispatch, so an
|
||||
// action that priced one without declaring it could never be put in a step.
|
||||
api.registerEventBudgets([{ id: 'test.creatures', label: 'Creatures', unit: 'count' }])
|
||||
api.registerEventActions([
|
||||
{
|
||||
id: 'test.spawn',
|
||||
@@ -770,7 +806,7 @@ function registerCosting() {
|
||||
risk: 'change',
|
||||
reversible: 'none',
|
||||
params: [{ name: 'count', type: 'int', required: true, example: 4 }],
|
||||
cost: (p) => ({ 'x.creatures': p.count }),
|
||||
cost: (p) => ({ 'test.creatures': p.count }),
|
||||
perform: async () => ({ ok: true }),
|
||||
},
|
||||
])
|
||||
@@ -862,7 +898,7 @@ test('a cap that is not a whole number of 0 or more is refused', async () => {
|
||||
for (const bad of [-1, 2.5, 'lots']) {
|
||||
const res = await call(ctrl.saveAction, {
|
||||
user: ADMIN,
|
||||
body: { actionId: 'test.spawn', enabled: true, caps: { 'x.creatures': bad } },
|
||||
body: { actionId: 'test.spawn', enabled: true, caps: { 'test.creatures': bad } },
|
||||
})
|
||||
assert.equal(res.statusCode, 400, String(bad))
|
||||
}
|
||||
@@ -875,19 +911,24 @@ test('a cap of zero is legal, and it means zero', async () => {
|
||||
registerCosting()
|
||||
const res = await call(ctrl.saveAction, {
|
||||
user: ADMIN,
|
||||
body: { actionId: 'test.spawn', enabled: true, caps: { 'x.creatures': 0 } },
|
||||
body: { actionId: 'test.spawn', enabled: true, caps: { 'test.creatures': 0 } },
|
||||
})
|
||||
assert.equal(res.statusCode, 200)
|
||||
assert.deepEqual(res.body.action.caps, { 'x.creatures': 0 })
|
||||
assert.deepEqual(res.body.action.caps, { 'test.creatures': 0 })
|
||||
})
|
||||
|
||||
test('the board offers a cap box per dimension, discovered from the declared examples', async () => {
|
||||
// The Phase 6 stand-in for §F's `registerEventBudgets`, which arrives in Phase
|
||||
// 7 — until then a param's required `example` is what tells core the names.
|
||||
test('the board offers a cap box per dimension, named by its own declaration', async () => {
|
||||
// WHICH dimensions an action spends is still discovered by pricing its declared
|
||||
// examples — `cost` is a function of params, so calling it is the only honest
|
||||
// way to ask. What Phase 7 added is what they are CALLED: the label and unit
|
||||
// come from `registerEventBudgets`, because "30" on an unlabelled box is
|
||||
// ambiguous in exactly the case that matters.
|
||||
registerCosting()
|
||||
const res = await call(ctrl.actions, { user: ADMIN })
|
||||
const spawn = res.body.actions.find((a) => a.id === 'test.spawn')
|
||||
assert.deepEqual(spawn.dimensions, ['x.creatures'])
|
||||
assert.deepEqual(spawn.dimensions, [
|
||||
{ id: 'test.creatures', label: 'Creatures', unit: 'count', registered: true },
|
||||
])
|
||||
assert.equal(spawn.enabled, false, 'a change action arrives disabled')
|
||||
assert.equal(spawn.changesWorld, true)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user