import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useNavigate, useParams } from 'react-router-dom' import { Loading, ErrorState } from '../../../components/PageState.jsx' import { useAuth } from '../../../contexts/AuthContext.jsx' import { api } from '../../../api/client.js' import { formFromDefinition, payloadFromForm, blankPhase, blankAdvance, blankWhere, whereFormFrom, ADVANCE_KINDS, blankStep, describeSchedule, scheduleFromForm, SCHEDULE_KINDS, MONTHLY_NTHS, WEEKDAYS, PARAM_FORM, PARAM_JSON, paramsMode, paramValue, setParam, setParamList, listValue, addToList, filterOptions, datetimeInputValue, priceBodyFrom, worthPricing, boolValue, isSegmentable, noteFor, missingParams, guardedAdvance, shortfallSentence, blankStart, isStartableTrigger, describeTriggerStarts, } from '../../../lib/eventAuthoring.js' import { operatorsForType, coerceLiteral } from '../../../lib/engagementRules.js' // Admin → Events → the definition editor (EVENTS.md §I, Phase 3). // // **A vertical timeline, not a node graph**, and that is a decision about what // the engine can actually do rather than a matter of taste. The condition // grammar has no branching — it is `and`/`or`/`not` over comparisons, bounded at // depth five — so a canvas would promise power this project has never handed an // operator. Phases in order, each with its steps in order, says exactly what the // runner does with them. // // **Core renders no game word here.** Every label on a step comes from the // action's own registration — its `label`, its params' names, their descriptions // and their examples — so an installed module's vocabulary appears without core // knowing any of it, and `check:modules` already fails core's build on a UO // identifier. // // **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). * * **A refusal renders as a warning and leaves the field usable**, which is the * contract rather than a nicety: a source is answered by a module that may be * talking to a sidecar, and an authoring form a shard outage can make unusable * would be a worse failure than the typo the dropdown exists to prevent. The * operator very often knows the value they want to type. * * The picker WRITES INTO THE JSON box rather than replacing it, because the box * is still the field until the schema-driven form arrives — so this is the one * affordance that can exist today and be right afterwards: the values come from * the module, and the exact spelling is never typed by hand. When the JSON does * not parse the picker says so rather than silently doing nothing, because * "clicked and nothing happened" is the one behaviour a form must never have. */ function ParamOptions({ entry, label, disabled, onPick }) { if (!entry || entry.state === 'loading') { return
Reading the list…
} if (entry.state === 'failed') { return (
{entry.reason} — type the value by hand.
) } if (!entry.options.length) { return (
{label} has nothing to offer right now — type the value by hand.
) } const grouped = entry.options.some((o) => o.group) const groups = grouped ? [...new Set(entry.options.map((o) => o.group || 'Other'))] : [] return ( ) } /** * 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 (
setTerm(e.target.value)} /> {state.status === 'loading' && (
Searching…
)} {state.status === 'failed' && (
{state.reason} — type the value by hand.
)} {state.status === 'ok' && state.options.length === 0 && (
Nothing matches “{term}”.
)} {state.status === 'ok' && state.options.length > 0 && ( )}
) } /** * The chosen option's note (MODULE_API 1.13.0), found even when the list the * form holds does not reach it. * * A searchable source answers the form a bounded first page, so the value an * author picked — or a template filled in — may not be in it, and its note with * it. Then the source is asked for that value as a search term, once per value, * and the row whose value matches exactly is the one whose note is shown. A * failure is no note: advice never breaks the field it advises. */ function useChosenNote(sourceId, value, entry) { const [asked, setAsked] = useState({ key: null, note: null }) const text = value === undefined || value === null ? '' : String(value) const known = noteFor(entry, value) const needAsk = Boolean(sourceId && entry?.state === 'ok' && entry.searchable && text && !known && !entry.options.some((o) => o.value === text)) const key = needAsk ? `${sourceId}\u0000${text}` : null useEffect(() => { if (!key || asked.key === key) return undefined let alive = true const timer = setTimeout(async () => { try { const answer = await api.admin.eventOptions(sourceId, text) const row = answer?.ok ? (answer.options || []).find((o) => o.value === text) : null if (alive) setAsked({ key, note: row?.note || null }) } catch { if (alive) setAsked({ key, note: null }) } }, 300) return () => { alive = false clearTimeout(timer) } }, [key, asked.key, sourceId, text]) if (known) return known return key && asked.key === key ? asked.note : null } /** * A row of buttons for a choice of two or three (Rust PLAN_REDESIGNS §2.4). * * A dropdown of two hides the other choice behind a click; a row shows both and * which one is on. `aria-pressed` carries the state for a screen reader, the * opacity for everyone else — the same convention the weekday buttons use. */ function Segmented({ choices, value, disabled, onPick }) { const current = value === undefined || value === null ? '' : String(value) return (
{choices.map((c) => { const on = current === String(c.value) return ( ) })}
) } /** * One declared param, as the control its type implies (Phase 13; reworked for * MODULE_API 1.13.0, Rust PLAN_REDESIGNS §2.1). * * **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. * * **The field is the thing it does, not its type.** There is no ` · int` tag and * no bare asterisk (U-3): a required field says so in words, and only when it is * empty at save time (`missing`). The controls: * * • **A boolean is a toggle when it is required and three buttons when it is * optional** (D334). For an OPTIONAL boolean "not set" is a real third state — * the action's own default — and a two-state control would post `false` for * every param an author never touched. * • **A number shows its range and its unit beside it** when the declaration has * them: a lease's `min`–`max`, a duration's minutes. * • **A closed set of two or three is a row of buttons**, whether it is a lease's * `values` or a source that answered with that few (§2.4). * • **A source-backed param keeps its free-text field** otherwise. 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. A row of buttons replaces the text box only while the source has * answered and the value is one of its choices. * • **The chosen option's `note` is shown under the field** (§2.6). * * `label`, `range` and `values` are passed by the lease form, which draws one * lease's fields out of `core.lease`'s generic ones. */ function ParamField({ param, value, sourceEntry, disabled, onChange, missing, label, range, values }) { const common = { className: 'input', disabled, style: { fontSize: '0.8rem' } } const asText = value === undefined || value === null ? '' : String(value) const placeholder = param.example === undefined || param.example === null ? '' : String(param.example) let control let beside = null if (param.type === 'boolean') { const b = boolValue(value) control = param.required ? ( ) : ( ) } else if (values && values.length) { control = values.length <= 3 ? ( ({ value: v, label: v }))} /> ) : ( ) } else if (param.type === 'datetime') { control = ( onChange(e.target.value)} /> ) } else if (param.type === 'int' || param.type === 'float') { control = ( onChange(e.target.value)} /> ) const parts = [] if (param.duration === 'minutes') parts.push('minutes') if (range && range.min !== null && range.min !== undefined && range.max !== null && range.max !== undefined) { parts.push(`${range.min}–${range.max}`) } else if (range && range.max !== null && range.max !== undefined) { parts.push(`at most ${range.max}`) } if (parts.length) beside = {parts.join(' · ')} } else if (param.type === 'url') { control = ( onChange(e.target.value)} /> ) } else { control = ( onChange(e.target.value)} /> ) } // A source of two or three answered choices is a row of buttons, and the text // box goes — unless the value is not one of them, which must stay visible and // editable rather than look unset. const segmented = param.source && param.type !== 'boolean' && !values && isSegmentable(sourceEntry) const offList = segmented && asText !== '' && !sourceEntry.options.some((o) => o.value === asText) const note = useChosenNote(param.source, value, sourceEntry) return (
{segmented && ( ({ value: o.value, label: o.label }))} /> )} {missing && ( This is required — fill it in before saving. )} {param.description && ( {param.description} )} {note && ( {note} )} {param.source && !segmented && ( sourceEntry?.searchable ? ( ) : ( ) )}
) } /** * A param that holds several values, as a multi-select (MODULE_API 1.14.0; Rust * PLAN_REDESIGNS §12.3, D344). * * The chosen values are chips above the control, each with its remove button, * and the control is whichever the param's source allows: * * • **a source that answered** — a grouped checklist in the source's own order, * with a filter box over it, because a server's few hundred permissions are a * list to narrow, not to scroll; * • **a searchable source** — the typeahead, each pick adding a chip; * • **no source, or one that failed** — a tag box, a value per Enter. A failure * is said and the field stays usable, the single-value field's rule (§F). * * Ticking past `max` is refused in words, where the click was. The order the * author picked in is the order stored, and the server keeps it. */ function MultiParamField({ param, value, sourceEntry, disabled, onChange, missing }) { const list = listValue(value) const [term, setTerm] = useState('') const [typed, setTyped] = useState('') const [refused, setRefused] = useState(null) const max = param.max || 20 const answered = sourceEntry && sourceEntry.state === 'ok' const byValue = new Map(answered ? sourceEntry.options.map((o) => [o.value, o]) : []) const add = (v) => { const next = addToList(list, v, max) if (next.refused) setRefused(next.refused) else { setRefused(null) onChange(next.list) } } const remove = (v) => { setRefused(null) onChange(list.filter((x) => String(x) !== String(v))) } const toggle = (v) => (list.some((x) => String(x) === v) ? remove(v) : add(v)) const shown = answered && !sourceEntry.searchable ? filterOptions(sourceEntry.options, term) : [] const groups = [...new Set(shown.map((o) => o.group || ''))] const chosenNotes = list .map((v) => ({ v, note: byValue.get(String(v))?.note })) .filter((x) => x.note) const tagBox = (
setTyped(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault() add(typed) setTyped('') } }} />
) let control if (param.source && sourceEntry?.searchable) { control = ( ) } else if (param.source && answered && sourceEntry.options.length) { control = (
setTerm(e.target.value)} />
{shown.length === 0 && (
Nothing matches “{term}”.
)} {groups.map((g) => (
{g &&
{g}
} {shown.filter((o) => (o.group || '') === g).map((o) => ( ))}
))}
) } else { control = ( <> {param.source && ( )} {tagBox} ) } return (
{param.name} · {list.length} of at most {max}
{list.length === 0 && None chosen yet.} {list.map((v) => ( {byValue.get(String(v))?.label || String(v)} ))}
{refused && ( {refused} )} {control} {missing && ( This is required — choose at least one before saving. )} {param.description && ( {param.description} )} {chosenNotes.map(({ v, note }) => ( {byValue.get(String(v))?.label || String(v)}: {note} ))}
) } /** * `core.lease`, drawn as the chosen lease (Rust PLAN_REDESIGNS §2.1, U-4, F11). * * `core.lease` declares four generic fields — a lease id, a value as text, a * target as text, a number of minutes — because one action serves every lease * there is. Drawn as declared, a Rust admin was shown a skill-gain rate as the * example and a free-text box for a target the lease has a list of. So the * lease is picked by its label, and the other three become that lease's own: * * • the value as the lease's type — a toggle, a number with the lease's range, * or its closed set of values — with the lease's own example; * • the target as a picker over the lease's own `target.source` (F11: the editor * read only `param.source`, and `core.lease`'s target param has none, on * purpose — see its declaration); * • the minutes with the lease's longest hold beside them. * * What is written is still `core.lease`'s params, as text where the action says * text: the action coerces the value to the lease's type, and that stays the one * place that knows both halves. */ function LeaseFields({ action, step, leases, sources, disabled, missing, onParam }) { const declared = new Map((action.params || []).map((p) => [p.name, p])) const leaseId = paramValue(step, 'lease') const lease = leases.find((l) => l.id === leaseId) || null const target = paramValue(step, 'target') const owners = [...new Set(leases.map((l) => l.owner || 'other'))] return ( <>
{missing.includes('lease') && ( This is required — fill it in before saving. )} {lease?.description && ( {lease.description} )} {leaseId && !lease && ( No installed module declares {leaseId}. )}
{lease && ( onParam('value', raw)} /> )} {lease?.target && ( onParam('target', raw)} /> )} {lease && !lease.target && target !== undefined && target !== '' && (

{lease.label} is a single value and takes no target, so the step will be refused with {String(target)} in it.{' '}

)} {declared.get('minutes') && ( onParam('minutes', raw)} /> )} ) } /** * "Start from a template" on a new event (MODULE_API 1.13.0; Rust PLAN_REDESIGNS * §2.5, D192). * * A module's named starting point: the author fills in a few inputs and gets an * ordinary draft in the editor below — the title, the phases, the steps — which * they read, change and save like any other. **Nothing is saved here**, and * nothing about the template follows the definition: it is a shortcut past * typing, and the editor is still where the event is made. * * The inputs are params and are drawn by `ParamField`, so an input with a source * gets the same dropdown, buttons and search a step's field does. */ function TemplatePicker({ templates, sources, loadSource, onDraft }) { const [chosenId, setChosenId] = useState('') const [inputs, setInputs] = useState({}) const [errors, setErrors] = useState([]) const [busy, setBusy] = useState(false) const chosen = templates.find((t) => t.id === chosenId) || null useEffect(() => { for (const p of chosen?.inputs || []) if (p.source) loadSource(p.source) }, [chosen, loadSource]) const choose = (id) => { setChosenId(id) setErrors([]) const t = templates.find((x) => x.id === id) // Prefilled from the examples, like a fresh step — but only the required // ones, because an example of a kit is not a kit this deployment has. setInputs({}) if (t) setInputs(Object.fromEntries(t.inputs.filter((p) => p.required && !p.source).map((p) => [p.name, p.example]))) } const fill = async () => { setBusy(true) setErrors([]) try { const { draft } = await api.admin.buildEventTemplate(chosen.id, inputs) onDraft(draft, chosen) } catch (err) { setErrors(err.body?.errors || [err.message]) } finally { setBusy(false) } } return (

Start from a template

A starting point an installed module offers. It fills in the plan below; nothing is saved until you create the draft, and you can change every part of it first.

{chosen && (
{chosen.description && (

{chosen.description}

)} {chosen.inputs.map((p) => ( setInputs((cur) => { const next = { ...cur } if (raw === '' || raw === undefined || raw === null) delete next[p.name] else next[p.name] = coerceLiteral(p.type, raw) return next })} /> ))} {errors.length > 0 && (
    {errors.map((e) =>
  • {e}
  • )}
)}
)}
) } /** * 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, emptyText }) { const variables = trigger?.variables || [] const rows = advance.whereRows || [] if (advance.whereEditable === false) { return (
Only when
          {advance.whereText}
        

This condition nests, and the builder only holds one and/or 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.

) } const setRow = (i, patch) => onChange({ whereRows: rows.map((r, j) => (j === i ? { ...r, ...patch } : r)) }) return (
Only when {rows.length === 0 && (

{emptyText || 'Every firing of this trigger counts. Add a clause to narrow it — the phase then waits for firings that match.'}

)} {rows.length > 1 && ( )} {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 (
{!valueless && ( setRow(i, { value: e.target.value })} /> )}
) })} {!variables.length && advance.on && ( This trigger declares no variables, so there is nothing to narrow on. )}

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.

) } /** * 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 (

What this plan draws {stale && · recalculating…}

{report.steps} step{report.steps === 1 ? '' : 's'}
{nothing && (

Nothing in this plan spends a capped resource.

)} {report.cost.length > 0 && ( {report.cost.map((c) => ( ))}
{labelOf(c.dimension)} {c.total} {unitOf(c.dimension)} {c.cap === null ? 'no cap' : `of ${c.cap} per run${c.from ? ` (${c.from})` : ''}`}
)} {report.unpriced.length > 0 && (

These totals are incomplete.

    {report.unpriced.map((u, i) => (
  • phase {u.phase + 1} · step {u.seq + 1} {' — '}{u.message}
  • ))}
)}

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.

) } /** * 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, draftAhead, publishedVersion, mayPublish }) { const [rehearsal, setRehearsal] = useState(false) const [scope, setScope] = useState('') const [paramsText, setParamsText] = useState('{}') const [problem, setProblem] = useState(null) // Rust PLAN_REDESIGNS §2.3 (U-5). A run is pinned to the PUBLISHED version, so // an author who saved an edit and pressed Start ran the old plan without being // told. When the working copy is ahead, the dialog asks which one — and does // not pick for them: publishing is a decision with consequences of its own // (scheduled occurrences move to the new version). const [which, setWhich] = 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 } } if (draftAhead && !which) { setProblem('Choose which plan to start: your edits, published first, or the published version.') return } onStart( { rehearsal, scope: scope || undefined, ...(params ? { params } : {}) }, { publishFirst: draftAhead && which === 'publish' }, ) } return (

Start a run now

{draftAhead && (

This definition has edits that are not published.{' '} A run uses a published version, never the working copy.

{mayPublish && ( )}
)}