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
281 lines
12 KiB
JavaScript
281 lines
12 KiB
JavaScript
import { useCallback, useEffect, useState } from 'react'
|
|
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
|
import { api } from '../../../api/client.js'
|
|
|
|
// Admin → Events → Actions — the deployment's switchboard (EVENTS.md §K, Phase 6).
|
|
//
|
|
// **This screen is the whole of the permission model beyond the role.** A module
|
|
// declaring `uo.creature.spawn` is code the operator installed; it is not a
|
|
// permission they granted. Enablement is the grant, and the cap is how much of
|
|
// it — so this is the one screen in the feature where an operator decides what
|
|
// the deployment *can do at all*, rather than what it is going to do tonight.
|
|
//
|
|
// **Nothing above `notify` and `inspect` arrives enabled.** Installing a module
|
|
// must never start doing things, which is the posture a seeded engagement rule
|
|
// already takes by arriving `enabled = 0`. The line falls between `inspect` and
|
|
// `change` (org lead, 2026-09-03): an `inspect` action reads state and writes
|
|
// nothing, so a deployment gains no risk by having it on, and `core.wait` — which
|
|
// is `inspect` — arriving off would break every published event that waits.
|
|
//
|
|
// **A row with no stored setting is not "off".** It is "the default for its risk
|
|
// class", computed on the server by the same function the runner asks. The screen
|
|
// says which it is looking at, because "an admin turned this on" and "this has
|
|
// always been on" are different facts and only one of them is a decision.
|
|
//
|
|
// **Admin only in both directions**, including the read: §K puts the switchboard
|
|
// in the same row as the world-changing actions it governs, and knowing exactly
|
|
// what a deployment permits is not a staff-wide read.
|
|
|
|
const RISK_WORD = {
|
|
notify: 'Tells people something',
|
|
inspect: 'Reads the world',
|
|
change: 'Changes the world',
|
|
irreversible: 'Changes the world irreversibly',
|
|
}
|
|
|
|
const RISK_COLOR = {
|
|
notify: 'var(--muted)',
|
|
inspect: 'var(--muted)',
|
|
change: '#d9c184',
|
|
irreversible: '#d98b84',
|
|
}
|
|
|
|
const REVERSIBLE_WORD = {
|
|
none: 'nothing to undo',
|
|
self: 'undoes itself',
|
|
ledger: 'undone from the ledger at teardown',
|
|
override: 'restores a baseline',
|
|
}
|
|
|
|
export default function EventActions() {
|
|
const [actions, setActions] = useState([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState(null)
|
|
const [busy, setBusy] = useState(null)
|
|
const [problem, setProblem] = useState(null)
|
|
const [notice, setNotice] = useState(null)
|
|
// Cap edits are held here until they are saved, keyed `actionId:dimension`.
|
|
// A cap is a number somebody types digit by digit, and writing on every
|
|
// keystroke would put "3" in the database on the way to "30".
|
|
const [drafts, setDrafts] = useState({})
|
|
|
|
const load = useCallback(async () => {
|
|
const data = await api.admin.eventActions()
|
|
setActions(data.actions || [])
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
let alive = true
|
|
;(async () => {
|
|
setLoading(true)
|
|
try {
|
|
await load()
|
|
if (alive) setError(null)
|
|
} catch (err) {
|
|
if (alive) setError(err.message)
|
|
} finally {
|
|
if (alive) setLoading(false)
|
|
}
|
|
})()
|
|
return () => {
|
|
alive = false
|
|
}
|
|
}, [load])
|
|
|
|
/**
|
|
* Write one action's row.
|
|
*
|
|
* The whole row goes every time — the switch and every cap — because the route
|
|
* takes one action per request and a sparse write would have to decide what an
|
|
* omitted cap means. Here it can only mean one thing, so it is sent.
|
|
*/
|
|
const save = async (action, { enabled = action.enabled, caps } = {}) => {
|
|
setBusy(action.id)
|
|
setProblem(null)
|
|
setNotice(null)
|
|
const nextCaps = caps !== undefined ? caps : capsOf(action)
|
|
try {
|
|
await api.admin.saveEventAction({ actionId: action.id, enabled, caps: nextCaps })
|
|
await load()
|
|
setDrafts((d) => {
|
|
const next = { ...d }
|
|
for (const d of action.dimensions) delete next[`${action.id}:${d.id}`]
|
|
return next
|
|
})
|
|
setNotice(`Saved ${action.label}.`)
|
|
} catch (err) {
|
|
setProblem(err.message)
|
|
} finally {
|
|
setBusy(null)
|
|
}
|
|
}
|
|
|
|
/** The caps this row would save: the drafts on top of what is stored. */
|
|
const capsOf = (action) => {
|
|
const out = {}
|
|
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
|
|
out[dimension] = Number(value)
|
|
}
|
|
return out
|
|
}
|
|
|
|
const capValue = (action, dimension) => {
|
|
const draft = drafts[`${action.id}:${dimension}`]
|
|
if (draft !== undefined) return draft
|
|
const stored = action.caps[dimension]
|
|
return stored === undefined || stored === null ? '' : String(stored)
|
|
}
|
|
|
|
const dirty = (action) =>
|
|
action.dimensions.some((d) => drafts[`${action.id}:${d.id}`] !== undefined)
|
|
|
|
if (loading) return <Loading />
|
|
if (error) return <ErrorState message={error} />
|
|
|
|
return (
|
|
<div>
|
|
<h2 className="sans" style={{ margin: '0 0 4px' }}>Event actions</h2>
|
|
<p className="sans dim" style={{ margin: '0 0 14px', fontSize: '0.85rem', maxWidth: '62ch' }}>
|
|
What this deployment permits an event to do, and how much of it per run. Anything that changes
|
|
the world arrives switched off — installing a module declares a verb, it does not grant
|
|
permission to use it. Caps are copied into a run when the run is created, so moving a switch
|
|
never changes what a run already in flight is allowed.
|
|
</p>
|
|
|
|
{problem && (
|
|
<div className="panel-flat" style={{ padding: 10, marginBottom: 12, borderLeft: '3px solid #d98b84' }}>
|
|
<span className="sans" style={{ fontSize: '0.85rem' }}>{problem}</span>
|
|
</div>
|
|
)}
|
|
{notice && (
|
|
<div className="panel-flat" style={{ padding: 10, marginBottom: 12, borderLeft: '3px solid #8fc79a' }}>
|
|
<span className="sans" style={{ fontSize: '0.85rem' }}>{notice}</span>
|
|
</div>
|
|
)}
|
|
|
|
{actions.length === 0 && (
|
|
<div className="panel-flat" style={{ padding: 14 }}>
|
|
<p className="sans dim" style={{ margin: 0, fontSize: '0.85rem' }}>
|
|
No module registers an event action. Core always declares its own three, so an empty list
|
|
here means the registry did not load.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{actions.map((action) => (
|
|
<div
|
|
key={action.id}
|
|
className="panel-flat"
|
|
style={{
|
|
padding: 14,
|
|
marginBottom: 10,
|
|
borderLeft: `3px solid ${action.enabled ? RISK_COLOR[action.risk] || 'var(--rule)' : 'var(--rule)'}`,
|
|
opacity: action.enabled ? 1 : 0.75,
|
|
}}
|
|
>
|
|
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-start', flexWrap: 'wrap' }}>
|
|
<div style={{ flex: '1 1 320px', minWidth: 0 }}>
|
|
<div style={{ display: 'flex', gap: 8, alignItems: 'baseline', flexWrap: 'wrap' }}>
|
|
<strong className="sans" style={{ fontSize: '0.95rem' }}>{action.label}</strong>
|
|
<code className="dim" style={{ fontSize: '0.78rem' }}>{action.id}</code>
|
|
</div>
|
|
{action.description && (
|
|
<p className="sans dim" style={{ margin: '4px 0 0', fontSize: '0.82rem' }}>{action.description}</p>
|
|
)}
|
|
<p className="sans dim" style={{ margin: '4px 0 0', fontSize: '0.78rem' }}>
|
|
<span style={{ color: RISK_COLOR[action.risk] }}>{RISK_WORD[action.risk] || action.risk}</span>
|
|
{' · '}
|
|
{REVERSIBLE_WORD[action.reversible] || action.reversible}
|
|
{/* Which of the two facts this is. A default is not a decision, and
|
|
an operator auditing their own deployment needs to see the
|
|
difference without reading the risk table in their head. */}
|
|
{' · '}
|
|
{action.configured
|
|
? `set by ${action.updatedBy || 'an administrator'}`
|
|
: 'never configured — showing the default for its risk class'}
|
|
</p>
|
|
</div>
|
|
|
|
<label className="sans" style={{ display: 'flex', gap: 6, alignItems: 'center', fontSize: '0.85rem' }}>
|
|
<input
|
|
type="checkbox"
|
|
checked={action.enabled}
|
|
disabled={busy === action.id}
|
|
onChange={(e) => save(action, { enabled: e.target.checked })}
|
|
/>
|
|
Enabled
|
|
</label>
|
|
</div>
|
|
|
|
{action.dimensions.length > 0 && (
|
|
<div style={{ marginTop: 10, paddingTop: 10, borderTop: '1px solid var(--rule)' }}>
|
|
<p className="sans dim" style={{ margin: '0 0 6px', fontSize: '0.78rem' }}>
|
|
Per-run caps. Blank is uncapped — the run still counts what it spends, nothing bounds
|
|
it. Where another enabled action spends the same thing, the tightest cap is the one a
|
|
run gets.
|
|
</p>
|
|
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'flex-end' }}>
|
|
{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
|
|
type="button"
|
|
className="btn"
|
|
disabled={busy === action.id || !dirty(action)}
|
|
onClick={() => save(action)}
|
|
>
|
|
Save caps
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|