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
2296 lines
104 KiB
JavaScript
2296 lines
104 KiB
JavaScript
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’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’s own, in <code>{form.timezone}</code> — 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.
|
||
</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 “Phase p3: steps done, waiting to advance” 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>
|
||
)
|
||
}
|