feat(events): open the event contract to modules (Phase 7)
Some checks failed
PR Checks / bot-tests (pull_request) Successful in 29s
PR Checks / client-build (pull_request) Successful in 36s
PR Checks / server-tests (pull_request) Failing after 8m41s

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:
2026-09-03 14:15:04 -05:00
parent 429e657239
commit fd9fb50351
22 changed files with 1825 additions and 117 deletions

View File

@@ -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)
})