feat(events): the authoring UI proper (Phase 13)
Some checks failed
PR Checks / client-build (pull_request) Successful in 31s
PR Checks / bot-tests (pull_request) Successful in 31s
PR Checks / server-tests (pull_request) Failing after 9m1s

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:
2026-09-07 16:34:03 -05:00
parent db8e01e868
commit 8453762e3b
13 changed files with 1913 additions and 149 deletions

View File

@@ -212,7 +212,11 @@ export default function App() {
`/:id` — the same collision, on the other side of the wire. */}
<Route path="events/actions" element={<EventActions />} />
<Route path="events/runs/:runId" element={<EventRun />} />
<Route path="events/new" element={<EventEditor />} />
{/* ONE route, and `new` is a value of `:id` rather than a
path beside it. A static `events/new` outranks the dynamic
segment in React Router whatever the order, so the editor
was handed no `id` at all and asked the API for
`/admin/events/undefined`. */}
<Route path="events/:id" element={<EventEditor />} />
{/* Engagement (ENGAGEMENT.md Phases 4b and 5b). Admin-only, matching the
server: every route under /admin/engagement re-gates to `admin`

View File

@@ -492,12 +492,25 @@ export const api = {
// 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 12b made a source SEARCHABLE and Phase 13 is what asks. `q` is
// ignored, never refused, by a source that does not declare itself
// searchable — so passing it is always safe and the field decides whether
// it is a typeahead by reading `searchable` off the answer.
eventOptions: (sourceId, q) => {
const qs = q ? `?${new URLSearchParams({ q }).toString()}` : ''
return req(`/admin/events/catalog/options/${encodeURIComponent(sourceId)}${qs}`)
},
// 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
// — the request succeeded, the plan has problems.
verifyEvent: (id) => req(`/admin/events/${id}/verify`, { method: 'POST' }),
// Phase 13's live cap meter, and NOT a lighter dry run — it dispatches
// nothing, so it knows nothing a module knows. It takes the spec in the
// body rather than an id because the plan it prices is the one in the
// author's hands, which is unsaved between keystrokes, and it records
// nothing, which is what makes it safe to call on a debounce.
priceEvent: (body) => req('/admin/events/price', { method: 'POST', body }),
// The switchboard, admin only in BOTH directions: reading which actions a
// deployment permits is as much configuration as writing it (§K). One action
// per write rather than the whole board, so an action that appeared between

View File

@@ -14,6 +14,13 @@
// meets at 2am while the thing they are trying to stop keeps running — so the
// guards are written twice on purpose and the copy is checked.
// **The condition builder is borrowed, not rebuilt.** §I says the step editor
// reuses "the condition builder, exactly" — and a phase's advance gate is
// literally the engagement grammar, validated on the server by
// `engagement/conditions.js`. Importing the row helpers is what keeps this screen
// from becoming a second opinion about a grammar core owns.
import { conditionRowsFrom, conditionsFromRows, coerceLiteral } from './engagementRules.js'
// A run that is over. Verbatim `eventRuns.db`'s TERMINAL.
export const TERMINAL_RUN_STATUSES = ['completed', 'cancelled', 'failed', 'missed']
@@ -115,14 +122,13 @@ const nextPhaseKey = (phases) => {
}
/**
* A new step, with its params box PREFILLED from the action's declared examples.
* A new step, with its params PREFILLED from the action's declared examples.
*
* Every param carries a required `example` — that requirement is the reason this
* works — so a fresh `core.announce` step arrives as a JSON object with the right
* keys and plausible values rather than as an empty `{}` an author has to guess
* the shape of. It is the nearest a raw JSON box gets to the schema-driven form
* Phase 13 replaces it with, and it costs nothing the catalog was not already
* serving.
* works — so a fresh `core.announce` step arrives with the right keys and
* plausible values rather than empty. Phase 13 turned the box into a form and
* this stayed exactly as it was: a form whose fields start at the declared
* example is a step an author edits rather than one they compose.
*/
export function blankStep(action) {
const params = {}
@@ -152,7 +158,18 @@ export function blankPhase(phases) {
* typed under the other option is one an operator learns to be afraid of.
*/
export function blankAdvance() {
return { kind: '', after: '30m', on: '', count: 1, whereText: '' }
return { kind: '', after: '30m', on: '', count: 1, ...blankWhere() }
}
/**
* The `where` predicate as the BUILDER holds it (Phase 13).
*
* `whereText` survives beside the rows and is not vestigial: it is what a
* predicate the builder cannot render is shown as, and what is posted for one.
* See `whereFormFrom`.
*/
export function blankWhere() {
return { whereOp: 'and', whereRows: [], whereEditable: true, whereText: '' }
}
export const ADVANCE_KINDS = [
@@ -161,7 +178,7 @@ export const ADVANCE_KINDS = [
{ value: 'on', label: 'When something happens in the game' },
]
/** The stored gate, as the form's three fields. */
/** The stored gate, as the form's fields. */
export function advanceFormFrom(advance) {
const blank = blankAdvance()
if (!advance) return blank
@@ -171,7 +188,34 @@ export function advanceFormFrom(advance) {
kind: 'on',
on: advance.on || '',
count: advance.count ?? 1,
whereText: advance.where ? JSON.stringify(advance.where, null, 2) : '',
...whereFormFrom(advance.where),
}
}
/**
* A stored `where` tree → the builder's flat rows (Phase 13).
*
* **This is `conditionRowsFrom` and it is deliberately the same function**, not a
* second one shaped like it. The grammar behind a phase gate is the engagement
* condition grammar — the server validates it with `engagement/conditions.js`
* and renders the diagnosis panel's sentence with the same labels — so an editor
* here that re-decided what a tree looks like would be the second implementation
* §I refuses on the read side for exactly this reason.
*
* A tree the flat editor cannot hold (`A and (B or C)`) comes back
* `whereEditable: false` and is SHOWN as its JSON rather than silently
* flattened: `A and B and C` fires on different events, and an author would have
* no way to know the save had done it to them.
*/
export function whereFormFrom(where) {
const blank = blankWhere()
if (!where) return blank
const rows = conditionRowsFrom(where)
return {
whereOp: rows.op,
whereRows: rows.rows,
whereEditable: rows.editable,
whereText: JSON.stringify(where, null, 2),
}
}
@@ -208,25 +252,37 @@ export function formFromDefinition(event) {
/**
* One phase's advance gate, as the spec shape — or null when it has none.
*
* Only the `where` JSON is checked, and only because text that is not JSON
* cannot be put in a request at all. **Whether the predicate is VALID is the
* server's answer**, and the whole trap of Phase 5 is that it is answered at
* save with the offending variable named — re-deciding it here would be a second
* validator drifting from the one that matters, exactly as with a step's params.
* **Whether the predicate is VALID is still the server's answer.** The builder
* coerces each literal to the type the trigger DECLARED — which is not a second
* validator but the thing that makes the first one's error useful: every value
* in an HTML input is a string, and `{ cmp: 'gt', value: \"5\" }` against an `int`
* variable is refused by `engagement/conditions.js`, rightly, at which point the
* author is reading an error about JSON rather than about what they typed.
*
* A predicate the builder could not render round-trips through `whereText`
* unchanged. That is the point of keeping the text: the alternative to posting it
* back verbatim is dropping an author's tree because this screen could not draw
* it.
*/
export function advancePayload(advance, where, errors) {
export function advancePayload(advance, where, errors, variables = []) {
if (!advance || !advance.kind) return null
if (advance.kind === 'after') return { after: advance.after }
const out = { on: advance.on, count: Number(advance.count) || 1 }
const text = String(advance.whereText || '').trim()
if (text) {
try {
out.where = JSON.parse(text)
} catch (err) {
errors.push(`${where}, advance condition: ${err.message}`)
if (advance.whereEditable === false) {
const text = String(advance.whereText || '').trim()
if (text) {
try {
out.where = JSON.parse(text)
} catch (err) {
errors.push(`${where}, advance condition: ${err.message}`)
}
}
return out
}
const built = conditionsFromRows(advance.whereOp || 'and', advance.whereRows || [], variables)
if (built) out.where = built
return out
}
@@ -244,10 +300,20 @@ export function advancePayload(advance, where, errors) {
* applies the action's risk-class default rather than being told a value the
* form invented.
*/
export function payloadFromForm(form) {
export function payloadFromForm(form, { triggersById = new Map() } = {}) {
const errors = []
const phases = (form.phases || []).map((phase, pi) => {
const where = advancePayload(phase.advance, `Phase ${pi + 1} "${phase.label || phase.key}"`, errors)
const where = advancePayload(
phase.advance,
`Phase ${pi + 1} "${phase.label || phase.key}"`,
errors,
// The declared types the builder coerces against. A trigger nothing
// registers has none, and every literal then stays the string it was typed
// as — which is right: the gate is dormant, the server carries its `where`
// through unvalidated, and inventing types for it here would edit a
// predicate nobody can currently check.
triggersById.get(phase.advance?.on)?.variables || [],
)
return {
key: phase.key,
label: phase.label,
@@ -431,6 +497,157 @@ export function parseParams(text) {
return { params: value }
}
// ── Step params, as a form (Phase 13) ─────────────────────────────
//
// §I: the step editor is *"the condition builder, exactly — core serves a
// catalog, the module declared the schema, core renders a form it does not
// understand"*. Phase 3 shipped the raw JSON box as an explicit placeholder for
// this, and everything the form needs was already in the catalog: a param's
// name, type, whether it is required, its description, its example, and the
// option source behind it.
//
// **The JSON stays as the storage and as the escape hatch, and both halves of
// that matter.** As storage, because `payloadFromForm` already builds a request
// out of it and a second representation would be two things to keep in step. As
// an escape hatch, because a form can only render what the declaration
// describes — and a step may legitimately hold something it does not.
//
// The rule for when the form gives way is the CONDITION BUILDER'S rule, which is
// the reason this reads as a port of it rather than as a new idea: a value the
// editor cannot round-trip is SHOWN rather than silently rewritten. Flattening
// `A and (B or C)` there and dropping an undeclared param here are the same
// mistake — a save that looks clean and means something else.
/** The two ways a step's params are edited. */
export const PARAM_FORM = 'form'
export const PARAM_JSON = 'json'
/**
* Can this step's params be rendered as a form without losing anything?
*
* `{ ok: true }`, or `{ ok: false, reason }` naming what the form cannot hold.
* Three things make one, and none of them is an error — each is a step that has
* to be edited as JSON:
*
* • **the action is dormant.** There is no declaration, so there are no fields.
* A form here would render nothing and look like a step with no params.
* • **a param the action does not declare.** The save refuses it by name, which
* is what the author needs to see — and a form that dropped it would post a
* step that saves cleanly having deleted something they typed.
* • **a value no single control can hold** — an object or an array against a
* scalar declaration.
*/
export function paramsRenderable(action, params) {
if (!action) return { ok: false, reason: 'the module that registered this action is not installed' }
const declared = new Map((action.params || []).map((p) => [p.name, p]))
for (const [name, value] of Object.entries(params || {})) {
if (!declared.has(name)) {
return { ok: false, reason: `this step carries "${name}", which ${action.id} does not declare` }
}
if (value !== null && typeof value === 'object') {
return { ok: false, reason: `"${name}" holds a ${Array.isArray(value) ? 'list' : 'structure'}, which no single field can hold` }
}
}
return { ok: true }
}
/**
* Which mode should this step open in?
*
* The author's own choice wins whenever the form COULD render the step — an
* author who switched to JSON stays in JSON. What they cannot do is stay in a
* form that would lose something, so an unrenderable step is forced to JSON
* whatever the choice was, and the reason is returned so the screen can say it.
*/
export function paramsMode(step, action) {
const parsed = parseParams(step?.paramsText)
if (parsed.error) return { mode: PARAM_JSON, forced: true, reason: parsed.error }
const renderable = paramsRenderable(action, parsed.params)
if (!renderable.ok) return { mode: PARAM_JSON, forced: true, reason: renderable.reason }
return { mode: step?.paramsMode === PARAM_JSON ? PARAM_JSON : PARAM_FORM, forced: false, reason: null }
}
/** One declared param's current value, as the control holds it. */
export function paramValue(step, name) {
const parsed = parseParams(step?.paramsText)
if (parsed.error) return undefined
return parsed.params[name]
}
/**
* Write one param, and give back the whole box.
*
* **An empty field REMOVES the key rather than posting an empty string**, and
* that is the server's own reading rather than a convenience: `checkParams`
* treats `undefined`, `null` and `''` alike — absent — so a required param left
* blank comes back as *"is required"*, which is the error the author needs,
* instead of as a type complaint about `""`.
*
* **A value that does not parse is passed through as typed.** `coerceLiteral` is
* the engagement builder's, unchanged, and its rule is the one that matters
* here too: half of `-` is not a number, and turning it into `NaN` or `0` while
* somebody is still typing would either post a value they never wrote or make
* the field impossible to type a negative into. The server's type check then
* names the param.
*
* Re-serialising the whole object rather than splicing text, for `pickParam`'s
* reason: a string edit that produced valid-looking JSON with a duplicate key
* would be a value the editor and the server read differently.
*/
export function setParam(step, name, raw, type) {
const parsed = parseParams(step?.paramsText)
if (parsed.error) return step?.paramsText || '{}'
const next = { ...parsed.params }
if (raw === '' || raw === undefined || raw === null) delete next[name]
else next[name] = coerceLiteral(type, raw)
return JSON.stringify(next, null, 2)
}
/**
* A stored `datetime` as a `datetime-local` input wants it, and back.
*
* The server normalises a datetime param to an ISO string (`conditions.js`
* `checkLiteral`), and the input needs `YYYY-MM-DDTHH:mm` with no zone. The
* slice is the whole conversion in one direction; in the other the input's own
* text is a moment `new Date()` parses, so it is posted as typed and the server
* does the normalising — one implementation of what a datetime is, and it is
* not this one.
*/
export const datetimeInputValue = (value) => (typeof value === 'string' ? value.slice(0, 16) : '')
/**
* Everything the meter needs out of the form, and nothing else.
*
* The price route takes a spec, not a definition: no title, no schedule, no
* series. Sending the whole payload would put a document in front of a route
* that reads two fields of it — and would fail the moment the rest of the form
* is mid-edit, which is exactly when the meter is being read.
*
* A step whose params do not parse is sent with none rather than dropped, so a
* half-typed JSON box costs its own step's draw and not the phase's.
*/
export function priceBodyFrom(form) {
return {
phases: (form?.phases || []).map((phase) => ({
key: phase.key || null,
steps: (phase.steps || []).map((step) => ({
actionId: step.actionId || '',
params: parseParams(step.paramsText).params || {},
})),
})),
}
}
/**
* Is this plan worth pricing at all?
*
* A meter that fires on an empty form asks the server what nothing costs, on
* every keystroke of the title field. One step with an action chosen is the
* threshold, because that is the first moment there is an answer.
*/
export const worthPricing = (form) =>
(form?.phases || []).some((p) => (p.steps || []).some((s) => s.actionId))
// ── Rendering what happened ────────────────────────────────────────────────
const STATUS_WORDS = {

View File

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

View File

@@ -23,6 +23,17 @@ import {
WEEKDAYS,
MONTHLY_NTHS,
ADVANCE_KINDS,
blankWhere,
whereFormFrom,
paramsRenderable,
paramsMode,
paramValue,
setParam,
datetimeInputValue,
priceBodyFrom,
worthPricing,
PARAM_FORM,
PARAM_JSON,
} from '../src/lib/eventAuthoring.js'
// lib/eventAuthoring.js — what the three Events screens say and what they let
@@ -479,29 +490,97 @@ test('a gate round-trips through the form without losing the other shape', () =>
assert.equal(ADVANCE_KINDS[0].value, '')
})
test('advancePayload sends one shape, and only reports a JSON error', () => {
test('advancePayload sends one shape, built from the builder\u2019s rows', () => {
const errors = []
assert.equal(advancePayload({ kind: '' }, 'Phase 1', errors), null, 'no gate sends no key at all')
assert.deepEqual(advancePayload({ kind: 'after', after: '30m' }, 'Phase 1', errors), { after: '30m' })
assert.deepEqual(
advancePayload({ kind: 'on', on: 'uo.champ.boss_up', count: '2', whereText: '' }, 'Phase 1', errors),
advancePayload({ kind: 'on', on: 'uo.champ.boss_up', count: '2', ...blankWhere() }, 'Phase 1', errors),
{ on: 'uo.champ.boss_up', count: 2 },
'an empty predicate is omitted, not sent as an empty object',
)
assert.equal(errors.length, 0)
advancePayload({ kind: 'on', on: 'x', count: 1, whereText: '{ not json' }, 'Phase 2 "Boss"', errors)
assert.equal(errors.length, 1)
assert.match(errors[0], /Phase 2 "Boss", advance condition:/)
// Whether the predicate is VALID is the server's answer, named variable and
// all. This only refuses text that cannot be put in a request.
const clean = []
// Whether the predicate is VALID is still the server's answer, named variable
// and all \u2014 the builder only offers what the trigger declares, and a variable
// that has gone away comes back named from the save.
assert.deepEqual(
advancePayload({ kind: 'on', on: 'x', count: 1, whereText: '{"variable":"nope","cmp":"eq","value":1}' }, 'Phase 1', clean),
advancePayload(
{
kind: 'on',
on: 'x',
count: 1,
...blankWhere(),
whereRows: [{ variable: 'nope', cmp: 'eq', value: '1' }],
},
'Phase 1',
errors,
[{ name: 'nope', type: 'int' }],
),
{ on: 'x', count: 1, where: { variable: 'nope', cmp: 'eq', value: 1 } },
)
assert.equal(clean.length, 0)
assert.equal(errors.length, 0)
})
test('the builder coerces each literal to the type the trigger declared', () => {
// The trap this closes: every value in an HTML input is a string, and
// `{ cmp: 'gt', value: "5" }` against an int variable is refused by
// engagement/conditions.js. Without this the author reads an error about JSON
// rather than about what they typed.
const built = advancePayload(
{
kind: 'on',
on: 'x',
count: 1,
...blankWhere(),
whereOp: 'or',
whereRows: [
{ variable: 'tier', cmp: 'gte', value: '3' },
{ variable: 'region', cmp: 'in', value: 'Yew, Britain' },
],
},
'Phase 1',
[],
[{ name: 'tier', type: 'int' }, { name: 'region', type: 'string' }],
)
assert.deepEqual(built.where, {
op: 'or',
nodes: [
{ variable: 'tier', cmp: 'gte', value: 3 },
{ variable: 'region', cmp: 'in', value: ['Yew', 'Britain'] },
],
})
})
test('a predicate the builder cannot render is posted back unchanged, not flattened', () => {
// `A and (B or C)` is not `A and B and C` \u2014 they fire on different events \u2014
// and an author would have no way to know the save had done it. The condition
// builder's own rule, and this is the same function.
const nested = {
op: 'and',
nodes: [
{ variable: 'region', cmp: 'eq', value: 'Yew' },
{ op: 'or', nodes: [{ variable: 'tier', cmp: 'eq', value: 1 }, { variable: 'tier', cmp: 'eq', value: 2 }] },
],
}
const form = whereFormFrom(nested)
assert.equal(form.whereEditable, false)
assert.deepEqual(form.whereRows, [])
const errors = []
const built = advancePayload({ kind: 'on', on: 'x', count: 1, ...form }, 'Phase 1', errors)
assert.deepEqual(built.where, nested, 'the tree survives a screen that cannot draw it')
assert.equal(errors.length, 0)
// And the text is still the thing that can fail to parse, which is the only
// reason this path keeps an error channel at all.
advancePayload(
{ kind: 'on', on: 'x', count: 1, whereEditable: false, whereText: '{ not json' },
'Phase 2 "Boss"',
errors,
)
assert.equal(errors.length, 1)
assert.match(errors[0], /Phase 2 "Boss", advance condition:/)
})
test('a phase with no gate sends no `advance` key', () => {
@@ -602,3 +681,159 @@ test("the log renders Phase 6's three kinds, and a refusal does not read as a fa
/Version 2 passed its dry run — scheduled occurrences may start/,
)
})
// ── Step params as a form (Phase 13) ──────────────────────────────
//
// The form is not a boundary either — `events/spec.js` still decides what may be
// saved. What is tested here is the thing that would be wrong SILENTLY: a form
// that drops a param it cannot draw, or writes a value the author never typed.
const spawn = {
id: 'test.spawn',
label: 'Spawn',
params: [
{ name: 'creature', type: 'string', required: true, example: 'orc', source: 'test.creatures' },
{ name: 'count', type: 'int', required: true, example: 8 },
{ name: 'tame', type: 'boolean', required: false, example: false },
{ name: 'at', type: 'datetime', required: false, example: '2026-09-07T20:00:00.000Z' },
],
}
const stepWith = (params, over = {}) => ({
actionId: 'test.spawn',
paramsText: JSON.stringify(params, null, 2),
...over,
})
test('a step whose params the form can hold opens as a form', () => {
const mode = paramsMode(stepWith({ creature: 'orc', count: 8 }), spawn)
assert.deepEqual(mode, { mode: PARAM_FORM, forced: false, reason: null })
})
test('an author who chose JSON stays in JSON', () => {
const mode = paramsMode(stepWith({ creature: 'orc' }, { paramsMode: PARAM_JSON }), spawn)
assert.equal(mode.mode, PARAM_JSON)
assert.equal(mode.forced, false, 'their choice, so no reason is shown')
})
test('a param the action does not declare FORCES the JSON box and says which', () => {
// The form would render four fields and post four values, having deleted
// `radius` — a save that looks clean and means something else. The save path
// refuses it by name, which is what the author needs to see.
const mode = paramsMode(stepWith({ creature: 'orc', count: 8, radius: 12 }), spawn)
assert.equal(mode.mode, PARAM_JSON)
assert.equal(mode.forced, true)
assert.match(mode.reason, /carries "radius", which test\.spawn does not declare/)
})
test('a value no single control can hold forces the JSON box', () => {
assert.match(paramsMode(stepWith({ creature: ['orc', 'troll'] }), spawn).reason, /holds a list/)
assert.match(paramsMode(stepWith({ creature: { id: 'orc' } }), spawn).reason, /holds a structure/)
})
test('a dormant step is edited as JSON, because there is no declaration to draw', () => {
const mode = paramsMode(stepWith({ creature: 'orc' }), undefined)
assert.equal(mode.mode, PARAM_JSON)
assert.equal(mode.forced, true)
assert.match(mode.reason, /not installed/)
})
test('a params box that is not JSON opens as JSON with the parse error', () => {
const mode = paramsMode({ actionId: 'test.spawn', paramsText: '{ not json' }, spawn)
assert.equal(mode.mode, PARAM_JSON)
assert.equal(mode.forced, true)
assert.match(mode.reason, /not valid JSON/)
})
test('paramsRenderable accepts a step with nothing in it', () => {
// A brand-new step with an optional-only action, and the empty case a form
// needs to survive before anybody has typed.
assert.deepEqual(paramsRenderable(spawn, {}), { ok: true })
})
test('setParam writes the type the param declared, not the string the input held', () => {
const step = stepWith({ creature: 'orc', count: 8 })
assert.deepEqual(JSON.parse(setParam(step, 'count', '12', 'int')), { creature: 'orc', count: 12 })
assert.deepEqual(JSON.parse(setParam(step, 'tame', 'true', 'boolean')), {
creature: 'orc',
count: 8,
tame: true,
})
})
test('a half-typed number is kept as typed rather than turned into NaN', () => {
// `coerceLiteral`'s rule, and the reason it is borrowed rather than rewritten:
// turning `-` into NaN while somebody types would either post a value they
// never wrote or make a negative impossible to enter. The server's type check
// then names the param.
const step = stepWith({ count: 8 })
assert.deepEqual(JSON.parse(setParam(step, 'count', '-', 'int')), { count: '-' })
})
test('clearing a field REMOVES the key rather than posting an empty string', () => {
// `checkParams` treats undefined, null and '' alike — absent — so a required
// param left blank comes back as "is required", which is the error the author
// needs, instead of a type complaint about "".
const step = stepWith({ creature: 'orc', count: 8 })
assert.deepEqual(JSON.parse(setParam(step, 'creature', '', 'string')), { count: 8 })
})
test('setParam leaves an unparseable box alone rather than overwriting it', () => {
// The only way to reach this is a race between the mode switch and a
// keystroke; silently replacing the text with `{ "count": 1 }` would destroy
// whatever the author was midway through writing.
const step = { actionId: 'test.spawn', paramsText: '{ not json' }
assert.equal(setParam(step, 'count', '1', 'int'), '{ not json')
})
test('paramValue reads one param, and answers nothing for a box that does not parse', () => {
assert.equal(paramValue(stepWith({ count: 8 }), 'count'), 8)
assert.equal(paramValue(stepWith({ count: 8 }), 'creature'), undefined)
assert.equal(paramValue({ paramsText: '{ not json' }, 'count'), undefined)
})
test('a datetime is sliced to what the input wants, and anything else is empty', () => {
assert.equal(datetimeInputValue('2026-09-07T20:00:00.000Z'), '2026-09-07T20:00')
assert.equal(datetimeInputValue(undefined), '')
assert.equal(datetimeInputValue(12), '')
})
// ── The meter's request (Phase 13) ────────────────────────────
test('the price body carries the plan and nothing else', () => {
const form = formFromDefinition({
title: 'Invasion',
spec: {
schedule: { kind: 'manual' },
phases: [
{ key: 'warn', label: 'Warn', steps: [{ actionId: 'core.announce', params: { trigger: 'x' } }] },
{ key: 'assault', label: 'Assault', steps: [{ actionId: 'test.spawn', params: { count: 8 } }] },
],
},
})
assert.deepEqual(priceBodyFrom(form), {
phases: [
{ key: 'warn', steps: [{ actionId: 'core.announce', params: { trigger: 'x' } }] },
{ key: 'assault', steps: [{ actionId: 'test.spawn', params: { count: 8 } }] },
],
})
})
test('a step whose params do not parse is priced with none rather than dropped', () => {
// Dropping it would move every step after it up an ordinal, so the meter's
// "phase 2 step 3" would name a different step from the one on the screen.
const form = {
phases: [{ key: 'p', steps: [{ actionId: 'test.spawn', paramsText: '{ not json' }] }] ,
}
assert.deepEqual(priceBodyFrom(form).phases[0].steps, [{ actionId: 'test.spawn', params: {} }])
})
test('an empty plan is not worth pricing', () => {
// Otherwise the meter asks the server what nothing costs on every keystroke of
// the title field.
assert.equal(worthPricing({ phases: [] }), false)
assert.equal(worthPricing({ phases: [{ steps: [] }] }), false)
assert.equal(worthPricing({ phases: [{ steps: [{ actionId: '' }] }] }), false)
assert.equal(worthPricing({ phases: [{ steps: [{ actionId: 'test.spawn' }] }] }), true)
})