Files
website/client/src/routes/admin/views/EventEditor.jsx
wtclaude f40dd765c3
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 49s
PR Checks / client-build (pull_request) Successful in 1m4s
PR Checks / server-tests (pull_request) Successful in 6m6s
fix(core): the last fixes' core half (W1 W2 W5 W19)
Stage 4 of docs/modules/rust/PLAN_LAST_FIXES.md, with the org lead's
2026-10-22 decisions.

W19: one module per site at boot, as at install. The first module
directory by name holds the site, whether or not it then loads; every
other is recorded startup_failed at the manifest stage, before a line of
its code is required, with a reason naming the module that holds the
site. Module-uo and Module-Rust side by side used to fail the second
with a slot collision. Loader, lifecycle, schema and event-contract
tests that loaded several fixture modules now load each case alone.

W1: a reconcile files a resource the game no longer has, and that is
past its `until`, as expired rather than orphaned, with the same log
line expired() writes. No MODULE_API bump: `until` and reconcile's
answer are unchanged.

W5: a dry run that passes on the draft is kept with the fingerprint of
the validated spec (three nullable columns on event_definitions).
Publish stamps the new version verified, with that dry run's reviewer
and time, when the spec it freezes has the same fingerprint, and writes
version.verified on the occurrences it re-pins. Verify answers
draftRecorded and publish answers verified; OpenAPI regenerated.

W2: the run console says "Phase p1: steps done, waiting to advance" and
"Why this phase has not advanced", not "has not started".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-22 00:38:39 -05:00

2296 lines
104 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 <div className="dim" style={{ fontSize: '0.72rem', marginTop: 4 }}>Reading the list…</div>
}
if (entry.state === 'failed') {
return (
<div className="sans" style={{ fontSize: '0.72rem', marginTop: 4, color: '#d98b84' }}>
{entry.reason} — type the value by hand.
</div>
)
}
if (!entry.options.length) {
return (
<div className="dim" style={{ fontSize: '0.72rem', marginTop: 4 }}>
{label} has nothing to offer right now — type the value by hand.
</div>
)
}
const grouped = entry.options.some((o) => o.group)
const groups = grouped
? [...new Set(entry.options.map((o) => o.group || 'Other'))]
: []
return (
<select
className="input"
style={{ fontSize: '0.75rem', marginTop: 4 }}
value=""
disabled={disabled}
onChange={(e) => { if (e.target.value) onPick(e.target.value) }}
>
<option value="">{`Pick from ${label}…`}</option>
{grouped
? groups.map((g) => (
<optgroup key={g} label={g}>
{entry.options.filter((o) => (o.group || 'Other') === g).map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</optgroup>
))
: entry.options.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
)
}
/**
* 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>
)
}
/**
* 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 (
<div role="group" style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 4 }}>
{choices.map((c) => {
const on = current === String(c.value)
return (
<button key={String(c.value)} type="button" className="pill" disabled={disabled}
aria-pressed={on}
style={{ fontSize: '0.74rem', opacity: on ? 1 : 0.45 }}
onClick={() => onPick(String(c.value))}>
{c.label}
</button>
)
})}
</div>
)
}
/**
* 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 ? (
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 4 }}>
<input type="checkbox" role="switch" disabled={disabled} checked={b === true}
onChange={(e) => onChange(e.target.checked ? 'true' : 'false')} />
<span className="sans" style={{ fontSize: '0.8rem' }}>
{b === null ? 'Not set yet' : b ? 'Yes' : 'No'}
</span>
</label>
) : (
<Segmented
disabled={disabled}
value={b === null ? '' : String(b)}
onPick={onChange}
choices={[
{ value: '', label: 'Not set' },
{ value: 'true', label: 'Yes' },
{ value: 'false', label: 'No' },
]}
/>
)
} else if (values && values.length) {
control = values.length <= 3 ? (
<Segmented disabled={disabled} value={asText} onPick={onChange}
choices={values.map((v) => ({ value: v, label: v }))} />
) : (
<select {...common} value={asText} onChange={(e) => onChange(e.target.value)}>
<option value="">Choose…</option>
{values.map((v) => <option key={v} value={v}>{v}</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'}
min={range?.min ?? undefined}
max={range?.max ?? undefined}
value={asText}
placeholder={placeholder}
style={{ ...common.style, maxWidth: 180 }}
onChange={(e) => 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 = <span className="sans dim" style={{ fontSize: '0.76rem' }}>{parts.join(' · ')}</span>
} else if (param.type === 'url') {
control = (
<input {...common} type="url" value={asText} placeholder={placeholder}
onChange={(e) => onChange(e.target.value)} />
)
} else {
control = (
<input {...common} value={asText} placeholder={placeholder}
onChange={(e) => 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 (
<div style={{ marginBottom: 12 }}>
<label style={{ display: 'block' }}>
<span className="field-label">{label || param.name}</span>
{(!segmented || offList) && (
beside
? <span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>{control}{beside}</span>
: control
)}
</label>
{segmented && (
<Segmented disabled={disabled} value={asText} onPick={onChange}
choices={sourceEntry.options.map((o) => ({ value: o.value, label: o.label }))} />
)}
{missing && (
<span className="sans" style={{ display: 'block', fontSize: '0.74rem', color: '#d98b84' }}>
This is required — fill it in before saving.
</span>
)}
{param.description && (
<span className="sans dim" style={{ display: 'block', fontSize: '0.74rem' }}>{param.description}</span>
)}
{note && (
<span className="sans" style={{ display: 'block', fontSize: '0.76rem', color: '#d9c184', marginTop: 4 }}>{note}</span>
)}
{param.source && !segmented && (
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}
/>
)
)}
</div>
)
}
/**
* 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 = (
<div style={{ display: 'flex', gap: 6, marginTop: 4 }}>
<input className="input" style={{ fontSize: '0.8rem' }} disabled={disabled} value={typed}
placeholder={param.example === undefined || param.example === null ? '' : String(param.example)}
onChange={(e) => setTyped(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
add(typed)
setTyped('')
}
}} />
<button type="button" className="pill" style={{ fontSize: '0.72rem' }} disabled={disabled || !typed.trim()}
onClick={() => { add(typed); setTyped('') }}>
Add
</button>
</div>
)
let control
if (param.source && sourceEntry?.searchable) {
control = (
<SearchableOptions sourceId={param.source} label={sourceEntry.label || param.source}
disabled={disabled} onPick={add} />
)
} else if (param.source && answered && sourceEntry.options.length) {
control = (
<div style={{ marginTop: 4 }}>
<input className="input" style={{ fontSize: '0.75rem' }} disabled={disabled} value={term}
placeholder={`Filter ${sourceEntry.label || param.source}…`} onChange={(e) => setTerm(e.target.value)} />
<div role="group" aria-label={param.name}
style={{ maxHeight: 240, overflowY: 'auto', marginTop: 4, border: '1px solid rgba(255,255,255,0.08)', padding: '4px 8px' }}>
{shown.length === 0 && (
<div className="dim" style={{ fontSize: '0.72rem' }}>Nothing matches “{term}”.</div>
)}
{groups.map((g) => (
<div key={g || '—'}>
{g && <div className="field-label" style={{ fontSize: '0.7rem', margin: '6px 0 2px' }}>{g}</div>}
{shown.filter((o) => (o.group || '') === g).map((o) => (
<label key={o.value} style={{ display: 'flex', alignItems: 'baseline', gap: 6, fontSize: '0.76rem' }}>
<input type="checkbox" disabled={disabled}
checked={list.some((x) => String(x) === o.value)}
onChange={() => toggle(o.value)} />
<span>{o.label}</span>
{o.note && <span className="dim" style={{ fontSize: '0.7rem' }}>{o.note}</span>}
</label>
))}
</div>
))}
</div>
</div>
)
} else {
control = (
<>
{param.source && (
<ParamOptions entry={sourceEntry} label={sourceEntry?.label || param.source} disabled={disabled} onPick={add} />
)}
{tagBox}
</>
)
}
return (
<div style={{ marginBottom: 12 }}>
<span className="field-label">
{param.name} <span className="dim" style={{ fontWeight: 'normal' }}>· {list.length} of at most {max}</span>
</span>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 4 }}>
{list.length === 0 && <span className="sans dim" style={{ fontSize: '0.76rem' }}>None chosen yet.</span>}
{list.map((v) => (
<span key={String(v)} className="pill" style={{ fontSize: '0.74rem', display: 'inline-flex', gap: 6, alignItems: 'center' }}>
{byValue.get(String(v))?.label || String(v)}
<button type="button" aria-label={`Remove ${v}`} disabled={disabled}
style={{ background: 'none', border: 'none', color: 'inherit', cursor: 'pointer', padding: 0 }}
onClick={() => remove(v)}>
×
</button>
</span>
))}
</div>
{refused && (
<span className="sans" style={{ display: 'block', fontSize: '0.74rem', color: '#d98b84', marginTop: 4 }}>{refused}</span>
)}
{control}
{missing && (
<span className="sans" style={{ display: 'block', fontSize: '0.74rem', color: '#d98b84' }}>
This is required — choose at least one before saving.
</span>
)}
{param.description && (
<span className="sans dim" style={{ display: 'block', fontSize: '0.74rem' }}>{param.description}</span>
)}
{chosenNotes.map(({ v, note }) => (
<span key={String(v)} className="sans" style={{ display: 'block', fontSize: '0.76rem', color: '#d9c184', marginTop: 4 }}>
{byValue.get(String(v))?.label || String(v)}: {note}
</span>
))}
</div>
)
}
/**
* `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 (
<>
<div style={{ marginBottom: 12 }}>
<label style={{ display: 'block' }}>
<span className="field-label">What to borrow</span>
<select className="input" style={{ fontSize: '0.8rem' }} disabled={disabled} value={leaseId || ''}
onChange={(e) => onParam('lease', e.target.value)}>
<option value="">Pick a value to borrow…</option>
{owners.map((owner) => (
<optgroup key={owner} label={owner}>
{leases.filter((l) => (l.owner || 'other') === owner).map((l) => (
<option key={l.id} value={l.id}>{l.label}</option>
))}
</optgroup>
))}
{leaseId && !lease && <option value={leaseId}>{leaseId} (not installed)</option>}
</select>
</label>
{missing.includes('lease') && (
<span className="sans" style={{ display: 'block', fontSize: '0.74rem', color: '#d98b84' }}>
This is required — fill it in before saving.
</span>
)}
{lease?.description && (
<span className="sans dim" style={{ display: 'block', fontSize: '0.74rem' }}>{lease.description}</span>
)}
{leaseId && !lease && (
<span className="sans" style={{ display: 'block', fontSize: '0.74rem', color: '#d9c184' }}>
No installed module declares <code>{leaseId}</code>.
</span>
)}
</div>
{lease && (
<ParamField
param={{
name: 'value',
type: lease.type === 'bool' ? 'boolean' : lease.type,
required: true,
example: lease.example ?? (lease.type === 'int' || lease.type === 'float' ? lease.min : ''),
}}
label="Hold it at"
range={lease.type === 'int' || lease.type === 'float' ? { min: lease.min, max: lease.max } : null}
values={lease.values}
value={paramValue(step, 'value')}
disabled={disabled}
missing={missing.includes('value')}
onChange={(raw) => onParam('value', raw)}
/>
)}
{lease?.target && (
<ParamField
param={{
name: 'target',
type: 'string',
required: true,
example: lease.target.example ?? '',
source: lease.target.source,
description: lease.target.description,
}}
label={lease.target.label}
value={target}
sourceEntry={lease.target.source ? sources[lease.target.source] : null}
disabled={disabled}
missing={missing.includes('target')}
onChange={(raw) => onParam('target', raw)}
/>
)}
{lease && !lease.target && target !== undefined && target !== '' && (
<p className="sans" style={{ fontSize: '0.76rem', color: '#d9c184', margin: '0 0 12px' }}>
{lease.label} is a single value and takes no target, so the step will be refused with
<code> {String(target)}</code> in it.{' '}
<button type="button" className="pill" style={{ fontSize: '0.7rem' }} disabled={disabled}
onClick={() => onParam('target', '')}>
Clear it
</button>
</p>
)}
{declared.get('minutes') && (
<ParamField
param={declared.get('minutes')}
label="For how long"
range={lease ? { min: 1, max: Math.floor(lease.maxDurationMs / 60_000) } : null}
value={paramValue(step, 'minutes')}
disabled={disabled}
missing={missing.includes('minutes')}
onChange={(raw) => 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 (
<div className="panel-flat" style={{ padding: 14, marginBottom: 14, borderLeft: '3px solid var(--accent, #6d7f9c)' }}>
<h3 className="sans" style={{ margin: '0 0 6px', fontSize: '0.92rem' }}>Start from a template</h3>
<p className="sans dim" style={{ margin: '0 0 10px', fontSize: '0.78rem' }}>
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.
</p>
<select className="input" style={{ fontSize: '0.8rem', maxWidth: 360 }} value={chosenId}
onChange={(e) => choose(e.target.value)}>
<option value="">Start from nothing</option>
{templates.map((t) => <option key={t.id} value={t.id}>{t.label}</option>)}
</select>
{chosen && (
<div style={{ marginTop: 12 }}>
{chosen.description && (
<p className="sans dim" style={{ margin: '0 0 10px', fontSize: '0.8rem' }}>{chosen.description}</p>
)}
{chosen.inputs.map((p) => (
<ParamField
key={p.name}
param={p}
value={inputs[p.name]}
sourceEntry={p.source ? sources[p.source] : null}
disabled={busy}
onChange={(raw) => 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 && (
<ul className="sans" style={{ margin: '0 0 10px', paddingLeft: 18, fontSize: '0.8rem', color: '#d98b84' }}>
{errors.map((e) => <li key={e}>{e}</li>)}
</ul>
)}
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy} onClick={fill}>
Fill in the plan
</button>
</div>
)}
</div>
)
}
/**
* 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 (
<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' }}>
{emptyText || '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' ? 'north, south, east' : ''}
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, 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 (
<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>
{draftAhead && (
<div style={{ marginBottom: 12, borderLeft: '3px solid #d9c184', paddingLeft: 10 }}>
<p className="sans" style={{ margin: '0 0 6px', fontSize: '0.84rem' }}>
<strong>This definition has edits that are not published.</strong>{' '}
<span className="dim">A run uses a published version, never the working copy.</span>
</p>
{mayPublish && (
<label style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 4 }}>
<input type="radio" name="start-which" checked={which === 'publish'} onChange={() => setWhich('publish')} />
<span className="sans" style={{ fontSize: '0.84rem' }}>
Publish and start <span className="dim">— your edits become v{(publishedVersion || 0) + 1}, and scheduled occurrences move to it</span>
</span>
</label>
)}
<label style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input type="radio" name="start-which" checked={which === 'published'} onChange={() => setWhich('published')} />
<span className="sans" style={{ fontSize: '0.84rem' }}>
Start version {publishedVersion} <span className="dim">(the published one, without your edits)</span>
</span>
</label>
</div>
)}
<label style={{ display: 'flex', gap: 8, alignItems: 'flex-start', marginBottom: 10 }}>
<input type="checkbox" checked={rehearsal} onChange={(e) => setRehearsal(e.target.checked)} />
<span className="sans" style={{ fontSize: '0.84rem' }}>
<strong>Rehearsal.</strong>{' '}
<span className="dim">
A real run — every world change actually happens — with its announcements ceilinged to
staff, so no player is told. This is not the dry run.
</span>
</span>
</label>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit,minmax(220px,1fr))', gap: 12 }}>
<label>
<span className="field-label">Scope (optional)</span>
<input className="input" value={scope} onChange={(e) => setScope(e.target.value)} />
<span className="sans dim" style={{ fontSize: '0.74rem' }}>
Passed to the module verbatim. Core never parses it.
</span>
</label>
<label>
<span className="field-label">Run params (optional)</span>
<textarea className="input" rows={3} spellCheck={false}
style={{ fontFamily: 'var(--mono, monospace)', fontSize: '0.78rem' }}
value={paramsText} onChange={(e) => setParamsText(e.target.value)} />
<span className="sans dim" style={{ fontSize: '0.74rem' }}>
What the concurrency key’s <code>{'{placeholders}'}</code> are filled from.
</span>
</label>
</div>
{problem && (
<p className="sans" style={{ fontSize: '0.8rem', color: '#d98b84', margin: '10px 0 0' }}>{problem}</p>
)}
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy} onClick={go}>
Start it
</button>
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy} onClick={onCancel}>
Cancel
</button>
</div>
</div>
)
}
const DORMANT_NOTE =
'The module that registered this action is not installed. The step is kept exactly as authored — nothing was dropped — but the definition cannot be published until it is resolved.'
export default function EventEditor() {
const { id } = useParams()
const navigate = useNavigate()
const { user } = useAuth()
const isNew = id === 'new'
const [form, setForm] = useState(null)
const [event, setEvent] = useState(null)
const [catalog, setCatalog] = useState(null)
const [series, setSeries] = useState([])
const [versions, setVersions] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [problems, setProblems] = useState([])
const [notice, setNotice] = useState(null)
const [busy, setBusy] = useState(false)
// The dry run's answer (Phase 6). Cleared on every save and every publish,
// 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
// — the run console is their whole job and a definition is what a run is OF. But
// authoring is `admin` + `editor`, so Save has to follow the route it calls.
// Offering it and letting the server answer 403 is the shape §K calls "a gate
// nobody notices was missing", only inverted: a button that does nothing.
const mayAuthor = isAdmin || user?.role === 'editor'
const load = useCallback(async () => {
const [cat, ser] = await Promise.all([api.admin.eventCatalog(), api.admin.eventSeries()])
setCatalog(cat)
setSeries(ser.series || [])
if (isNew) {
setEvent(null)
setVersions([])
setForm(formFromDefinition({ spec: { schedule: { kind: 'manual' }, phases: [] } }))
return
}
const [{ event: loaded }, { versions: history }] = await Promise.all([
api.admin.getEvent(id),
api.admin.listEventVersions(id),
])
setEvent(loaded)
setVersions(history || [])
setForm(formFromDefinition(loaded))
}, [id, isNew])
useEffect(() => {
let alive = true
;(async () => {
setLoading(true)
try {
await load()
if (alive) setError(null)
} catch (err) {
if (alive) setError(err.message)
} finally {
if (alive) setLoading(false)
}
})()
return () => {
alive = false
}
}, [load])
const actions = useMemo(() => catalog?.actions || [], [catalog])
const actionById = useMemo(() => new Map(actions.map((a) => [a.id, a])), [actions])
// Phase 5. Served with the actions on the same route, so an EDITOR sees the
// same catalog an admin does — `/admin/engagement/triggers` is admin-only, and
// an editor writing a trigger id from memory into a field the save path then
// refuses is the failure this avoids.
const triggers = useMemo(() => catalog?.triggers || [], [catalog])
const triggerById = useMemo(() => new Map(triggers.map((t) => [t.id, t])), [triggers])
// Rust PLAN_REDESIGNS §2.1: `core.lease` is drawn as the chosen lease.
const leases = useMemo(() => catalog?.leases || [], [catalog])
const leaseById = useMemo(() => new Map(leases.map((l) => [l.id, l])), [leases])
// §2.5: a new event may start from a module's template.
const templates = useMemo(() => catalog?.templates || [], [catalog])
// §2.1: a required field says it is empty only once a save has been tried —
// a form that opens covered in red reads as broken before anyone has typed.
const [checkMissing, setCheckMissing] = useState(false)
// Phase 7. source id -> { state: 'loading' | 'ok' | 'failed', options, reason }.
//
// Resolved LAZILY, one request per source, and only for the sources the steps
// on this page actually name. A definition uses two or three of them; a
// deployment with a game module installed may register a dozen, and asking a
// shard for eight hundred creature names to draw a form that needs none of
// them is a page that opens slowly for no one's benefit.
const [sources, setSources] = useState({})
// **The guard is a ref, and it has to be.** `setSources` QUEUES its updater
// rather than running it, so a "have I already asked for this?" check written
// inside the updater cannot be read on the next line — it has not run yet. The
// first draft did exactly that and the field sat on *Reading the list…* for
// ever, having never made the request at all: state is the wrong tool for a
// question that must be answered synchronously, at the call.
const requested = useRef(new Set())
const loadSource = useCallback(async (sourceId) => {
if (requested.current.has(sourceId)) return
requested.current.add(sourceId)
setSources((s) => ({ ...s, [sourceId]: { state: 'loading', options: [] } }))
try {
const answer = await api.admin.eventOptions(sourceId)
setSources((s) => ({
...s,
[sourceId]: answer?.ok
// `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) {
// The route answers a refusal with a 200, so reaching here means the
// REQUEST failed rather than the source — and the field's behaviour is the
// same either way: it degrades to free text and says why.
setSources((s) => ({
...s,
[sourceId]: { state: 'failed', options: [], reason: err.message || 'this list could not be read' },
}))
}
}, [])
// Phase 7. Every option source the steps on this page name, resolved once.
//
// An effect rather than a lookup at render time, because resolving one is a
// request and a request started during render is a render with a side effect.
// `sources` is deliberately NOT a dependency: `loadSource` keeps its own ref of
// what it has already asked for, so re-running this on every answer would be a
// pass over the same set, changing nothing.
useEffect(() => {
const wanted = new Set()
for (const phase of form?.phases || []) {
for (const step of phase.steps || []) {
for (const p of actionById.get(step.actionId)?.params || []) {
if (p.source) wanted.add(p.source)
}
// A lease step's target list is the chosen LEASE's (F11), which only
// becomes knowable once a lease is picked.
if (step.actionId === 'core.lease') {
const source = leaseById.get(paramValue(step, 'lease'))?.target?.source
if (source) wanted.add(source)
}
}
}
for (const id of wanted) loadSource(id)
}, [form, actionById, leaseById, 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) =>
setForm((f) => ({
...f,
phases: f.phases.map((p, i) => (i === pi ? { ...p, ...patch } : p)),
}))
const movePhase = (pi, delta) =>
setForm((f) => {
const next = [...f.phases]
const to = pi + delta
if (to < 0 || to >= next.length) return f
;[next[pi], next[to]] = [next[to], next[pi]]
return { ...f, phases: next }
})
const moveStep = (pi, si, delta) =>
setForm((f) => ({
...f,
phases: f.phases.map((p, i) => {
if (i !== pi) return p
const steps = [...p.steps]
const to = si + delta
if (to < 0 || to >= steps.length) return p
;[steps[si], steps[to]] = [steps[to], steps[si]]
return { ...p, steps }
}),
}))
/**
* Changing a step's action REPLACES its params with the new action's examples.
*
* The alternative — keeping what was typed — leaves an object whose keys belong
* to a different action, and the save refuses it with "x is not a param of y"
* for every one of them. Replacing is the honest move and it is not
* destructive in any way an author minds: they have just said this step does
* something else.
*/
const changeAction = (pi, si, actionId) => {
const action = actionById.get(actionId)
const fresh = blankStep(action)
setStep(pi, si, { actionId, label: fresh.label, paramsText: fresh.paramsText, onFailure: '' }, { timed: true })
}
/**
* Write one step and, when the change could make it a timed step, keep its
* phase open long enough for it (Rust PLAN_REDESIGNS §2.2, U-6).
*
* Teardown gives a held thing back when the run ends, so a lease "for 10
* minutes" in a one-phase event with no advance rule lasted a second. Only a
* phase with NO rule is given one — `after: <the longest hold>`, on the screen,
* for the author to change; a rule the author chose is left alone and the
* sentence under the phase says what it costs. Only changes that can create or
* lengthen a hold ask (`timed`), so an author who removed the rule on purpose
* and then edits a message is not overruled.
*/
const setStep = (pi, si, patch, { timed = false } = {}) =>
setForm((f) => {
const phases = f.phases.map((p, i) =>
i === pi ? { ...p, steps: p.steps.map((s, j) => (j === si ? { ...s, ...patch } : s)) } : p,
)
if (!timed) return { ...f, phases }
const advance = guardedAdvance(phases, pi, actionById)
return { ...f, phases: advance ? phases.map((p, i) => (i === pi ? { ...p, advance } : p)) : phases }
})
const addStep = (pi) =>
setForm((f) => {
const phases = f.phases.map((p, i) => (i === pi ? { ...p, steps: [...p.steps, blankStep(actions[0])] } : p))
const advance = guardedAdvance(phases, pi, actionById)
return { ...f, phases: advance ? phases.map((p, i) => (i === pi ? { ...p, advance } : p)) : phases }
})
/** One param of one step, from a form control. */
const setStepParam = (pi, si, step, param, raw) =>
setStep(pi, si, { paramsText: setParam(step, param.name, raw, param.type) }, { timed: Boolean(param.duration) })
/** Every required field the plan leaves empty, as the sentences the save shows. */
const missingSentences = () => {
const out = []
form.phases.forEach((phase, pi) => phase.steps.forEach((step, si) => {
for (const name of missingParams(step, actionById.get(step.actionId), leaseById)) {
out.push(`Phase ${pi + 1} "${phase.label || phase.key}", step ${si + 1}: ${name} is required`)
}
}))
return out
}
const save = async () => {
setProblems([])
setNotice(null)
// §2.1: a required field left empty is named under the field AND in the
// list, here, before the round trip the server would refuse it on anyway.
setCheckMissing(true)
const missing = missingSentences()
if (missing.length) {
setProblems(missing)
return
}
setBusy(true)
// 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)
// 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)
return
}
try {
if (isNew) {
const result = await api.admin.createEvent(built.payload)
navigate(`/admin/events/${result.event.id}`, { replace: true })
} else {
const result = await api.admin.updateEvent(id, built.payload)
setEvent(result.event)
setForm(formFromDefinition(result.event))
setNotice('Saved.')
}
} catch (err) {
// The server answers with every problem rather than the first, so an author
// fixing a spec does it in one pass rather than six round trips.
setProblems(err.body?.errors || [err.message])
} finally {
setBusy(false)
}
}
const publish = async () => {
setBusy(true)
setProblems([])
setNotice(null)
setReport(null)
try {
const result = await api.admin.publishEvent(id)
setEvent(result.event)
setVersions(await api.admin.listEventVersions(id).then((r) => r.versions || []))
// The re-pin count is said out loud, because an editor who does not know
// their fix reached next Friday finds out on Friday. So is a dry run that
// carried over from the draft (W5): it is why no second one is asked for.
setNotice(
[
`Published as v${result.version}.`,
result.verified ? 'The dry run that passed on this draft counts for it.' : '',
result.repinned
? `${result.repinned} scheduled occurrence${result.repinned === 1 ? '' : 's'} moved to it.`
: '',
].filter(Boolean).join(' '),
)
} catch (err) {
setProblems(err.body?.errors || [err.message])
} finally {
setBusy(false)
}
}
/**
* The dry run (Phase 6).
*
* `admin, editor` — it dispatches nothing. What it verifies follows the
* definition's state, and the server says which: a `ready` definition is
* checked against its PUBLISHED version, because that is the only thing that
* ever actually runs and it is that pass §K's gate is about; a draft is checked
* against the working spec the author is still holding.
*
* Findings arrive with a 200 — the request succeeded, the plan has problems —
* so they are rendered rather than thrown into the error box.
*/
const verify = async () => {
setBusy(true)
setProblems([])
setNotice(null)
setReport(null)
try {
const result = await api.admin.verifyEvent(id)
setReport(result)
if (result.recorded) setEvent(await api.admin.getEvent(id).then((r) => r.event))
} catch (err) {
setProblems(err.body?.errors || [err.message])
} finally {
setBusy(false)
}
}
/**
* 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, { publishFirst = false } = {}) => {
setBusy(true)
setProblems([])
try {
// §2.3: "Publish and start" is the two calls an admin would otherwise
// make by hand, in order, and a publish the server refuses stops here
// rather than starting the old version behind the author's back.
if (publishFirst) {
const published = await api.admin.publishEvent(id)
setEvent(published.event)
}
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)
}
}
// **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'
return (
<section>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 16, flexWrap: 'wrap', marginBottom: 14 }}>
<div>
<h2 className="sans" style={{ margin: 0, fontSize: '1.05rem' }}>
{isNew ? 'New event' : event?.title}
</h2>
<p className="sans dim" style={{ margin: '4px 0 0', fontSize: '0.8rem' }}>
{isNew ? (
'The slug is derived from the title once and frozen afterwards — the public event page lives at it.'
) : (
<>
{event?.slug} · {event?.state}
{event?.currentVersion ? ` · published v${event.currentVersion}` : ' · never published'}
</>
)}
</p>
</div>
<div style={{ display: 'flex', gap: 8 }}>
{mayAuthor && (
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy || archived} onClick={save}>
{isNew ? 'Create draft' : 'Save'}
</button>
)}
{/* The dry run is admin+editor, deliberately wider than publish: an
author should be able to find out what their event would cost
before asking an admin to commit the deployment to it. */}
{!isNew && mayAuthor && (
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy || archived} onClick={verify}>
Dry run
</button>
)}
{/* Publish and start are admin ONLY (§N2) and not the same gate as the
live controls: publishing commits a definition a schedule will later
start unattended. */}
{!isNew && isAdmin && (
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy || archived} onClick={publish}>
Publish
</button>
)}
{!isNew && isAdmin && event?.state === 'ready' && (
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy}
onClick={() => setStarting((s) => !s)}>
Start now
</button>
)}
</div>
</div>
{!mayAuthor && (
<p className="sans dim" style={{ fontSize: '0.82rem' }}>
You can read this definition and watch its runs. Editing and publishing an event are an
admin or editor's, and starting one is an admin's alone — live control of a run already in
flight is yours.
</p>
)}
{archived && (
<p className="sans" style={{ fontSize: '0.84rem', color: '#d9c184' }}>
This definition is archived. It is kept so its past runs can still be explained, and it
cannot be edited or run again.
</p>
)}
{/* ── §K's gate, said where it can still be acted on ──
A published version that nobody has dry-run will not start on its
schedule. The alternative to saying so here is an operator finding out
on the Friday it did not run, so it is a banner rather than a log line —
and only for a definition that actually HAS a schedule to be held. */}
{!isNew && event?.state === 'ready' && !event?.currentVersionVerifiedAt && !archived && (
<div className="panel-flat" style={{ padding: '10px 14px', marginBottom: 14, borderLeft: '3px solid #d9c184' }}>
<p className="sans" style={{ margin: 0, fontSize: '0.84rem' }}>
<strong>This version has not been dry-run.</strong> Scheduled occurrences are held until it
is — an event that starts while nobody is watching gets one review, and this is it.
Starting it by hand is unaffected.
</p>
</div>
)}
{report && (
<div
className="panel-flat"
style={{
padding: '10px 14px',
marginBottom: 14,
borderLeft: `3px solid ${report.report.ok ? '#8fc79a' : '#d98b84'}`,
}}
>
<p className="sans" style={{ margin: '0 0 6px', fontSize: '0.84rem' }}>
<strong>
{report.report.ok ? 'Dry run passed' : 'Dry run found problems'}
</strong>{' '}
<span className="dim">
· {report.report.steps} step{report.report.steps === 1 ? '' : 's'} checked against{' '}
{/* Which spec was checked. The two answer different questions, and a
report that did not say would be read as the other one. */}
{report.target === 'version' ? `published v${report.version}` : 'the working draft'}
{report.recorded && ' · recorded, so scheduled occurrences may now start'}
{report.draftRecorded && ' · kept, so publishing this draft unchanged makes a verified version'}
</span>
</p>
{report.report.findings.length > 0 && (
<ul className="sans" style={{ margin: '0 0 6px', paddingLeft: 18, fontSize: '0.82rem' }}>
{report.report.findings.map((f, i) => (
<li key={`${f.phase}-${f.seq}-${f.code}-${i}`} style={{ color: f.level === 'warning' ? '#d9c184' : undefined }}>
{f.phase !== null && (
<code className="dim" style={{ fontSize: '0.78rem' }}>
{f.phase} · step {f.seq + 1}
{f.actionId ? ` · ${f.actionId}` : ''}
</code>
)}
{f.phase !== null && ' — '}
{f.message}
</li>
))}
</ul>
)}
{/* The whole-plan cost, which is the finding no other path can make: a
step that fits on its own and does not fit alongside its siblings. */}
{report.report.cost.length > 0 && (
<table className="sans" style={{ fontSize: '0.8rem', borderCollapse: 'collapse' }}>
<tbody>
{report.report.cost.map((c) => (
<tr key={c.dimension} style={{ color: c.over ? '#d98b84' : undefined }}>
<td style={{ paddingRight: 12 }}><code style={{ fontSize: '0.78rem' }}>{c.dimension}</code></td>
<td style={{ paddingRight: 12 }}>{c.total}</td>
<td className="dim">
{c.cap === null ? 'no cap' : `of ${c.cap} per run${c.from ? ` (${c.from})` : ''}`}
</td>
</tr>
))}
</tbody>
</table>
)}
{report.report.findings.length === 0 && report.report.cost.length === 0 && (
<p className="sans dim" style={{ margin: 0, fontSize: '0.82rem' }}>
Nothing this event does costs a capped resource.
</p>
)}
</div>
)}
{starting && (
<StartDialog busy={busy} onCancel={() => setStarting(false)} onStart={start}
draftAhead={Boolean(event?.draftAhead)} publishedVersion={event?.currentVersion} mayPublish={isAdmin} />
)}
{notice && <p className="sans" style={{ fontSize: '0.84rem', color: '#8fc79a' }}>{notice}</p>}
{problems.length > 0 && (
<div className="panel-flat" style={{ padding: '10px 14px', marginBottom: 14, borderLeft: '3px solid #d98b84' }}>
<p className="sans" style={{ margin: '0 0 6px', fontSize: '0.84rem' }}>That did not save:</p>
<ul className="sans" style={{ margin: 0, paddingLeft: 18, fontSize: '0.82rem' }}>
{problems.map((p) => <li key={p}>{p}</li>)}
</ul>
</div>
)}
{isNew && mayAuthor && templates.length > 0 && (
<TemplatePicker
templates={templates}
sources={sources}
loadSource={loadSource}
onDraft={(draft, template) => {
setForm(formFromDefinition({ title: draft.title, summary: draft.summary, spec: draft.spec }))
setProblems([])
setNotice(`Filled in from "${template.label}". Nothing is saved until you create the draft.`)
}}
/>
)}
{/* ── Basics ── */}
<div className="panel-flat" style={{ padding: 14, marginBottom: 14 }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit,minmax(220px,1fr))', gap: 12 }}>
<label>
<span className="field-label">Title</span>
<input className="input" value={form.title} onChange={(e) => set({ title: e.target.value })} />
</label>
<label>
<span className="field-label">Series</span>
<select className="select" value={form.seriesId} onChange={(e) => set({ seriesId: e.target.value })}>
<option value="">Not part of a series</option>
{series.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
</label>
<label>
<span className="field-label">Timezone</span>
<input className="input" value={form.timezone} onChange={(e) => set({ timezone: e.target.value })} />
</label>
<label>
<span className="field-label">Grace window (seconds)</span>
<input className="input" type="number" min="0" value={form.graceSeconds}
onChange={(e) => set({ graceSeconds: e.target.value })} />
</label>
<label>
<span className="field-label">Concurrency key</span>
<input className="input" value={form.concurrencyKey} placeholder="invasion:{region}"
onChange={(e) => set({ concurrencyKey: e.target.value })} />
</label>
</div>
<label style={{ display: 'block', marginTop: 12 }}>
<span className="field-label">Summary</span>
<input className="input" value={form.summary} onChange={(e) => set({ summary: e.target.value })} />
</label>
<label style={{ display: 'block', marginTop: 12 }}>
<span className="field-label">Storyline</span>
<textarea className="input" rows={4} value={form.body} onChange={(e) => set({ body: e.target.value })} />
</label>
{/* Announcement, not permission. Publishing is what makes an event
RUNNABLE, so without this switch a surprise invasion would have to be
advertised a fortnight in advance in order to be allowed to happen. */}
<label style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 14 }}>
<input
type="checkbox"
checked={Boolean(form.listed)}
onChange={(e) => set({ listed: e.target.checked })}
/>
<span className="field-label" style={{ margin: 0 }}>Show on the public calendar</span>
</label>
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '10px 0 0' }}>
The grace window is how late this event may still start: past it an occurrence becomes
<em> missed</em> rather than beginning hours after it was announced. Two runs sharing a
concurrency key never overlap — <code>{'{placeholders}'}</code> are filled from the run&rsquo;s own
params. An event that is <em>not</em> shown publicly still schedules, still runs and is
still on this calendar — it is simply not announced, and its lifecycle mails carry no
link because there is no page to link to.
</p>
</div>
{/* ── Schedule ── */}
{/*
Four closed shapes rendered as a form, never a cron string. A cron
expression is the one field an operator cannot proofread, and the whole
point of the closed set is that this panel can be read back in English —
which is what the preview line under it does.
*/}
<div className="panel-flat" style={{ padding: 14, marginBottom: 14 }}>
<h3 className="sans" style={{ margin: '0 0 10px', fontSize: '0.92rem' }}>Schedule</h3>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit,minmax(180px,1fr))', gap: 12 }}>
<label>
<span className="field-label">Repeats</span>
<select className="select" value={form.scheduleKind} disabled={archived}
onChange={(e) => set({ scheduleKind: e.target.value })}>
{SCHEDULE_KINDS.map((k) => <option key={k.value} value={k.value}>{k.label}</option>)}
</select>
</label>
{form.scheduleKind === 'once' && (
<label>
<span className="field-label">Date and time</span>
<input className="input" type="datetime-local" value={form.scheduleAt} disabled={archived}
onChange={(e) => set({ scheduleAt: e.target.value.slice(0, 16) })} />
</label>
)}
{form.scheduleKind === 'monthly' && (
<>
<label>
<span className="field-label">Week</span>
<select className="select" value={form.scheduleNth} disabled={archived}
onChange={(e) => set({ scheduleNth: e.target.value })}>
{MONTHLY_NTHS.map((n) => <option key={n.value} value={n.value}>{n.label}</option>)}
</select>
</label>
<label>
<span className="field-label">Weekday</span>
<select className="select" value={form.scheduleWeekday} disabled={archived}
onChange={(e) => set({ scheduleWeekday: e.target.value })}>
{WEEKDAYS.map((d) => (
<option key={d} value={d}>{d.charAt(0).toUpperCase() + d.slice(1)}</option>
))}
</select>
</label>
</>
)}
{(form.scheduleKind === 'weekly' || form.scheduleKind === 'monthly') && (
<label>
<span className="field-label">Time</span>
<input className="input" type="time" value={form.scheduleTime} disabled={archived}
onChange={(e) => set({ scheduleTime: e.target.value })} />
</label>
)}
</div>
{form.scheduleKind === 'weekly' && (
<div style={{ marginTop: 12 }}>
<span className="field-label">Days</span>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 6 }}>
{WEEKDAYS.map((day) => {
const on = (form.scheduleDays || []).includes(day)
return (
<button key={day} type="button" className="pill" disabled={archived}
aria-pressed={on}
style={{ fontSize: '0.72rem', opacity: on ? 1 : 0.45 }}
onClick={() => set({
scheduleDays: on
? form.scheduleDays.filter((d) => d !== day)
: WEEKDAYS.filter((d) => d === day || form.scheduleDays.includes(d)),
})}>
{day.charAt(0).toUpperCase() + day.slice(1, 3)}
</button>
)
})}
</div>
</div>
)}
<p className="sans" style={{ margin: '12px 0 0', fontSize: '0.84rem' }}>
{describeSchedule(scheduleFromForm(form), form.timezone)}
</p>
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '8px 0 0' }}>
Times are the event&rsquo;s own, in <code>{form.timezone}</code> — not the reader&rsquo;s. A
recurring schedule goes live when the definition is published and stops when it is
archived; occurrences become real runs a fortnight before they happen, and the
calendar forecasts the rest. A time that daylight saving skips moves forward to the next
one that exists, and an hour that happens twice takes the first.
</p>
</div>
{/* ── Started by the game (Rust PLAN_REDESIGNS §14.3.12, D370) ──
A start is a phase gate's trigger and condition plus a cooldown, so it
is drawn with the gate's own builder. Beside the schedule rather than
inside it: a definition may have either, or both. */}
<div className="panel-flat" style={{ padding: 14, marginBottom: 14 }}>
<h3 className="sans" style={{ margin: '0 0 6px', fontSize: '0.92rem' }}>Started by the game</h3>
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 10px' }}>
Start a run when something happens in the game — <em>when a boss appears on a chosen server</em>.
One run at a time: a firing while a run is going starts nothing, and nor does one inside the
cooldown after the last run a trigger started. Like a schedule, it only starts a published
version that has passed its dry run.
</p>
{(form.starts || []).map((start, si) => {
const setStart = (patch) =>
set({ starts: form.starts.map((s, i) => (i === si ? { ...s, ...patch } : s)) })
return (
<div key={si} style={{ borderTop: si ? '1px solid var(--rule, #2a2f3a)' : 'none', paddingTop: si ? 10 : 0, marginTop: si ? 10 : 0 }}>
<div style={{ display: 'flex', gap: 10, alignItems: 'flex-end', flexWrap: 'wrap' }}>
<label style={{ flex: '1 1 260px' }}>
<span className="field-label">When</span>
{/* Changing the trigger clears the condition, as a gate's does. */}
<select className="input" value={start.on} disabled={archived}
onChange={(e) => setStart({ on: e.target.value, dormant: false, ...blankWhere() })}>
<option value="">Choose a trigger…</option>
{start.on && !triggerById.has(start.on) && (
<option value={start.on}>{start.on} — no module registers it</option>
)}
{triggers.filter(isStartableTrigger).map((t) => (
<option key={t.id} value={t.id}>{t.label} — {t.id}</option>
))}
</select>
</label>
<label style={{ flex: '0 1 140px' }}>
<span className="field-label">Cooldown</span>
<input className="input" value={start.cooldown} disabled={archived} placeholder="30m"
onChange={(e) => setStart({ cooldown: e.target.value })} />
</label>
<button type="button" className="pill" style={{ fontSize: '0.72rem' }} disabled={archived}
onClick={() => set({ starts: form.starts.filter((_, i) => i !== si) })}>
Remove
</button>
</div>
{start.on && !triggerById.has(start.on) && (
<p className="sans" style={{ fontSize: '0.76rem', color: '#d9c184', margin: '6px 0 0' }}>
No installed module registers <code>{start.on}</code>. The start is kept, and the
definition cannot be published until the module is back or the start is removed.
</p>
)}
{start.on && (
<WhereBuilder
advance={start}
trigger={triggerById.get(start.on)}
operators={catalog?.operators || []}
disabled={archived}
emptyText="Every firing of this trigger starts a run. Add a clause to narrow it — only firings that match then start one."
onChange={(patch) => setStart(patch)}
/>
)}
</div>
)
})}
<button type="button" className="pill" style={{ fontSize: '0.74rem', marginTop: 10 }}
disabled={archived || (form.starts || []).length >= 10}
onClick={() => set({ starts: [...(form.starts || []), blankStart()] })}>
Add a trigger
</button>
<p className="sans dim" style={{ fontSize: '0.76rem', margin: '8px 0 0' }}>
A cooldown is a whole number of <code>s</code>, <code>m</code>, <code>h</code> or <code>d</code>,
from <code>0m</code> to <code>7d</code>. Zero leaves one run at a time as the only limit.
</p>
{describeTriggerStarts(event?.triggerStarts) && (
<p className="sans" style={{ fontSize: '0.8rem', margin: '8px 0 0' }}>
{describeTriggerStarts(event.triggerStarts)}
</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>
<button type="button" className="pill" style={{ fontSize: '0.72rem' }} disabled={archived}
onClick={() => set({ phases: [...form.phases, blankPhase(form.phases)] })}>
Add phase
</button>
</div>
{form.phases.length === 0 && (
<p className="sans dim" style={{ fontSize: '0.85rem' }}>
No phases yet. An event needs at least one phase with at least one step before it can be
published.
</p>
)}
{form.phases.map((phase, pi) => (
<div key={pi} className="panel-flat" style={{ padding: 14, marginBottom: 12, borderLeft: '3px solid var(--accent, #6d7f9c)' }}>
<div style={{ display: 'flex', gap: 10, alignItems: 'flex-end', flexWrap: 'wrap' }}>
<label style={{ flex: '1 1 200px' }}>
<span className="field-label">Phase {pi + 1} — label</span>
<input className="input" value={phase.label} onChange={(e) => setPhase(pi, { label: e.target.value })} />
</label>
<label style={{ flex: '0 1 180px' }}>
<span className="field-label">Key</span>
<input className="input" value={phase.key} onChange={(e) => setPhase(pi, { key: e.target.value })} />
</label>
<div style={{ display: 'flex', gap: 6 }}>
<button type="button" className="pill" style={{ fontSize: '0.72rem' }} disabled={pi === 0} onClick={() => movePhase(pi, -1)}>↑</button>
<button type="button" className="pill" style={{ fontSize: '0.72rem' }} disabled={pi === form.phases.length - 1} onClick={() => movePhase(pi, 1)}>↓</button>
<button type="button" className="pill" style={{ fontSize: '0.72rem' }}
onClick={() => set({ phases: form.phases.filter((_, i) => i !== pi) })}>Remove</button>
</div>
</div>
<p className="sans dim" style={{ fontSize: '0.76rem', margin: '8px 0 0' }}>
The key is what the run console groups by and what &ldquo;Phase p3: steps done, waiting to advance&rdquo; names, so it
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
is not advanced by a boss that spawned early. */}
<div style={{ marginTop: 12, borderTop: '1px solid var(--rule, #2a2f3a)', paddingTop: 12 }}>
<div style={{ display: 'flex', gap: 10, alignItems: 'flex-end', flexWrap: 'wrap' }}>
<label style={{ flex: '1 1 240px' }}>
<span className="field-label">This phase advances</span>
<select className="input" value={phase.advance?.kind || ''}
onChange={(e) => setPhase(pi, { advance: { ...(phase.advance || blankAdvance()), kind: e.target.value } })}>
{ADVANCE_KINDS.map((k) => <option key={k.value} value={k.value}>{k.label}</option>)}
</select>
</label>
{phase.advance?.kind === 'after' && (
<label style={{ flex: '0 1 160px' }}>
<span className="field-label">Delay</span>
<input className="input" value={phase.advance.after}
onChange={(e) => setPhase(pi, { advance: { ...phase.advance, after: e.target.value } })}
placeholder="30m" />
</label>
)}
{phase.advance?.kind === 'on' && (
<>
<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, ...blankWhere() },
})}>
<option value="">Choose a trigger…</option>
{triggers.map((t) => <option key={t.id} value={t.id}>{t.label} — {t.id}</option>)}
</select>
</label>
<label style={{ flex: '0 1 110px' }}>
<span className="field-label">How many</span>
<input className="input" type="number" min="1" value={phase.advance.count}
onChange={(e) => setPhase(pi, { advance: { ...phase.advance, count: e.target.value } })} />
</label>
</>
)}
</div>
{phase.advance?.kind === 'on' && (
<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' }}>
{phase.advance?.kind
? 'This is in ADDITION to its steps: the phase waits until every step has finished AND this is met. Nothing times out — if the condition never happens, the run is marked stalled and a person advances it from the run console.'
: 'The phase advances the moment every one of its steps is finished.'}
</p>
{/* §2.2 (U-6). A held value is given back when the RUN ends, so a
plan that can end sooner than its longest hold is said out loud
here, where the rule that decides it is. */}
{shortfallSentence(form.phases, pi, actionById) && (
<p className="sans" style={{ fontSize: '0.78rem', color: '#d9c184', margin: '6px 0 0' }}>
{shortfallSentence(form.phases, pi, actionById)}
</p>
)}
</div>
<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' }}>
<label style={{ flex: '1 1 220px' }}>
<span className="field-label">Step {si + 1} — action</span>
<select className="select" value={step.actionId} onChange={(e) => changeAction(pi, si, e.target.value)}>
<option value="">Pick an action…</option>
{actions.map((a) => <option key={a.id} value={a.id}>{a.label} — {a.id}</option>)}
{/* A step whose module has been uninstalled keeps its
action id, so the select must be able to show a value
that is not in the catalog rather than silently
resetting the step to nothing. */}
{step.actionId && !action && <option value={step.actionId}>{step.actionId} (not installed)</option>}
</select>
</label>
<label style={{ flex: '0 1 200px' }}>
<span className="field-label">If it fails</span>
<select className="select" value={step.onFailure} onChange={(e) => setStep(pi, si, { onFailure: e.target.value })}>
<option value="">
Default{action ? ` — ${catalog?.onFailureByRisk?.[action.risk] || 'pause'}` : ''}
</option>
{(catalog?.onFailure || []).map((f) => <option key={f} value={f}>{f}</option>)}
</select>
</label>
<div style={{ display: 'flex', gap: 6 }}>
<button type="button" className="pill" style={{ fontSize: '0.72rem' }} disabled={si === 0} onClick={() => moveStep(pi, si, -1)}>↑</button>
<button type="button" className="pill" style={{ fontSize: '0.72rem' }} disabled={si === phase.steps.length - 1} onClick={() => moveStep(pi, si, 1)}>↓</button>
<button type="button" className="pill" style={{ fontSize: '0.72rem' }}
onClick={() => setPhase(pi, { steps: phase.steps.filter((_, j) => j !== si) })}>Remove</button>
</div>
</div>
{step.dormant && (
<p className="sans" style={{ fontSize: '0.78rem', color: '#d9c184', margin: '8px 0 0' }}>{DORMANT_NOTE}</p>
)}
{action && (
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '8px 0 0' }}>
{action.description} <span style={{ opacity: 0.75 }}>· risk: {action.risk} · reversible: {action.reversible}</span>
</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?.id === 'core.lease' ? (
<LeaseFields
action={action}
step={step}
leases={leases}
sources={sources}
disabled={archived}
missing={checkMissing ? missingParams(step, action, leaseById) : []}
onParam={(name, raw) => {
const declared = action.params.find((p) => p.name === name)
// A new lease changes which hold this is, so it is
// checked like a new duration.
setStep(pi, si, { paramsText: setParam(step, name, raw, declared.type) },
{ timed: name === 'lease' || Boolean(declared.duration) })
}}
/>
) : (action?.params || []).map((p) => (p.multiple ? (
<MultiParamField
key={p.name}
param={p}
value={paramValue(step, p.name)}
sourceEntry={p.source ? sources[p.source] : null}
disabled={archived}
missing={checkMissing && missingParams(step, action, leaseById).includes(p.name)}
onChange={(list) => setStep(pi, si, { paramsText: setParamList(step, p.name, list, p.type) })}
/>
) : (
<ParamField
key={p.name}
param={p}
value={paramValue(step, p.name)}
sourceEntry={p.source ? sources[p.source] : null}
disabled={archived}
missing={checkMissing && missingParams(step, action, leaseById).includes(p.name)}
onChange={(raw) => setStepParam(pi, si, step, p, raw)}
/>
)))}
{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.multiple ? `a list of ${p.type}, at most ${p.max}` : p.type}${p.required ? ', required' : ''})`).join(', ')}.
</span>
)}
</>
)}
</div>
</div>
)
})}
<button type="button" className="pill" style={{ fontSize: '0.72rem', marginTop: 12 }} disabled={archived}
onClick={() => addStep(pi)}>
Add step
</button>
</div>
</div>
))}
{!isNew && versions.length > 0 && (
<div className="panel-flat" style={{ padding: 14, marginTop: 14 }}>
<h3 className="sans" style={{ margin: '0 0 6px', fontSize: '0.92rem' }}>Versions</h3>
<p className="sans dim" style={{ margin: '0 0 8px', fontSize: '0.8rem' }}>
Publishing snapshots the whole spec into a version nothing ever edits. Editing this
definition while a run is live is free — <strong>the live run keeps the version it
pinned</strong> and is unaffected by anything on this screen.
</p>
<table className="adm-table">
<tbody>
{versions.map((v) => (
<tr key={v.id}>
<td className="adm-td" style={{ fontSize: '0.82rem' }}>v{v.version}{v.current && <span className="dim"> · current</span>}</td>
<td className="adm-td dim" style={{ fontSize: '0.8rem' }}>{new Date(v.publishedAt).toLocaleString()}</td>
<td className="adm-td dim" style={{ fontSize: '0.8rem' }}>{v.publishedByUsername || '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
)
}