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:
@@ -486,6 +486,13 @@ export const api = {
|
||||
archiveEvent: (id) => req(`/admin/events/${id}`, { method: 'DELETE' }),
|
||||
listEventVersions: (id) => req(`/admin/events/${id}/versions`),
|
||||
eventCatalog: () => req('/admin/events/catalog'),
|
||||
// Phase 7. The values behind a param's `source` — resolved by the module that
|
||||
// registered the source, on a request of its own rather than inside the
|
||||
// catalog, because a source can be slow or down and must not take the whole
|
||||
// editor with it. A refusal comes back 200 with `ok: false`, so this never
|
||||
// throws for the case the screen is meant to render: the field degrades to
|
||||
// free text with the reason beside it.
|
||||
eventOptions: (sourceId) => req(`/admin/events/catalog/options/${encodeURIComponent(sourceId)}`),
|
||||
// Phase 6. The dry run is admin+editor: it dispatches nothing, and the author
|
||||
// who wrote the definition is who should be able to price it against the caps
|
||||
// before asking an admin to publish it. A report with findings comes back 200
|
||||
|
||||
@@ -11,6 +11,15 @@
|
||||
// that the two files can drift, so a test asserts they agree
|
||||
// (client/test/moduleRegistry.test.js) rather than trusting a bump to remember
|
||||
// both.
|
||||
// 1.10.0 — the event contract opens to modules (EVENTS.md §F, EVENTS_PLAN.md
|
||||
// Phase 7): a module may register event actions, budget dimensions, leases and
|
||||
// param option sources. All four are server-side registrations and nothing on
|
||||
// `window.__rg` changed — but what they produce is met on this half, in the step
|
||||
// editor: an option source is what turns a param from a text box into a dropdown
|
||||
// of real values, and a budget's label and unit are what the switchboard's cap
|
||||
// box says beside its number. This file bumps for the reason at the top: the two
|
||||
// halves state ONE version, and a module declares one `coreApi` range against
|
||||
// both.
|
||||
// 1.9.0 - a module may ship its own message bodies and rules:
|
||||
// `api.registerEngagementSeeds({ templates, ruleGroups })` (ENGAGEMENT.md Phase
|
||||
// 11b, decision 7). Nothing on this half changed - a seed is server-side data
|
||||
@@ -65,4 +74,4 @@
|
||||
// but the two halves state ONE version: a module declares a single coreApi range
|
||||
// and is served one chunk, so a client that claimed 1.0.0 while the server
|
||||
// answered 1.1.0 would be two answers to one question.
|
||||
export const MODULE_API_VERSION = '1.9.0'
|
||||
export const MODULE_API_VERSION = '1.10.0'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { useAuth } from '../../../contexts/AuthContext.jsx'
|
||||
@@ -40,6 +40,70 @@ import {
|
||||
// is required, and what a value looks like. All of that was already in the
|
||||
// catalog; none of it is a second copy of anything.
|
||||
|
||||
/**
|
||||
* The values behind one param's `source` (§F *Param option sources*, Phase 7).
|
||||
*
|
||||
* **A refusal renders as a warning and leaves the field usable**, which is the
|
||||
* contract rather than a nicety: a source is answered by a module that may be
|
||||
* talking to a sidecar, and an authoring form a shard outage can make unusable
|
||||
* would be a worse failure than the typo the dropdown exists to prevent. The
|
||||
* operator very often knows the value they want to type.
|
||||
*
|
||||
* The picker WRITES INTO THE JSON box rather than replacing it, because the box
|
||||
* is still the field until the schema-driven form arrives — so this is the one
|
||||
* affordance that can exist today and be right afterwards: the values come from
|
||||
* the module, and the exact spelling is never typed by hand. When the JSON does
|
||||
* not parse the picker says so rather than silently doing nothing, because
|
||||
* "clicked and nothing happened" is the one behaviour a form must never have.
|
||||
*/
|
||||
function ParamOptions({ entry, label, disabled, onPick }) {
|
||||
if (!entry || entry.state === 'loading') {
|
||||
return <div className="dim" style={{ fontSize: '0.72rem', marginTop: 4 }}>Reading the list…</div>
|
||||
}
|
||||
if (entry.state === 'failed') {
|
||||
return (
|
||||
<div className="sans" style={{ fontSize: '0.72rem', marginTop: 4, color: '#d98b84' }}>
|
||||
{entry.reason} — type the value by hand.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (!entry.options.length) {
|
||||
return (
|
||||
<div className="dim" style={{ fontSize: '0.72rem', marginTop: 4 }}>
|
||||
{label} has nothing to offer right now — type the value by hand.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const grouped = entry.options.some((o) => o.group)
|
||||
const groups = grouped
|
||||
? [...new Set(entry.options.map((o) => o.group || 'Other'))]
|
||||
: []
|
||||
|
||||
return (
|
||||
<select
|
||||
className="input"
|
||||
style={{ fontSize: '0.75rem', marginTop: 4 }}
|
||||
value=""
|
||||
disabled={disabled}
|
||||
onChange={(e) => { if (e.target.value) onPick(e.target.value) }}
|
||||
>
|
||||
<option value="">
|
||||
{disabled ? 'Fix the params JSON to pick a value' : `Pick from ${label}…`}
|
||||
</option>
|
||||
{grouped
|
||||
? groups.map((g) => (
|
||||
<optgroup key={g} label={g}>
|
||||
{entry.options.filter((o) => (o.group || 'Other') === g).map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))
|
||||
: entry.options.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
)
|
||||
}
|
||||
|
||||
const DORMANT_NOTE =
|
||||
'The module that registered this action is not installed. The step is kept exactly as authored — nothing was dropped — but the definition cannot be published until it is resolved.'
|
||||
|
||||
@@ -118,6 +182,94 @@ export default function EventEditor() {
|
||||
const triggers = useMemo(() => catalog?.triggers || [], [catalog])
|
||||
const triggerById = useMemo(() => new Map(triggers.map((t) => [t.id, t])), [triggers])
|
||||
|
||||
// Phase 7. source id -> { state: 'loading' | 'ok' | 'failed', options, reason }.
|
||||
//
|
||||
// Resolved LAZILY, one request per source, and only for the sources the steps
|
||||
// on this page actually name. A definition uses two or three of them; a
|
||||
// deployment with a game module installed may register a dozen, and asking a
|
||||
// shard for eight hundred creature names to draw a form that needs none of
|
||||
// them is a page that opens slowly for no one's benefit.
|
||||
const [sources, setSources] = useState({})
|
||||
|
||||
// **The guard is a ref, and it has to be.** `setSources` QUEUES its updater
|
||||
// rather than running it, so a "have I already asked for this?" check written
|
||||
// inside the updater cannot be read on the next line — it has not run yet. The
|
||||
// first draft did exactly that and the field sat on *Reading the list…* for
|
||||
// ever, having never made the request at all: state is the wrong tool for a
|
||||
// question that must be answered synchronously, at the call.
|
||||
const requested = useRef(new Set())
|
||||
const loadSource = useCallback(async (sourceId) => {
|
||||
if (requested.current.has(sourceId)) return
|
||||
requested.current.add(sourceId)
|
||||
setSources((s) => ({ ...s, [sourceId]: { state: 'loading', options: [] } }))
|
||||
try {
|
||||
const answer = await api.admin.eventOptions(sourceId)
|
||||
setSources((s) => ({
|
||||
...s,
|
||||
[sourceId]: answer?.ok
|
||||
? { state: 'ok', label: answer.label, options: answer.options || [] }
|
||||
: { state: 'failed', options: [], reason: answer?.reason || 'this list could not be read' },
|
||||
}))
|
||||
} catch (err) {
|
||||
// The route answers a refusal with a 200, so reaching here means the
|
||||
// REQUEST failed rather than the source — and the field's behaviour is the
|
||||
// same either way: it degrades to free text and says why.
|
||||
setSources((s) => ({
|
||||
...s,
|
||||
[sourceId]: { state: 'failed', options: [], reason: err.message || 'this list could not be read' },
|
||||
}))
|
||||
}
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Write one param into a step's JSON box, from a picked option.
|
||||
*
|
||||
* Re-serialising the whole object rather than splicing text: the box holds an
|
||||
* object the save path parses, and a string edit that produced valid-looking
|
||||
* JSON with a duplicate key would be a value the editor and the server read
|
||||
* differently. `2` because that is what `blankStep` writes, so picking a value
|
||||
* does not reformat the box under the author's cursor.
|
||||
*/
|
||||
const pickParam = (pi, si, step, name, value) => {
|
||||
let parsed
|
||||
try {
|
||||
parsed = JSON.parse(step.paramsText || '{}')
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return
|
||||
setStep(pi, si, { paramsText: JSON.stringify({ ...parsed, [name]: value }, null, 2) })
|
||||
}
|
||||
|
||||
/** Does this step's JSON box currently hold an object we can write into? */
|
||||
const paramsParse = (step) => {
|
||||
try {
|
||||
const parsed = JSON.parse(step.paramsText || '{}')
|
||||
return Boolean(parsed) && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 7. Every option source the steps on this page name, resolved once.
|
||||
//
|
||||
// An effect rather than a lookup at render time, because resolving one is a
|
||||
// request and a request started during render is a render with a side effect.
|
||||
// `sources` is deliberately NOT a dependency: `loadSource` keeps its own ref of
|
||||
// what it has already asked for, so re-running this on every answer would be a
|
||||
// pass over the same set, changing nothing.
|
||||
useEffect(() => {
|
||||
const wanted = new Set()
|
||||
for (const phase of form?.phases || []) {
|
||||
for (const step of phase.steps || []) {
|
||||
for (const p of actionById.get(step.actionId)?.params || []) {
|
||||
if (p.source) wanted.add(p.source)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const id of wanted) loadSource(id)
|
||||
}, [form, actionById, loadSource])
|
||||
|
||||
const set = (patch) => setForm((f) => ({ ...f, ...patch }))
|
||||
|
||||
const setPhase = (pi, patch) =>
|
||||
@@ -742,6 +894,14 @@ export default function EventEditor() {
|
||||
<td className="adm-td">
|
||||
{p.description}
|
||||
<div className="dim">e.g. <code>{JSON.stringify(p.example)}</code></div>
|
||||
{p.source && (
|
||||
<ParamOptions
|
||||
entry={sources[p.source]}
|
||||
label={sources[p.source]?.label || p.source}
|
||||
disabled={!paramsParse(step)}
|
||||
onPick={(v) => pickParam(pi, si, step, p.name, v)}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user