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:
@@ -99,7 +99,7 @@ export default function EventActions() {
|
||||
await load()
|
||||
setDrafts((d) => {
|
||||
const next = { ...d }
|
||||
for (const dimension of action.dimensions) delete next[`${action.id}:${dimension}`]
|
||||
for (const d of action.dimensions) delete next[`${action.id}:${d.id}`]
|
||||
return next
|
||||
})
|
||||
setNotice(`Saved ${action.label}.`)
|
||||
@@ -113,7 +113,7 @@ export default function EventActions() {
|
||||
/** The caps this row would save: the drafts on top of what is stored. */
|
||||
const capsOf = (action) => {
|
||||
const out = {}
|
||||
for (const dimension of action.dimensions) {
|
||||
for (const { id: dimension } of action.dimensions) {
|
||||
const draft = drafts[`${action.id}:${dimension}`]
|
||||
const value = draft !== undefined ? draft : action.caps[dimension]
|
||||
if (value === '' || value === undefined || value === null) continue
|
||||
@@ -130,7 +130,7 @@ export default function EventActions() {
|
||||
}
|
||||
|
||||
const dirty = (action) =>
|
||||
action.dimensions.some((d) => drafts[`${action.id}:${d}`] !== undefined)
|
||||
action.dimensions.some((d) => drafts[`${action.id}:${d.id}`] !== undefined)
|
||||
|
||||
if (loading) return <Loading />
|
||||
if (error) return <ErrorState message={error} />
|
||||
@@ -218,20 +218,48 @@ export default function EventActions() {
|
||||
run gets.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'flex-end' }}>
|
||||
{action.dimensions.map((dimension) => (
|
||||
<label key={dimension} className="sans" style={{ fontSize: '0.8rem' }}>
|
||||
<span className="dim" style={{ display: 'block', marginBottom: 2 }}>{dimension}</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
style={{ width: 110 }}
|
||||
value={capValue(action, dimension)}
|
||||
disabled={busy === action.id}
|
||||
onChange={(e) =>
|
||||
setDrafts((d) => ({ ...d, [`${action.id}:${dimension}`]: e.target.value }))
|
||||
}
|
||||
/>
|
||||
{action.dimensions.map((d) => (
|
||||
<label key={d.id} className="sans" style={{ fontSize: '0.8rem' }}>
|
||||
{/*
|
||||
The LABEL, with the unit beside the box — both from the module's
|
||||
`registerEventBudgets` declaration (Phase 7). Before it, this said
|
||||
`uo.creatures` over an unlabelled number, which is ambiguous in exactly
|
||||
the case that matters: 30 of what?
|
||||
*/}
|
||||
<span className="dim" style={{ display: 'block', marginBottom: 2 }}>
|
||||
{d.registered ? d.label : d.id}
|
||||
</span>
|
||||
<span style={{ display: 'flex', alignItems: 'baseline', gap: 6 }}>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
style={{ width: 110 }}
|
||||
value={capValue(action, d.id)}
|
||||
disabled={busy === action.id || !d.registered}
|
||||
onChange={(e) =>
|
||||
setDrafts((s) => ({ ...s, [`${action.id}:${d.id}`]: e.target.value }))
|
||||
}
|
||||
/>
|
||||
{d.registered && d.unit && (
|
||||
<span className="dim" style={{ fontSize: '0.75rem' }}>{d.unit}</span>
|
||||
)}
|
||||
</span>
|
||||
{/*
|
||||
A dimension nobody declares is SHOWN rather than hidden. The action is
|
||||
refused when it is saved into a step and again if it is ever dispatched,
|
||||
so the operator needs to be told which module is incomplete — hiding the
|
||||
row would make a broken module look like a cheap one.
|
||||
*/}
|
||||
{!d.registered && (
|
||||
<span
|
||||
className="sans"
|
||||
style={{ display: 'block', marginTop: 2, fontSize: '0.72rem', color: '#d98b84' }}
|
||||
>
|
||||
No module declares this as a budget, so a step using this action is
|
||||
refused. It cannot be capped until one does.
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user