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, datetimeInputValue, priceBodyFrom, worthPricing, } from '../../../lib/eventAuthoring.js' import { operatorsForType } 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 && ( )}
) } /** * 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 = ( ) } else if (param.type === 'datetime') { control = ( onChange(e.target.value)} /> ) } else if (param.type === 'int' || param.type === 'float') { control = ( onChange(e.target.value)} /> ) } else if (param.type === 'url') { control = ( onChange(e.target.value)} /> ) } else { control = ( onChange(e.target.value)} /> ) } return ( ) } /** * 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 (
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 && (

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.

)}

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 }) { 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 (

Start a run now