feat(events): the authoring UI proper (Phase 13)
Replaces the two raw JSON boxes Phase 3 shipped as explicit placeholders: a
step's params are a form rendered from the action's own declaration, and a
phase's advance condition is the engagement condition builder. Adds the live cap
meter, the searchable option source's first consumer, and a start dialog
carrying the three fields the route has taken since Phase 10.
One route: POST /admin/events/price, admin+editor. A module's cost() runs on the
server and only there, so a meter has nothing to add up until something asks --
and the dry run is the wrong thing to ask on a debounce twice over: it dispatches
every step through the module and a pass against a version is RECORDED, which is
the stamp K's unattended-start gate reads. This dispatches nothing and records
nothing, and takes the spec in the body because the plan being priced is unsaved
between keystrokes.
A form gives way to JSON on the condition builder's own rule: a value the editor
cannot round-trip is SHOWN rather than silently rewritten. Dropping a param the
action does not declare and flattening `A and (B or C)` are the same mistake.
Two defects fixed in already-merged code:
* Creating an event has been impossible since Phase 6. `events/new` was added
beside `events/:id` and binds no param, and React Router ranks a static
segment above a dynamic one whatever the order -- so the editor was handed no
id and fetched /admin/events/undefined. Worse, the failure was invisible:
`!form` is true for every failed load, so the error state sat behind a
spinner that never stopped.
* 12b's searchable sources had no consumer. The server half shipped and the
only UI that reads a source never sent a term, so the 6,707-entry spawner
list was picked from a 2,000-entry truncation with nothing saying so.
Server: 2113 tests, 2024 pass, 0 fail (89 DB-skipped). Client: 380 pass, 0 fail.
routes:manifest and swagger regenerated -- one route added, none moved.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
This commit is contained in:
@@ -8,6 +8,8 @@ import {
|
||||
payloadFromForm,
|
||||
blankPhase,
|
||||
blankAdvance,
|
||||
blankWhere,
|
||||
whereFormFrom,
|
||||
ADVANCE_KINDS,
|
||||
blankStep,
|
||||
describeSchedule,
|
||||
@@ -15,7 +17,16 @@ import {
|
||||
SCHEDULE_KINDS,
|
||||
MONTHLY_NTHS,
|
||||
WEEKDAYS,
|
||||
PARAM_FORM,
|
||||
PARAM_JSON,
|
||||
paramsMode,
|
||||
paramValue,
|
||||
setParam,
|
||||
datetimeInputValue,
|
||||
priceBodyFrom,
|
||||
worthPricing,
|
||||
} from '../../../lib/eventAuthoring.js'
|
||||
import { operatorsForType } from '../../../lib/engagementRules.js'
|
||||
|
||||
// Admin → Events → the definition editor (EVENTS.md §I, Phase 3).
|
||||
//
|
||||
@@ -32,13 +43,21 @@ import {
|
||||
// knowing any of it, and `check:modules` already fails core's build on a UO
|
||||
// identifier.
|
||||
//
|
||||
// **The params box is a raw JSON field and it is captioned as a placeholder**,
|
||||
// because that is what it is: Phase 13 replaces it with the schema-driven form
|
||||
// the condition builder already models. What makes it usable in the meantime is
|
||||
// that a new step arrives PREFILLED from the action's declared examples, and the
|
||||
// declaration is rendered beside the box — every param's name, type, whether it
|
||||
// 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 params are a FORM as of Phase 13**, one control per declared param,
|
||||
// rendered from a schema core does not understand — §I's *"the condition
|
||||
// builder, exactly"*. The JSON box did not go away: it is the escape hatch, and
|
||||
// a step opens in it automatically when the form could not hold what the step
|
||||
// carries. That rule is the condition builder's own, ported rather than
|
||||
// reinvented — dropping a param the action does not declare and flattening
|
||||
// `A and (B or C)` are the same mistake, a save that looks clean and means
|
||||
// something else.
|
||||
//
|
||||
// **The meter beside the timeline is not a lighter dry run.** `POST
|
||||
// /admin/events/price` dispatches nothing, so it knows nothing a module knows —
|
||||
// whether the landmark exists, whether the shard is up. It answers the half core
|
||||
// can answer alone, which is what a plan would SPEND, and it can therefore run on
|
||||
// a debounce while somebody types. The dry run stays the thing that asks the
|
||||
// modules, and the screen labels them apart.
|
||||
|
||||
/**
|
||||
* The values behind one param's `source` (§F *Param option sources*, Phase 7).
|
||||
@@ -88,9 +107,7 @@ function ParamOptions({ entry, label, disabled, onPick }) {
|
||||
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>
|
||||
<option value="">{`Pick from ${label}…`}</option>
|
||||
{grouped
|
||||
? groups.map((g) => (
|
||||
<optgroup key={g} label={g}>
|
||||
@@ -104,6 +121,484 @@ function ParamOptions({ entry, label, disabled, onPick }) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A source too large for a dropdown, as a search box (Phase 13).
|
||||
*
|
||||
* **The first source that needed this made it unavoidable.** Phase 12b's spawner
|
||||
* target is 6,707 spawn points against `MAX_OPTIONS`' bound of 2,000, so a flat
|
||||
* list drops two thirds of the world and says nothing about which two thirds —
|
||||
* an author picking from it would be choosing from a truncation they cannot see.
|
||||
* The sidecar half of that shipped in 12b; this is what asks.
|
||||
*
|
||||
* `searchable` on the answer decides which control is drawn, rather than the
|
||||
* length of the list: inferring it from a truncated answer reads correctly right
|
||||
* up until a small deployment's list happens to fit, at which point the same
|
||||
* source is a dropdown on one shard and a search box on another.
|
||||
*
|
||||
* A term is sent on a debounce and the answer is dropped if it is not the one
|
||||
* for the term still in the box — a slow source answering after a faster one
|
||||
* would otherwise repaint the list under the author's cursor with results for
|
||||
* something they have finished typing.
|
||||
*/
|
||||
function SearchableOptions({ sourceId, label, disabled, onPick }) {
|
||||
const [term, setTerm] = useState('')
|
||||
const [state, setState] = useState({ status: 'idle', options: [] })
|
||||
const latest = useRef('')
|
||||
|
||||
useEffect(() => {
|
||||
const wanted = term.trim()
|
||||
latest.current = wanted
|
||||
if (!wanted) {
|
||||
setState({ status: 'idle', options: [] })
|
||||
return undefined
|
||||
}
|
||||
setState((s) => ({ ...s, status: 'loading' }))
|
||||
const timer = setTimeout(async () => {
|
||||
try {
|
||||
const answer = await api.admin.eventOptions(sourceId, wanted)
|
||||
if (latest.current !== wanted) return
|
||||
setState(
|
||||
answer?.ok
|
||||
? { status: 'ok', options: answer.options || [] }
|
||||
: { status: 'failed', options: [], reason: answer?.reason || 'this list could not be read' },
|
||||
)
|
||||
} catch (err) {
|
||||
if (latest.current !== wanted) return
|
||||
setState({ status: 'failed', options: [], reason: err.message || 'this list could not be read' })
|
||||
}
|
||||
}, 250)
|
||||
return () => clearTimeout(timer)
|
||||
}, [term, sourceId])
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<input
|
||||
className="input"
|
||||
style={{ fontSize: '0.75rem' }}
|
||||
value={term}
|
||||
disabled={disabled}
|
||||
placeholder={`Search ${label}…`}
|
||||
onChange={(e) => setTerm(e.target.value)}
|
||||
/>
|
||||
{state.status === 'loading' && (
|
||||
<div className="dim" style={{ fontSize: '0.72rem', marginTop: 4 }}>Searching…</div>
|
||||
)}
|
||||
{state.status === 'failed' && (
|
||||
<div className="sans" style={{ fontSize: '0.72rem', marginTop: 4, color: '#d98b84' }}>
|
||||
{state.reason} — type the value by hand.
|
||||
</div>
|
||||
)}
|
||||
{state.status === 'ok' && state.options.length === 0 && (
|
||||
<div className="dim" style={{ fontSize: '0.72rem', marginTop: 4 }}>
|
||||
Nothing matches “{term}”.
|
||||
</div>
|
||||
)}
|
||||
{state.status === 'ok' && state.options.length > 0 && (
|
||||
<ul style={{ listStyle: 'none', margin: '4px 0 0', padding: 0, maxHeight: 160, overflowY: 'auto' }}>
|
||||
{state.options.map((o) => (
|
||||
<li key={o.value}>
|
||||
<button
|
||||
type="button"
|
||||
className="pill"
|
||||
style={{ fontSize: '0.72rem', width: '100%', textAlign: 'left', marginBottom: 2 }}
|
||||
onClick={() => { onPick(o.value); setTerm('') }}
|
||||
>
|
||||
{o.label}
|
||||
{o.group && <span className="dim"> · {o.group}</span>}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One declared param, as the control its type implies (Phase 13).
|
||||
*
|
||||
* **Core does not know what any of this means and that is the design.** The
|
||||
* label is the param's own name, the help is its own description, the placeholder
|
||||
* is its own example, and the values behind it come from the module that declared
|
||||
* the source — `check:modules` fails core's build on a UO identifier, so there is
|
||||
* nowhere for a game word to be written here even by accident.
|
||||
*
|
||||
* Two of the controls are worth their own sentence:
|
||||
*
|
||||
* • **A boolean is a three-value select, not a checkbox.** A checkbox cannot say
|
||||
* *"not set"*, and for an OPTIONAL boolean that is a real third state — the
|
||||
* action's own default. A checkbox would post `false` for every param an author
|
||||
* never touched.
|
||||
* • **A source-backed param keeps its free-text field.** The dropdown writes
|
||||
* into it; it does not replace it. §F: a source is answered by a module that may
|
||||
* be talking to a sidecar, and an authoring form a shard outage can make
|
||||
* unusable is a worse failure than the typo the dropdown exists to prevent.
|
||||
*/
|
||||
function ParamField({ param, value, sourceEntry, disabled, onChange }) {
|
||||
const common = { className: 'input', disabled, style: { fontSize: '0.8rem' } }
|
||||
const asText = value === undefined || value === null ? '' : String(value)
|
||||
|
||||
let control
|
||||
if (param.type === 'boolean') {
|
||||
control = (
|
||||
<select
|
||||
{...common}
|
||||
value={value === true ? 'true' : value === false ? 'false' : ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
>
|
||||
<option value="">Not set{param.required ? '' : ' — the action decides'}</option>
|
||||
<option value="true">Yes</option>
|
||||
<option value="false">No</option>
|
||||
</select>
|
||||
)
|
||||
} else if (param.type === 'datetime') {
|
||||
control = (
|
||||
<input
|
||||
{...common}
|
||||
type="datetime-local"
|
||||
value={datetimeInputValue(asText)}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
)
|
||||
} else if (param.type === 'int' || param.type === 'float') {
|
||||
control = (
|
||||
<input
|
||||
{...common}
|
||||
type="number"
|
||||
step={param.type === 'int' ? '1' : 'any'}
|
||||
value={asText}
|
||||
placeholder={param.example === undefined ? '' : String(param.example)}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
)
|
||||
} else if (param.type === 'url') {
|
||||
control = (
|
||||
<input {...common} type="url" value={asText}
|
||||
placeholder={param.example === undefined ? '' : String(param.example)}
|
||||
onChange={(e) => onChange(e.target.value)} />
|
||||
)
|
||||
} else {
|
||||
control = (
|
||||
<input {...common} value={asText}
|
||||
placeholder={param.example === undefined ? '' : String(param.example)}
|
||||
onChange={(e) => onChange(e.target.value)} />
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<label style={{ display: 'block', marginBottom: 12 }}>
|
||||
<span className="field-label">
|
||||
{param.name}
|
||||
{param.required && <span style={{ color: '#d98b84' }}> *</span>}
|
||||
<span className="dim" style={{ fontWeight: 'normal' }}> · {param.type}</span>
|
||||
</span>
|
||||
{control}
|
||||
{param.description && (
|
||||
<span className="sans dim" style={{ fontSize: '0.74rem' }}>{param.description}</span>
|
||||
)}
|
||||
{param.source && (
|
||||
sourceEntry?.searchable
|
||||
? (
|
||||
<SearchableOptions
|
||||
sourceId={param.source}
|
||||
label={sourceEntry.label || param.source}
|
||||
disabled={disabled}
|
||||
onPick={onChange}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<ParamOptions
|
||||
entry={sourceEntry}
|
||||
label={sourceEntry?.label || param.source}
|
||||
disabled={disabled}
|
||||
onPick={onChange}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The advance condition, as a builder rather than as JSON (Phase 13).
|
||||
*
|
||||
* **This is the engagement condition builder**, and being the same one is the
|
||||
* point rather than a saving: the grammar is `engagement/conditions.js`, the
|
||||
* server validates a phase gate with it, and the run console's diagnosis panel
|
||||
* renders its sentence from the same labels. A second editor here would be a
|
||||
* second opinion about a grammar core owns — exactly what §I refuses on the read
|
||||
* side, where the sentence is rendered on the server for the same reason.
|
||||
*
|
||||
* It offers the FLAT half of the grammar — one `and`/`or` over a list of
|
||||
* comparisons — because that is what a dropdown per operator can render
|
||||
* honestly. A tree it cannot hold opens READ-ONLY with its JSON showing and one
|
||||
* choice: leave it, or clear it and start again. Flattening `A and (B or C)`
|
||||
* into `A and B and C` changes which firings release the phase, and an author
|
||||
* would have no way to know the save had done it.
|
||||
*/
|
||||
function WhereBuilder({ advance, trigger, operators, disabled, onChange }) {
|
||||
const variables = trigger?.variables || []
|
||||
const rows = advance.whereRows || []
|
||||
|
||||
if (advance.whereEditable === false) {
|
||||
return (
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<span className="field-label">Only when</span>
|
||||
<pre className="dim" style={{ fontSize: '0.76rem', margin: 0, whiteSpace: 'pre-wrap' }}>
|
||||
{advance.whereText}
|
||||
</pre>
|
||||
<p className="sans" style={{ fontSize: '0.74rem', color: '#d9c184', margin: '6px 0 0' }}>
|
||||
This condition nests, and the builder only holds one <code>and</code>/<code>or</code> over a
|
||||
flat list. It is kept exactly as authored and posted back unchanged — flattening it would
|
||||
change which firings release the phase without saying so.
|
||||
</p>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.72rem', marginTop: 6 }} disabled={disabled}
|
||||
onClick={() => onChange(blankWhere())}>
|
||||
Clear it and start again
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const setRow = (i, patch) =>
|
||||
onChange({ whereRows: rows.map((r, j) => (j === i ? { ...r, ...patch } : r)) })
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<span className="field-label">Only when</span>
|
||||
{rows.length === 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.76rem', margin: '4px 0 0' }}>
|
||||
Every firing of this trigger counts. Add a clause to narrow it — the phase then waits for
|
||||
firings that match.
|
||||
</p>
|
||||
)}
|
||||
{rows.length > 1 && (
|
||||
<label style={{ display: 'block', margin: '6px 0' }}>
|
||||
<span className="field-label">Match</span>
|
||||
<select className="input" style={{ maxWidth: 220, fontSize: '0.76rem' }} value={advance.whereOp}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange({ whereOp: e.target.value })}>
|
||||
<option value="and">all of these</option>
|
||||
<option value="or">any of these</option>
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{rows.map((row, i) => {
|
||||
const declared = variables.find((v) => v.name === row.variable)
|
||||
const usable = operatorsForType(operators, declared?.type)
|
||||
const valueless = row.cmp === 'present' || row.cmp === 'absent'
|
||||
return (
|
||||
<div key={i} style={{ display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap', marginTop: 6 }}>
|
||||
<select className="input" style={{ flex: '1 1 150px', fontSize: '0.76rem' }} value={row.variable}
|
||||
disabled={disabled}
|
||||
onChange={(e) => setRow(i, { variable: e.target.value, cmp: '' })}>
|
||||
<option value="">Which variable…</option>
|
||||
{variables.map((v) => <option key={v.name} value={v.name}>{v.name} ({v.type})</option>)}
|
||||
</select>
|
||||
<select className="input" style={{ flex: '0 1 130px', fontSize: '0.76rem' }} value={row.cmp}
|
||||
disabled={disabled || !row.variable}
|
||||
onChange={(e) => setRow(i, { cmp: e.target.value })}>
|
||||
<option value="">is…</option>
|
||||
{usable.map((o) => <option key={o.cmp} value={o.cmp}>{o.label}</option>)}
|
||||
</select>
|
||||
{!valueless && (
|
||||
<input className="input" style={{ flex: '1 1 140px', fontSize: '0.76rem' }} value={row.value ?? ''}
|
||||
disabled={disabled || !row.cmp}
|
||||
placeholder={row.cmp === 'in' || row.cmp === 'nin' ? 'Yew, Britain, Vesper' : ''}
|
||||
onChange={(e) => setRow(i, { value: e.target.value })} />
|
||||
)}
|
||||
<button type="button" className="pill" style={{ fontSize: '0.72rem' }} disabled={disabled}
|
||||
onClick={() => onChange({ whereRows: rows.filter((_, j) => j !== i) })}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<button type="button" className="pill" style={{ fontSize: '0.72rem', marginTop: 8 }}
|
||||
disabled={disabled || !variables.length}
|
||||
onClick={() => onChange({ whereRows: [...rows, { variable: '', cmp: '', value: '' }] })}>
|
||||
Add a clause
|
||||
</button>
|
||||
{!variables.length && advance.on && (
|
||||
<span className="sans dim" style={{ fontSize: '0.74rem', marginLeft: 8 }}>
|
||||
This trigger declares no variables, so there is nothing to narrow on.
|
||||
</span>
|
||||
)}
|
||||
<p className="sans dim" style={{ fontSize: '0.74rem', margin: '8px 0 0' }}>
|
||||
A list operator takes comma-separated values. Every literal is read as the type the trigger
|
||||
declared, and a variable it does not have comes back named from the save.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The live cap meter (§I, Phase 13).
|
||||
*
|
||||
* Two facts per dimension — what this plan draws, and what the deployment allows
|
||||
* — because that is all a cap is. §I says the same thing about the run console's
|
||||
* meter: *"a meter per dimension rather than a sentence, because unlike a gate a
|
||||
* cap is two numbers and a name and needs no grammar rendered to be read."*
|
||||
*
|
||||
* **It says what it does not know.** A step core could not price makes every
|
||||
* total below an under-count, and an author reading a number smaller than what
|
||||
* will happen is worse off than one reading no number at all. So `unpriced` is
|
||||
* rendered as prominently as the totals, not tucked underneath them.
|
||||
*
|
||||
* And it does not claim to be the dry run: the caption says so, because a green
|
||||
* meter beside a plan whose landmarks do not exist would otherwise read as a
|
||||
* pass.
|
||||
*/
|
||||
function CapMeter({ report, budgets, stale }) {
|
||||
const labelOf = (id) => budgets.find((b) => b.id === id)?.label || id
|
||||
const unitOf = (id) => budgets.find((b) => b.id === id)?.unit || ''
|
||||
if (!report) return null
|
||||
const nothing = report.cost.length === 0 && report.unpriced.length === 0
|
||||
return (
|
||||
<div className="panel-flat" style={{ padding: '10px 14px', marginBottom: 12, opacity: stale ? 0.55 : 1 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 10 }}>
|
||||
<h3 className="sans" style={{ margin: 0, fontSize: '0.88rem' }}>
|
||||
What this plan draws
|
||||
{stale && <span className="dim" style={{ fontWeight: 'normal' }}> · recalculating…</span>}
|
||||
</h3>
|
||||
<span className="sans dim" style={{ fontSize: '0.74rem' }}>
|
||||
{report.steps} step{report.steps === 1 ? '' : 's'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{nothing && (
|
||||
<p className="sans dim" style={{ margin: '6px 0 0', fontSize: '0.8rem' }}>
|
||||
Nothing in this plan spends a capped resource.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{report.cost.length > 0 && (
|
||||
<table className="sans" style={{ fontSize: '0.8rem', borderCollapse: 'collapse', marginTop: 6 }}>
|
||||
<tbody>
|
||||
{report.cost.map((c) => (
|
||||
<tr key={c.dimension} style={{ color: c.over ? '#d98b84' : undefined }}>
|
||||
<td style={{ paddingRight: 12 }}>{labelOf(c.dimension)}</td>
|
||||
<td style={{ paddingRight: 12 }}>{c.total} {unitOf(c.dimension)}</td>
|
||||
<td className="dim">
|
||||
{c.cap === null ? 'no cap' : `of ${c.cap} per run${c.from ? ` (${c.from})` : ''}`}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{report.unpriced.length > 0 && (
|
||||
<div style={{ marginTop: 8, borderLeft: '3px solid #d9c184', paddingLeft: 10 }}>
|
||||
<p className="sans" style={{ margin: 0, fontSize: '0.78rem' }}>
|
||||
<strong>These totals are incomplete.</strong>
|
||||
</p>
|
||||
<ul className="sans" style={{ margin: '4px 0 0', paddingLeft: 18, fontSize: '0.78rem' }}>
|
||||
{report.unpriced.map((u, i) => (
|
||||
<li key={`${u.phase}-${u.seq}-${u.code}-${i}`}>
|
||||
<code className="dim" style={{ fontSize: '0.76rem' }}>phase {u.phase + 1} · step {u.seq + 1}</code>
|
||||
{' — '}{u.message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="sans dim" style={{ margin: '8px 0 0', fontSize: '0.74rem' }}>
|
||||
Arithmetic only — nothing was dispatched, so this does not know whether the places and things
|
||||
these steps name exist. The dry run asks the modules that do.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Starting a run, with the three things the route has always taken (Phase 13).
|
||||
*
|
||||
* **Two of them were unreachable from this screen until now**, and one of those
|
||||
* is not a nicety: `concurrencyKey` is a `{placeholder}` template rendered from
|
||||
* the RUN's own params, so an event whose key names one could not be started
|
||||
* correctly from the UI at all — every manual run rendered the same key and the
|
||||
* second one was refused as an overlap.
|
||||
*
|
||||
* **Rehearsal is a real run.** §I: the world changes are real, the announcements
|
||||
* are ceilinged to `staff`. It is not a dry run and the dialog says so, because
|
||||
* the two words are near enough to swap in a hurry.
|
||||
*/
|
||||
function StartDialog({ onStart, onCancel, busy }) {
|
||||
const [rehearsal, setRehearsal] = useState(false)
|
||||
const [scope, setScope] = useState('')
|
||||
const [paramsText, setParamsText] = useState('{}')
|
||||
const [problem, setProblem] = useState(null)
|
||||
|
||||
const go = () => {
|
||||
let params = null
|
||||
const text = paramsText.trim()
|
||||
if (text && text !== '{}') {
|
||||
try {
|
||||
params = JSON.parse(text)
|
||||
} catch (err) {
|
||||
setProblem(`The params are not valid JSON (${err.message})`)
|
||||
return
|
||||
}
|
||||
if (!params || typeof params !== 'object' || Array.isArray(params)) {
|
||||
setProblem('The params must be a JSON object')
|
||||
return
|
||||
}
|
||||
}
|
||||
onStart({ rehearsal, scope: scope || undefined, ...(params ? { params } : {}) })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="panel-flat" style={{ padding: 14, marginBottom: 14, borderLeft: '3px solid var(--accent, #6d7f9c)' }}>
|
||||
<h3 className="sans" style={{ margin: '0 0 10px', fontSize: '0.92rem' }}>Start a run now</h3>
|
||||
|
||||
<label style={{ display: 'flex', gap: 8, alignItems: 'flex-start', marginBottom: 10 }}>
|
||||
<input type="checkbox" checked={rehearsal} onChange={(e) => setRehearsal(e.target.checked)} />
|
||||
<span className="sans" style={{ fontSize: '0.84rem' }}>
|
||||
<strong>Rehearsal.</strong>{' '}
|
||||
<span className="dim">
|
||||
A real run — every world change actually happens — with its announcements ceilinged to
|
||||
staff, so no player is told. This is not the dry run.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit,minmax(220px,1fr))', gap: 12 }}>
|
||||
<label>
|
||||
<span className="field-label">Scope (optional)</span>
|
||||
<input className="input" value={scope} onChange={(e) => setScope(e.target.value)} />
|
||||
<span className="sans dim" style={{ fontSize: '0.74rem' }}>
|
||||
Passed to the module verbatim. Core never parses it.
|
||||
</span>
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Run params (optional)</span>
|
||||
<textarea className="input" rows={3} spellCheck={false}
|
||||
style={{ fontFamily: 'var(--mono, monospace)', fontSize: '0.78rem' }}
|
||||
value={paramsText} onChange={(e) => setParamsText(e.target.value)} />
|
||||
<span className="sans dim" style={{ fontSize: '0.74rem' }}>
|
||||
What the concurrency key’s <code>{'{placeholders}'}</code> are filled from.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{problem && (
|
||||
<p className="sans" style={{ fontSize: '0.8rem', color: '#d98b84', margin: '10px 0 0' }}>{problem}</p>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy} onClick={go}>
|
||||
Start it
|
||||
</button>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy} onClick={onCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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.'
|
||||
|
||||
@@ -127,6 +622,14 @@ export default function EventEditor() {
|
||||
// because a report is a statement about a spec and both of those change it —
|
||||
// a stale green report beside an edited plan is worse than no report.
|
||||
const [report, setReport] = useState(null)
|
||||
// Phase 13. The meter's answer, and whether the plan has moved since it was
|
||||
// asked — `stale` is what stops the number reading as current while a request
|
||||
// is in flight, which on a debounce it very often is not.
|
||||
const [priced, setPriced] = useState(null)
|
||||
const [priceStale, setPriceStale] = useState(false)
|
||||
// The start dialog is open. Not a modal: this screen is long, and a dialog
|
||||
// that covers the plan hides the thing being started.
|
||||
const [starting, setStarting] = useState(false)
|
||||
|
||||
const isAdmin = user?.role === 'admin'
|
||||
// Reads here are staff-wide (§K), so a moderator reaches this screen legitimately
|
||||
@@ -207,7 +710,16 @@ export default function EventEditor() {
|
||||
setSources((s) => ({
|
||||
...s,
|
||||
[sourceId]: answer?.ok
|
||||
? { state: 'ok', label: answer.label, options: answer.options || [] }
|
||||
// `searchable` is the SOURCE's answer about itself (Phase 12b), not a
|
||||
// guess from how long the list came back. A source bounded at 2,000
|
||||
// that happens to have 40 entries on this deployment is still the one
|
||||
// that has to be searched on the shard where it has 6,707.
|
||||
? {
|
||||
state: 'ok',
|
||||
label: answer.label,
|
||||
searchable: Boolean(answer.searchable),
|
||||
options: answer.options || [],
|
||||
}
|
||||
: { state: 'failed', options: [], reason: answer?.reason || 'this list could not be read' },
|
||||
}))
|
||||
} catch (err) {
|
||||
@@ -221,36 +733,6 @@ export default function EventEditor() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -270,6 +752,52 @@ export default function EventEditor() {
|
||||
for (const id of wanted) loadSource(id)
|
||||
}, [form, actionById, loadSource])
|
||||
|
||||
/**
|
||||
* The live cap meter (§I, Phase 13).
|
||||
*
|
||||
* **A debounce and a generation counter, and the counter is not optional.**
|
||||
* Requests started 400ms apart do not necessarily answer in that order, and an
|
||||
* older answer landing last would leave the meter showing the cost of a plan
|
||||
* the author has already changed — stale in the one direction that matters,
|
||||
* silently, with nothing on the screen to say so.
|
||||
*
|
||||
* A failure leaves the LAST answer standing rather than blanking the meter or
|
||||
* raising an error box: this is an aid to authoring, and the plan is still
|
||||
* saveable, dry-runnable and publishable without it. The staleness marker is
|
||||
* how the screen stays honest about it.
|
||||
*/
|
||||
const priceGeneration = useRef(0)
|
||||
useEffect(() => {
|
||||
if (!form || !worthPricing(form)) {
|
||||
setPriced(null)
|
||||
setPriceStale(false)
|
||||
return undefined
|
||||
}
|
||||
setPriceStale(true)
|
||||
const generation = ++priceGeneration.current
|
||||
const timer = setTimeout(async () => {
|
||||
try {
|
||||
const answer = await api.admin.priceEvent(priceBodyFrom(form))
|
||||
if (priceGeneration.current !== generation) return
|
||||
setPriced(answer)
|
||||
setPriceStale(false)
|
||||
} catch {
|
||||
if (priceGeneration.current !== generation) return
|
||||
// Left stale on purpose: a number the screen cannot vouch for is shown
|
||||
// dimmed rather than replaced by nothing.
|
||||
setPriceStale(true)
|
||||
}
|
||||
}, 400)
|
||||
return () => clearTimeout(timer)
|
||||
}, [form])
|
||||
|
||||
/** The meter's per-phase rollup, by ordinal, for the timeline. */
|
||||
const drawByPhase = useMemo(
|
||||
() => new Map((priced?.phases || []).map((p) => [p.phase, p.draw])),
|
||||
[priced],
|
||||
)
|
||||
const budgets = useMemo(() => catalog?.budgets || [], [catalog])
|
||||
|
||||
const set = (patch) => setForm((f) => ({ ...f, ...patch }))
|
||||
|
||||
const setPhase = (pi, patch) =>
|
||||
@@ -330,7 +858,10 @@ export default function EventEditor() {
|
||||
// A report describes a spec, and saving changes it. A green report left
|
||||
// standing beside an edited plan is worse than no report at all.
|
||||
setReport(null)
|
||||
const built = payloadFromForm(form)
|
||||
// The declared variable types, so the builder's literals are coerced to
|
||||
// them before the request rather than compared as strings by a server that
|
||||
// will rightly refuse them.
|
||||
const built = payloadFromForm(form, { triggersById: triggerById })
|
||||
if (!built.ok) {
|
||||
setProblems(built.errors)
|
||||
setBusy(false)
|
||||
@@ -406,21 +937,34 @@ export default function EventEditor() {
|
||||
}
|
||||
}
|
||||
|
||||
const start = async () => {
|
||||
/**
|
||||
* Start a run, with what the dialog collected (Phase 13).
|
||||
*
|
||||
* The route has taken `rehearsal`, `scope` and `params` since Phase 10 and this
|
||||
* screen sent `{}` — so rehearsal, the affordance §I asks for by name, was
|
||||
* unreachable, and an event whose concurrency key names a `{placeholder}` could
|
||||
* not be started correctly at all: every manual run rendered the same key and
|
||||
* the second was refused as an overlap with the first.
|
||||
*/
|
||||
const start = async (body) => {
|
||||
setBusy(true)
|
||||
setProblems([])
|
||||
try {
|
||||
const result = await api.admin.startEventRun(id, {})
|
||||
const result = await api.admin.startEventRun(id, body)
|
||||
navigate(`/admin/events/runs/${result.run.id}`)
|
||||
} catch (err) {
|
||||
setProblems(err.body?.errors || [err.message])
|
||||
setStarting(false)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading || !form) return <Loading />
|
||||
// **The error is checked BEFORE the form.** `!form` is true for every failed
|
||||
// load, so testing it first put the error state permanently behind a spinner:
|
||||
// the screen that could not load said "Loading…" for ever and named nothing.
|
||||
if (error) return <ErrorState message={error} />
|
||||
if (loading || !form) return <Loading />
|
||||
|
||||
const archived = event?.state === 'archived'
|
||||
|
||||
@@ -465,7 +1009,8 @@ export default function EventEditor() {
|
||||
</button>
|
||||
)}
|
||||
{!isNew && isAdmin && event?.state === 'ready' && (
|
||||
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy} onClick={start}>
|
||||
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy}
|
||||
onClick={() => setStarting((s) => !s)}>
|
||||
Start now
|
||||
</button>
|
||||
)}
|
||||
@@ -567,6 +1112,10 @@ export default function EventEditor() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{starting && (
|
||||
<StartDialog busy={busy} onCancel={() => setStarting(false)} onStart={start} />
|
||||
)}
|
||||
|
||||
{notice && <p className="sans" style={{ fontSize: '0.84rem', color: '#8fc79a' }}>{notice}</p>}
|
||||
|
||||
{problems.length > 0 && (
|
||||
@@ -715,6 +1264,12 @@ export default function EventEditor() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ── The live cap meter (Phase 13) ──
|
||||
Above the timeline rather than under it, because what a plan draws is a
|
||||
fact about the whole plan and an author scrolling twelve steps to find
|
||||
out they are over is an author who finds out too late. */}
|
||||
<CapMeter report={priced} budgets={budgets} stale={priceStale} />
|
||||
|
||||
{/* ── The phase timeline ── */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<h3 className="sans" style={{ margin: 0, fontSize: '0.95rem' }}>Phases</h3>
|
||||
@@ -754,6 +1309,20 @@ export default function EventEditor() {
|
||||
cannot change once runs exist.
|
||||
</p>
|
||||
|
||||
{/* §I asks the timeline for "phases in order, each with its steps, its
|
||||
advance condition, its cap draw and its failure policy". This is
|
||||
the cap draw, and it is the phase's own rather than a share of the
|
||||
total: an author moving a step between phases is asking exactly
|
||||
this question. */}
|
||||
{(drawByPhase.get(pi) || []).length > 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.76rem', margin: '4px 0 0' }}>
|
||||
Draws{' '}
|
||||
{(drawByPhase.get(pi) || [])
|
||||
.map((d) => `${d.total} ${budgets.find((b) => b.id === d.dimension)?.label || d.dimension}`)
|
||||
.join(' · ')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* ── The advance condition (Phase 5) ──
|
||||
A gate is an ADDITIONAL condition and never a replacement, which is
|
||||
what the caption has to say: a phase whose steps are still running
|
||||
@@ -779,8 +1348,16 @@ export default function EventEditor() {
|
||||
<>
|
||||
<label style={{ flex: '1 1 240px' }}>
|
||||
<span className="field-label">Trigger</span>
|
||||
{/* Changing the trigger CLEARS the predicate, for the same
|
||||
reason changing a step's action replaces its params:
|
||||
every clause names a variable of the old trigger, and the
|
||||
save would refuse each of them by name. Keeping them
|
||||
would look like tolerance and behave like a form that
|
||||
cannot be saved. */}
|
||||
<select className="input" value={phase.advance.on}
|
||||
onChange={(e) => setPhase(pi, { advance: { ...phase.advance, on: e.target.value } })}>
|
||||
onChange={(e) => setPhase(pi, {
|
||||
advance: { ...phase.advance, on: e.target.value, ...blankWhere() },
|
||||
})}>
|
||||
<option value="">Choose a trigger…</option>
|
||||
{triggers.map((t) => <option key={t.id} value={t.id}>{t.label} — {t.id}</option>)}
|
||||
</select>
|
||||
@@ -795,24 +1372,13 @@ export default function EventEditor() {
|
||||
</div>
|
||||
|
||||
{phase.advance?.kind === 'on' && (
|
||||
<label style={{ display: 'block', marginTop: 10 }}>
|
||||
<span className="field-label">Only when (JSON, optional)</span>
|
||||
<textarea className="input" rows={3} spellCheck={false} value={phase.advance.whereText}
|
||||
onChange={(e) => setPhase(pi, { advance: { ...phase.advance, whereText: e.target.value } })}
|
||||
placeholder={'{ "variable": "region", "cmp": "eq", "value": "Yew" }'} />
|
||||
<span className="sans dim" style={{ fontSize: '0.74rem' }}>
|
||||
A raw JSON field, and a placeholder for the same reason the params box is one — the
|
||||
condition builder proper is a later phase. It is checked at save against what the
|
||||
trigger declares, and a variable the trigger does not have comes back named.
|
||||
{triggerById.get(phase.advance.on)?.variables?.length > 0 && (
|
||||
<>
|
||||
{' '}
|
||||
<code>{phase.advance.on}</code> declares{' '}
|
||||
{triggerById.get(phase.advance.on).variables.map((v) => `${v.name} (${v.type})`).join(', ')}.
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
<WhereBuilder
|
||||
advance={phase.advance}
|
||||
trigger={triggerById.get(phase.advance.on)}
|
||||
operators={catalog?.operators || []}
|
||||
disabled={archived}
|
||||
onChange={(patch) => setPhase(pi, { advance: { ...phase.advance, ...patch } })}
|
||||
/>
|
||||
)}
|
||||
|
||||
<p className="sans dim" style={{ fontSize: '0.76rem', margin: '8px 0 0' }}>
|
||||
@@ -825,6 +1391,9 @@ export default function EventEditor() {
|
||||
<div style={{ marginTop: 12 }}>
|
||||
{phase.steps.map((step, si) => {
|
||||
const action = actionById.get(step.actionId)
|
||||
// Phase 13. Which editor this step gets, and — when it is not the
|
||||
// author's choice — why.
|
||||
const mode = paramsMode(step, action)
|
||||
return (
|
||||
<div key={si} style={{ borderTop: '1px solid var(--rule, #2a2f3a)', paddingTop: 12, marginTop: 12 }}>
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
||||
@@ -867,56 +1436,71 @@ export default function EventEditor() {
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0,1fr) minmax(0,1fr)', gap: 12, marginTop: 10 }}>
|
||||
<label>
|
||||
<span className="field-label">Params (JSON)</span>
|
||||
<textarea className="input" rows={Math.max(4, (step.paramsText || '').split('\n').length)}
|
||||
style={{ fontFamily: 'var(--mono, monospace)', fontSize: '0.8rem' }}
|
||||
value={step.paramsText} onChange={(e) => setStep(pi, si, { paramsText: e.target.value })} />
|
||||
<span className="sans dim" style={{ fontSize: '0.74rem' }}>
|
||||
A raw JSON field, and a placeholder: the form that renders each param from its
|
||||
declared type arrives with the authoring pass. What is saved is checked
|
||||
against the declaration either way.
|
||||
</span>
|
||||
</label>
|
||||
<div>
|
||||
<span className="field-label">What this action takes</span>
|
||||
{action ? (
|
||||
<table className="adm-table" style={{ fontSize: '0.78rem' }}>
|
||||
<tbody>
|
||||
{(action.params || []).map((p) => (
|
||||
<tr key={p.name}>
|
||||
<td className="adm-td" style={{ whiteSpace: 'nowrap' }}>
|
||||
<code>{p.name}</code>
|
||||
{p.required && <span style={{ color: '#d98b84' }}> *</span>}
|
||||
</td>
|
||||
<td className="adm-td dim">{p.type}</td>
|
||||
<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>
|
||||
))}
|
||||
{(action.params || []).length === 0 && (
|
||||
<tr><td className="adm-td dim">This action takes no params.</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<p className="sans dim" style={{ fontSize: '0.78rem' }}>
|
||||
Pick an action and its parameters are listed here, straight from what the
|
||||
module declared.
|
||||
</p>
|
||||
{/* ── The params (Phase 13) ──
|
||||
A form of the action's own declaration, with the JSON box
|
||||
kept as the escape hatch. A step the form cannot hold
|
||||
without losing something opens in JSON and says why — the
|
||||
condition builder's rule, and the same one, because
|
||||
dropping an undeclared param and flattening a nested
|
||||
condition are the same failure: a save that looks clean
|
||||
and means something else. */}
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 10 }}>
|
||||
<span className="field-label" style={{ marginBottom: 0 }}>Params</span>
|
||||
{!mode.forced && action && (action.params || []).length > 0 && (
|
||||
<button type="button" className="pill" style={{ fontSize: '0.7rem' }}
|
||||
onClick={() => setStep(pi, si, {
|
||||
paramsMode: mode.mode === PARAM_JSON ? PARAM_FORM : PARAM_JSON,
|
||||
})}>
|
||||
{mode.mode === PARAM_JSON ? 'Edit as a form' : 'Edit as JSON'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{mode.forced && (
|
||||
<p className="sans" style={{ fontSize: '0.76rem', color: '#d9c184', margin: '6px 0 0' }}>
|
||||
Opened as JSON: {mode.reason}. Nothing has been dropped — what is here is
|
||||
exactly what was authored.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{mode.mode === PARAM_FORM ? (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
{(action?.params || []).map((p) => (
|
||||
<ParamField
|
||||
key={p.name}
|
||||
param={p}
|
||||
value={paramValue(step, p.name)}
|
||||
sourceEntry={p.source ? sources[p.source] : null}
|
||||
disabled={archived}
|
||||
onChange={(raw) => setStep(pi, si, { paramsText: setParam(step, p.name, raw, p.type) })}
|
||||
/>
|
||||
))}
|
||||
{action && (action.params || []).length === 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.78rem', margin: 0 }}>
|
||||
This action takes no params.
|
||||
</p>
|
||||
)}
|
||||
{!action && (
|
||||
<p className="sans dim" style={{ fontSize: '0.78rem', margin: 0 }}>
|
||||
Pick an action and its fields appear here, straight from what the module
|
||||
declared.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<textarea className="input" rows={Math.max(4, (step.paramsText || '').split('\n').length)}
|
||||
style={{ fontFamily: 'var(--mono, monospace)', fontSize: '0.8rem', marginTop: 8 }}
|
||||
value={step.paramsText} onChange={(e) => setStep(pi, si, { paramsText: e.target.value })} />
|
||||
{action && (action.params || []).length > 0 && (
|
||||
<span className="sans dim" style={{ fontSize: '0.74rem' }}>
|
||||
<code>{action.id}</code> declares{' '}
|
||||
{(action.params || []).map((p) => `${p.name} (${p.type}${p.required ? ', required' : ''})`).join(', ')}.
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user