import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { useAuth } from '../../../contexts/AuthContext.jsx'
import { api } from '../../../api/client.js'
import {
formFromDefinition,
payloadFromForm,
blankPhase,
blankAdvance,
blankWhere,
whereFormFrom,
ADVANCE_KINDS,
blankStep,
describeSchedule,
scheduleFromForm,
SCHEDULE_KINDS,
MONTHLY_NTHS,
WEEKDAYS,
PARAM_FORM,
PARAM_JSON,
paramsMode,
paramValue,
setParam,
setParamList,
listValue,
addToList,
filterOptions,
datetimeInputValue,
priceBodyFrom,
worthPricing,
boolValue,
isSegmentable,
noteFor,
missingParams,
guardedAdvance,
shortfallSentence,
blankStart,
isStartableTrigger,
describeTriggerStarts,
} from '../../../lib/eventAuthoring.js'
import { operatorsForType, coerceLiteral } from '../../../lib/engagementRules.js'
// Admin → Events → the definition editor (EVENTS.md §I, Phase 3).
//
// **A vertical timeline, not a node graph**, and that is a decision about what
// the engine can actually do rather than a matter of taste. The condition
// grammar has no branching — it is `and`/`or`/`not` over comparisons, bounded at
// depth five — so a canvas would promise power this project has never handed an
// operator. Phases in order, each with its steps in order, says exactly what the
// runner does with them.
//
// **Core renders no game word here.** Every label on a step comes from the
// action's own registration — its `label`, its params' names, their descriptions
// and their examples — so an installed module's vocabulary appears without core
// knowing any of it, and `check:modules` already fails core's build on a UO
// identifier.
//
// **The params are a FORM as of Phase 13**, one control per declared param,
// rendered from a schema core does not understand — §I's *"the condition
// builder, exactly"*. The JSON box did not go away: it is the escape hatch, and
// a step opens in it automatically when the form could not hold what the step
// carries. That rule is the condition builder's own, ported rather than
// reinvented — dropping a param the action does not declare and flattening
// `A and (B or C)` are the same mistake, a save that looks clean and means
// something else.
//
// **The meter beside the timeline is not a lighter dry run.** `POST
// /admin/events/price` dispatches nothing, so it knows nothing a module knows —
// whether the landmark exists, whether the shard is up. It answers the half core
// can answer alone, which is what a plan would SPEND, and it can therefore run on
// a debounce while somebody types. The dry run stays the thing that asks the
// modules, and the screen labels them apart.
/**
* The values behind one param's `source` (§F *Param option sources*, Phase 7).
*
* **A refusal renders as a warning and leaves the field usable**, which is the
* contract rather than a nicety: a source is answered by a module that may be
* talking to a sidecar, and an authoring form a shard outage can make unusable
* would be a worse failure than the typo the dropdown exists to prevent. The
* operator very often knows the value they want to type.
*
* The picker WRITES INTO THE JSON box rather than replacing it, because the box
* is still the field until the schema-driven form arrives — so this is the one
* affordance that can exist today and be right afterwards: the values come from
* the module, and the exact spelling is never typed by hand. When the JSON does
* not parse the picker says so rather than silently doing nothing, because
* "clicked and nothing happened" is the one behaviour a form must never have.
*/
function ParamOptions({ entry, label, disabled, onPick }) {
if (!entry || entry.state === 'loading') {
return
Reading the list…
}
if (entry.state === 'failed') {
return (
{entry.reason} — type the value by hand.
)
}
if (!entry.options.length) {
return (
{label} has nothing to offer right now — type the value by hand.
)
}
const grouped = entry.options.some((o) => o.group)
const groups = grouped
? [...new Set(entry.options.map((o) => o.group || 'Other'))]
: []
return (
)
}
/**
* A source too large for a dropdown, as a search box (Phase 13).
*
* **The first source that needed this made it unavoidable.** Phase 12b's spawner
* target is 6,707 spawn points against `MAX_OPTIONS`' bound of 2,000, so a flat
* list drops two thirds of the world and says nothing about which two thirds —
* an author picking from it would be choosing from a truncation they cannot see.
* The sidecar half of that shipped in 12b; this is what asks.
*
* `searchable` on the answer decides which control is drawn, rather than the
* length of the list: inferring it from a truncated answer reads correctly right
* up until a small deployment's list happens to fit, at which point the same
* source is a dropdown on one shard and a search box on another.
*
* A term is sent on a debounce and the answer is dropped if it is not the one
* for the term still in the box — a slow source answering after a faster one
* would otherwise repaint the list under the author's cursor with results for
* something they have finished typing.
*/
function SearchableOptions({ sourceId, label, disabled, onPick }) {
const [term, setTerm] = useState('')
const [state, setState] = useState({ status: 'idle', options: [] })
const latest = useRef('')
useEffect(() => {
const wanted = term.trim()
latest.current = wanted
if (!wanted) {
setState({ status: 'idle', options: [] })
return undefined
}
setState((s) => ({ ...s, status: 'loading' }))
const timer = setTimeout(async () => {
try {
const answer = await api.admin.eventOptions(sourceId, wanted)
if (latest.current !== wanted) return
setState(
answer?.ok
? { status: 'ok', options: answer.options || [] }
: { status: 'failed', options: [], reason: answer?.reason || 'this list could not be read' },
)
} catch (err) {
if (latest.current !== wanted) return
setState({ status: 'failed', options: [], reason: err.message || 'this list could not be read' })
}
}, 250)
return () => clearTimeout(timer)
}, [term, sourceId])
return (
)
}
/**
* The chosen option's note (MODULE_API 1.13.0), found even when the list the
* form holds does not reach it.
*
* A searchable source answers the form a bounded first page, so the value an
* author picked — or a template filled in — may not be in it, and its note with
* it. Then the source is asked for that value as a search term, once per value,
* and the row whose value matches exactly is the one whose note is shown. A
* failure is no note: advice never breaks the field it advises.
*/
function useChosenNote(sourceId, value, entry) {
const [asked, setAsked] = useState({ key: null, note: null })
const text = value === undefined || value === null ? '' : String(value)
const known = noteFor(entry, value)
const needAsk = Boolean(sourceId && entry?.state === 'ok' && entry.searchable && text && !known &&
!entry.options.some((o) => o.value === text))
const key = needAsk ? `${sourceId}\u0000${text}` : null
useEffect(() => {
if (!key || asked.key === key) return undefined
let alive = true
const timer = setTimeout(async () => {
try {
const answer = await api.admin.eventOptions(sourceId, text)
const row = answer?.ok ? (answer.options || []).find((o) => o.value === text) : null
if (alive) setAsked({ key, note: row?.note || null })
} catch {
if (alive) setAsked({ key, note: null })
}
}, 300)
return () => {
alive = false
clearTimeout(timer)
}
}, [key, asked.key, sourceId, text])
if (known) return known
return key && asked.key === key ? asked.note : null
}
/**
* A row of buttons for a choice of two or three (Rust PLAN_REDESIGNS §2.4).
*
* A dropdown of two hides the other choice behind a click; a row shows both and
* which one is on. `aria-pressed` carries the state for a screen reader, the
* opacity for everyone else — the same convention the weekday buttons use.
*/
function Segmented({ choices, value, disabled, onPick }) {
const current = value === undefined || value === null ? '' : String(value)
return (
{choices.map((c) => {
const on = current === String(c.value)
return (
)
})}
)
}
/**
* One declared param, as the control its type implies (Phase 13; reworked for
* MODULE_API 1.13.0, Rust PLAN_REDESIGNS §2.1).
*
* **Core does not know what any of this means and that is the design.** The
* label is the param's own name, the help is its own description, the placeholder
* is its own example, and the values behind it come from the module that declared
* the source — `check:modules` fails core's build on a UO identifier, so there is
* nowhere for a game word to be written here even by accident.
*
* **The field is the thing it does, not its type.** There is no ` · int` tag and
* no bare asterisk (U-3): a required field says so in words, and only when it is
* empty at save time (`missing`). The controls:
*
* • **A boolean is a toggle when it is required and three buttons when it is
* optional** (D334). For an OPTIONAL boolean "not set" is a real third state —
* the action's own default — and a two-state control would post `false` for
* every param an author never touched.
* • **A number shows its range and its unit beside it** when the declaration has
* them: a lease's `min`–`max`, a duration's minutes.
* • **A closed set of two or three is a row of buttons**, whether it is a lease's
* `values` or a source that answered with that few (§2.4).
* • **A source-backed param keeps its free-text field** otherwise. The dropdown
* writes into it; it does not replace it. §F: a source is answered by a module
* that may be talking to a sidecar, and an authoring form a shard outage can
* make unusable is a worse failure than the typo the dropdown exists to
* prevent. A row of buttons replaces the text box only while the source has
* answered and the value is one of its choices.
* • **The chosen option's `note` is shown under the field** (§2.6).
*
* `label`, `range` and `values` are passed by the lease form, which draws one
* lease's fields out of `core.lease`'s generic ones.
*/
function ParamField({ param, value, sourceEntry, disabled, onChange, missing, label, range, values }) {
const common = { className: 'input', disabled, style: { fontSize: '0.8rem' } }
const asText = value === undefined || value === null ? '' : String(value)
const placeholder = param.example === undefined || param.example === null ? '' : String(param.example)
let control
let beside = null
if (param.type === 'boolean') {
const b = boolValue(value)
control = param.required ? (
) : (
)
} else if (values && values.length) {
control = values.length <= 3 ? (
({ value: v, label: v }))} />
) : (
)
} else if (param.type === 'datetime') {
control = (
onChange(e.target.value)}
/>
)
} else if (param.type === 'int' || param.type === 'float') {
control = (
onChange(e.target.value)}
/>
)
const parts = []
if (param.duration === 'minutes') parts.push('minutes')
if (range && range.min !== null && range.min !== undefined && range.max !== null && range.max !== undefined) {
parts.push(`${range.min}–${range.max}`)
} else if (range && range.max !== null && range.max !== undefined) {
parts.push(`at most ${range.max}`)
}
if (parts.length) beside = {parts.join(' · ')}
} else if (param.type === 'url') {
control = (
onChange(e.target.value)} />
)
} else {
control = (
onChange(e.target.value)} />
)
}
// A source of two or three answered choices is a row of buttons, and the text
// box goes — unless the value is not one of them, which must stay visible and
// editable rather than look unset.
const segmented = param.source && param.type !== 'boolean' && !values && isSegmentable(sourceEntry)
const offList = segmented && asText !== '' && !sourceEntry.options.some((o) => o.value === asText)
const note = useChosenNote(param.source, value, sourceEntry)
return (
{segmented && (
({ value: o.value, label: o.label }))} />
)}
{missing && (
This is required — fill it in before saving.
)}
{param.description && (
{param.description}
)}
{note && (
{note}
)}
{param.source && !segmented && (
sourceEntry?.searchable
? (
)
: (
)
)}
)
}
/**
* A param that holds several values, as a multi-select (MODULE_API 1.14.0; Rust
* PLAN_REDESIGNS §12.3, D344).
*
* The chosen values are chips above the control, each with its remove button,
* and the control is whichever the param's source allows:
*
* • **a source that answered** — a grouped checklist in the source's own order,
* with a filter box over it, because a server's few hundred permissions are a
* list to narrow, not to scroll;
* • **a searchable source** — the typeahead, each pick adding a chip;
* • **no source, or one that failed** — a tag box, a value per Enter. A failure
* is said and the field stays usable, the single-value field's rule (§F).
*
* Ticking past `max` is refused in words, where the click was. The order the
* author picked in is the order stored, and the server keeps it.
*/
function MultiParamField({ param, value, sourceEntry, disabled, onChange, missing }) {
const list = listValue(value)
const [term, setTerm] = useState('')
const [typed, setTyped] = useState('')
const [refused, setRefused] = useState(null)
const max = param.max || 20
const answered = sourceEntry && sourceEntry.state === 'ok'
const byValue = new Map(answered ? sourceEntry.options.map((o) => [o.value, o]) : [])
const add = (v) => {
const next = addToList(list, v, max)
if (next.refused) setRefused(next.refused)
else {
setRefused(null)
onChange(next.list)
}
}
const remove = (v) => {
setRefused(null)
onChange(list.filter((x) => String(x) !== String(v)))
}
const toggle = (v) => (list.some((x) => String(x) === v) ? remove(v) : add(v))
const shown = answered && !sourceEntry.searchable ? filterOptions(sourceEntry.options, term) : []
const groups = [...new Set(shown.map((o) => o.group || ''))]
const chosenNotes = list
.map((v) => ({ v, note: byValue.get(String(v))?.note }))
.filter((x) => x.note)
const tagBox = (
)
let control
if (param.source && sourceEntry?.searchable) {
control = (
)
} else if (param.source && answered && sourceEntry.options.length) {
control = (
{refused && (
{refused}
)}
{control}
{missing && (
This is required — choose at least one before saving.
)}
{param.description && (
{param.description}
)}
{chosenNotes.map(({ v, note }) => (
{byValue.get(String(v))?.label || String(v)}: {note}
))}
)
}
/**
* `core.lease`, drawn as the chosen lease (Rust PLAN_REDESIGNS §2.1, U-4, F11).
*
* `core.lease` declares four generic fields — a lease id, a value as text, a
* target as text, a number of minutes — because one action serves every lease
* there is. Drawn as declared, a Rust admin was shown a skill-gain rate as the
* example and a free-text box for a target the lease has a list of. So the
* lease is picked by its label, and the other three become that lease's own:
*
* • the value as the lease's type — a toggle, a number with the lease's range,
* or its closed set of values — with the lease's own example;
* • the target as a picker over the lease's own `target.source` (F11: the editor
* read only `param.source`, and `core.lease`'s target param has none, on
* purpose — see its declaration);
* • the minutes with the lease's longest hold beside them.
*
* What is written is still `core.lease`'s params, as text where the action says
* text: the action coerces the value to the lease's type, and that stays the one
* place that knows both halves.
*/
function LeaseFields({ action, step, leases, sources, disabled, missing, onParam }) {
const declared = new Map((action.params || []).map((p) => [p.name, p]))
const leaseId = paramValue(step, 'lease')
const lease = leases.find((l) => l.id === leaseId) || null
const target = paramValue(step, 'target')
const owners = [...new Set(leases.map((l) => l.owner || 'other'))]
return (
<>
{missing.includes('lease') && (
This is required — fill it in before saving.
)}
{lease?.description && (
{lease.description}
)}
{leaseId && !lease && (
No installed module declares {leaseId}.
)}
{lease.label} is a single value and takes no target, so the step will be refused with
{String(target)} in it.{' '}
)}
{declared.get('minutes') && (
onParam('minutes', raw)}
/>
)}
>
)
}
/**
* "Start from a template" on a new event (MODULE_API 1.13.0; Rust PLAN_REDESIGNS
* §2.5, D192).
*
* A module's named starting point: the author fills in a few inputs and gets an
* ordinary draft in the editor below — the title, the phases, the steps — which
* they read, change and save like any other. **Nothing is saved here**, and
* nothing about the template follows the definition: it is a shortcut past
* typing, and the editor is still where the event is made.
*
* The inputs are params and are drawn by `ParamField`, so an input with a source
* gets the same dropdown, buttons and search a step's field does.
*/
function TemplatePicker({ templates, sources, loadSource, onDraft }) {
const [chosenId, setChosenId] = useState('')
const [inputs, setInputs] = useState({})
const [errors, setErrors] = useState([])
const [busy, setBusy] = useState(false)
const chosen = templates.find((t) => t.id === chosenId) || null
useEffect(() => {
for (const p of chosen?.inputs || []) if (p.source) loadSource(p.source)
}, [chosen, loadSource])
const choose = (id) => {
setChosenId(id)
setErrors([])
const t = templates.find((x) => x.id === id)
// Prefilled from the examples, like a fresh step — but only the required
// ones, because an example of a kit is not a kit this deployment has.
setInputs({})
if (t) setInputs(Object.fromEntries(t.inputs.filter((p) => p.required && !p.source).map((p) => [p.name, p.example])))
}
const fill = async () => {
setBusy(true)
setErrors([])
try {
const { draft } = await api.admin.buildEventTemplate(chosen.id, inputs)
onDraft(draft, chosen)
} catch (err) {
setErrors(err.body?.errors || [err.message])
} finally {
setBusy(false)
}
}
return (
Start from a template
A starting point an installed module offers. It fills in the plan below; nothing is saved
until you create the draft, and you can change every part of it first.
{chosen && (
{chosen.description && (
{chosen.description}
)}
{chosen.inputs.map((p) => (
setInputs((cur) => {
const next = { ...cur }
if (raw === '' || raw === undefined || raw === null) delete next[p.name]
else next[p.name] = coerceLiteral(p.type, raw)
return next
})}
/>
))}
{errors.length > 0 && (
{errors.map((e) =>
{e}
)}
)}
)}
)
}
/**
* The advance condition, as a builder rather than as JSON (Phase 13).
*
* **This is the engagement condition builder**, and being the same one is the
* point rather than a saving: the grammar is `engagement/conditions.js`, the
* server validates a phase gate with it, and the run console's diagnosis panel
* renders its sentence from the same labels. A second editor here would be a
* second opinion about a grammar core owns — exactly what §I refuses on the read
* side, where the sentence is rendered on the server for the same reason.
*
* It offers the FLAT half of the grammar — one `and`/`or` over a list of
* comparisons — because that is what a dropdown per operator can render
* honestly. A tree it cannot hold opens READ-ONLY with its JSON showing and one
* choice: leave it, or clear it and start again. Flattening `A and (B or C)`
* into `A and B and C` changes which firings release the phase, and an author
* would have no way to know the save had done it.
*/
function WhereBuilder({ advance, trigger, operators, disabled, onChange, emptyText }) {
const variables = trigger?.variables || []
const rows = advance.whereRows || []
if (advance.whereEditable === false) {
return (
Only when
{advance.whereText}
This condition nests, and the builder only holds one and/or over a
flat list. It is kept exactly as authored and posted back unchanged — flattening it would
change which firings release the phase without saying so.
)
})}
{!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 (
Arithmetic only — nothing was dispatched, so this does not know whether the places and things
these steps name exist. The dry run asks the modules that do.
)
}
/**
* Starting a run, with the three things the route has always taken (Phase 13).
*
* **Two of them were unreachable from this screen until now**, and one of those
* is not a nicety: `concurrencyKey` is a `{placeholder}` template rendered from
* the RUN's own params, so an event whose key names one could not be started
* correctly from the UI at all — every manual run rendered the same key and the
* second one was refused as an overlap.
*
* **Rehearsal is a real run.** §I: the world changes are real, the announcements
* are ceilinged to `staff`. It is not a dry run and the dialog says so, because
* the two words are near enough to swap in a hurry.
*/
function StartDialog({ onStart, onCancel, busy, draftAhead, publishedVersion, mayPublish }) {
const [rehearsal, setRehearsal] = useState(false)
const [scope, setScope] = useState('')
const [paramsText, setParamsText] = useState('{}')
const [problem, setProblem] = useState(null)
// Rust PLAN_REDESIGNS §2.3 (U-5). A run is pinned to the PUBLISHED version, so
// an author who saved an edit and pressed Start ran the old plan without being
// told. When the working copy is ahead, the dialog asks which one — and does
// not pick for them: publishing is a decision with consequences of its own
// (scheduled occurrences move to the new version).
const [which, setWhich] = useState(null)
const go = () => {
let params = null
const text = paramsText.trim()
if (text && text !== '{}') {
try {
params = JSON.parse(text)
} catch (err) {
setProblem(`The params are not valid JSON (${err.message})`)
return
}
if (!params || typeof params !== 'object' || Array.isArray(params)) {
setProblem('The params must be a JSON object')
return
}
}
if (draftAhead && !which) {
setProblem('Choose which plan to start: your edits, published first, or the published version.')
return
}
onStart(
{ rehearsal, scope: scope || undefined, ...(params ? { params } : {}) },
{ publishFirst: draftAhead && which === 'publish' },
)
}
return (
Start a run now
{draftAhead && (
This definition has edits that are not published.{' '}
A run uses a published version, never the working copy.
{mayPublish && (
)}
)}
{problem && (
{problem}
)}
)
}
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: `, 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
if (loading || !form) return
const archived = event?.state === 'archived'
return (
{isNew ? 'New event' : event?.title}
{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'}
>
)}
{mayAuthor && (
)}
{/* 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 && (
)}
{/* 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 && (
)}
{!isNew && isAdmin && event?.state === 'ready' && (
)}
{!mayAuthor && (
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.
)}
{archived && (
This definition is archived. It is kept so its past runs can still be explained, and it
cannot be edited or run again.
)}
{/* ── §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 && (
This version has not been dry-run. 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.
)}
{report && (
{report.report.ok ? 'Dry run passed' : 'Dry run found problems'}
{' '}
· {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'}
)}
{/* 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 && (
)}
{isNew && mayAuthor && templates.length > 0 && (
{
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 ── */}
{/* 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. */}
The grace window is how late this event may still start: past it an occurrence becomes
missed rather than beginning hours after it was announced. Two runs sharing a
concurrency key never overlap — {'{placeholders}'} are filled from the run’s own
params. An event that is not 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.
{/* ── 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.
*/}
Times are the event’s own, in {form.timezone} — not the reader’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.
{/* ── 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. */}
Started by the game
Start a run when something happens in the game — when a boss appears on a chosen server.
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.
No installed module registers {start.on}. The start is kept, and the
definition cannot be published until the module is back or the start is removed.
)}
{start.on && (
setStart(patch)}
/>
)}
)
})}
A cooldown is a whole number of s, m, h or d,
from 0m to 7d. Zero leaves one run at a time as the only limit.
{describeTriggerStarts(event?.triggerStarts) && (
{describeTriggerStarts(event.triggerStarts)}
)}
{/* ── 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. */}
{/* ── The phase timeline ── */}
Phases
{form.phases.length === 0 && (
No phases yet. An event needs at least one phase with at least one step before it can be
published.
)}
{form.phases.map((phase, pi) => (
The key is what the run console groups by and what “Phase p3: steps done, waiting to advance” names, so it
cannot change once runs exist.
{/* §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 && (
)}
{/* ── 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. */}
{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.'}
{/* §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) && (
{shortfallSentence(form.phases, pi, actionById)}
)}
{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 (
)}
{/* ── 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. */}
Opened as JSON: {mode.reason}. Nothing has been dropped — what is here is
exactly what was authored.
)}
{mode.mode === PARAM_FORM ? (
{action?.id === 'core.lease' ? (
{
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 ? (
setStep(pi, si, { paramsText: setParamList(step, p.name, list, p.type) })}
/>
) : (
setStepParam(pi, si, step, p, raw)}
/>
)))}
{action && (action.params || []).length === 0 && (
This action takes no params.
)}
{!action && (
Pick an action and its fields appear here, straight from what the module
declared.
)}
) : (
<>
)
})}
))}
{!isNew && versions.length > 0 && (
Versions
Publishing snapshots the whole spec into a version nothing ever edits. Editing this
definition while a run is live is free — the live run keeps the version it
pinned and is unaffected by anything on this screen.