feat(events): the step editor reworked, and event templates (MODULE_API 1.13.0)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 2m26s
PR Checks / client-build (pull_request) Successful in 2m44s
PR Checks / server-tests (pull_request) Successful in 20m38s

Rust PLAN_REDESIGNS §2 (U-3-U-6, F11; D192, D334), core half. Game-agnostic:
Module-uo's steps get the new controls and nothing else.

Contract (MODULE_API 1.12.0 -> 1.13.0, four optional additions):
- api.registerEventTemplates([{ id, label, description, inputs, build }]):
  inputs are params (<= 8); core checks them, calls build() (10 s), validates
  the result as a spec and answers an unsaved draft.
  POST /admin/events/catalog/templates/:templateId (admin, editor).
- a param's duration: 'minutes' (int/float only); core.lease's minutes has it.
- a lease's example for its value.
- an option row's note (<= 300 chars), shown under the chosen field.

Editor:
- no type tags or bare asterisks; a required field says so in words once a
  save has been tried with it empty (and the save stops before the round trip).
- boolean: toggle when required, [Not set] [Yes] [No] when optional (D334).
- a choice of two or three is a row of buttons (lease values, small sources).
- core.lease drawn as the chosen lease: picked by label; value by the lease's
  type, range, values and example; target picker over target.source (F11);
  minutes with the lease's longest hold.
- a timed step keeps its run open: a phase with no advance rule gets
  after: <longest hold> when a timed step is added; a sentence under any phase
  whose run can end sooner (U-6).
- Start asks "Publish and start" or "Start version N" when the working copy is
  ahead of the published version (draftAhead on GET/PUT/publish; U-5).
- New event can start from a module's template.
- core's own examples name no game (§0.9).

Swagger, routes.manifest/guards and engagement-triggers.json regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-10-12 02:08:04 -05:00
parent 43f706c003
commit 3e243ce772
19 changed files with 1603 additions and 72 deletions

View File

@@ -518,6 +518,10 @@ export const api = {
const qs = q ? `?${new URLSearchParams({ q }).toString()}` : ''
return req(`/admin/events/catalog/options/${encodeURIComponent(sourceId)}${qs}`)
},
// MODULE_API 1.13.0. Fill an event template in; the answer is a draft the
// editor opens, and nothing is saved until the author saves it.
buildEventTemplate: (templateId, inputs) =>
req(`/admin/events/catalog/templates/${encodeURIComponent(templateId)}`, { method: 'POST', body: { inputs } }),
// Phase 6. The dry run is admin+editor: it dispatches nothing, and the author
// who wrote the definition is who should be able to price it against the caps
// before asking an admin to publish it. A report with findings comes back 200

View File

@@ -625,6 +625,152 @@ export function setParam(step, name, raw, type) {
*/
export const datetimeInputValue = (value) => (typeof value === 'string' ? value.slice(0, 16) : '')
// ── The step editor's own rules (MODULE_API 1.13.0; Rust PLAN_REDESIGNS §2) ──
//
// What the controls need to decide and nothing the server re-decides: which
// control a field gets, which required fields are empty, and whether a phase
// gives a timed step back before its time is up. The server still refuses a
// missing param by name — these exist so the form can say it first, in place.
/**
* A boolean as the control holds it. A lease's value is a string param that the
* lease reads as a boolean, so "true" and "false" are booleans here too.
*/
export function boolValue(value) {
if (value === true || value === 'true') return true
if (value === false || value === 'false') return false
return null
}
/**
* Should this source be drawn as a row of buttons? (§2.4)
*
* Two or three choices that fit on one line, from a source that answered and is
* not searched. A dropdown of two hides the other choice behind a click.
*/
export function isSegmentable(entry) {
return Boolean(
entry && entry.state === 'ok' && !entry.searchable &&
entry.options.length >= 2 && entry.options.length <= 3,
)
}
/** The note on the chosen option, if it carries one (§2.6). */
export function noteFor(entry, value) {
if (!entry || entry.state !== 'ok' || value === undefined || value === null || value === '') return null
const chosen = entry.options.find((o) => o.value === String(value))
return chosen?.note || null
}
/**
* The required params a step leaves empty, by name. `core.lease`'s `target` is
* optional on the action and required by a targeted LEASE, so the lease is
* asked too when there is one.
*/
export function missingParams(step, action, leasesById = new Map()) {
if (!action) return []
const parsed = parseParams(step?.paramsText)
if (parsed.error) return []
const empty = (v) => v === undefined || v === null || v === ''
const missing = (action.params || []).filter((p) => p.required && empty(parsed.params[p.name])).map((p) => p.name)
if (action.id === 'core.lease') {
const lease = leasesById.get(parsed.params.lease)
if (lease?.target && empty(parsed.params.target)) missing.push('target')
}
return missing
}
/** `30m`, `2h`, `90s`, `1d` → minutes. Null for anything the server would refuse. */
export function afterMinutes(after) {
const m = /^(\d{1,6})(s|m|h|d)$/.exec(String(after ?? '').trim())
if (!m) return null
const n = Number(m[1])
return { s: n / 60, m: n, h: n * 60, d: n * 1440 }[m[2]]
}
/** The longest declared duration in a step, in minutes; 0 for an untimed one. */
export function stepMinutes(step, action) {
const parsed = parseParams(step?.paramsText)
if (parsed.error || !action) return 0
let longest = 0
for (const p of action.params || []) {
if (p.duration !== 'minutes') continue
const n = Number(parsed.params[p.name])
if (Number.isFinite(n) && n > longest) longest = n
}
return longest
}
/** The longest timed step in a phase, in minutes. */
export const phaseMinutes = (phase, actionById) =>
Math.max(0, ...(phase?.steps || []).map((s) => stepMinutes(s, actionById.get(s.actionId))))
/**
* How long, at the least, the run goes on from the start of phase `pi`, in
* minutes — or Infinity when a phase from there on waits for something in the
* game, which may take any time at all.
*
* **Why the RUN and not the phase.** Teardown gives everything back when the run
* ends, so a 10-minute hold in phase 1 of three is safe if phases 2 and 3 take
* ten minutes between them. A phase with no advance rule ends when its steps do,
* which is counted as no time: a floor, never a promise.
*/
export function runFloorMinutes(phases, pi) {
let total = 0
for (const phase of (phases || []).slice(pi)) {
const kind = phase.advance?.kind || ''
if (kind === 'on') return Infinity
if (kind === 'after') total += afterMinutes(phase.advance.after) || 0
}
return total
}
/**
* Does phase `pi` give a timed step back before its time? (§2.2, U-6)
*
* `null`, or `{ longest, floor }` in minutes. Only a run that can END sooner than
* the longest hold counts: teardown is what gives a lease back, not the phase.
*/
export function timedPhaseShortfall(phases, pi, actionById) {
const longest = phaseMinutes(phases?.[pi], actionById)
if (!longest) return null
const floor = runFloorMinutes(phases, pi)
return floor < longest ? { longest, floor } : null
}
/**
* The phase's advance after a timed step was added or changed (§2.2).
*
* A phase with NO advance rule that would give the step back early gets
* `after: '<longest>m'`, visibly, and the author may change it. A phase the
* author already gave a rule keeps it: the warning under the phase says what it
* costs, and overriding an author's choice is not this function's to do.
*/
export function guardedAdvance(phases, pi, actionById) {
const phase = phases?.[pi]
if (!phase || (phase.advance?.kind || '') !== '') return null
const short = timedPhaseShortfall(phases, pi, actionById)
if (!short) return null
return { ...(phase.advance || blankAdvance()), kind: 'after', after: `${Math.ceil(short.longest)}m` }
}
/** "1 minute", "90 minutes", "2 hours". */
export function minutesWord(n) {
if (n >= 120 && n % 60 === 0) return `${n / 60} hours`
return `${n} minute${n === 1 ? '' : 's'}`
}
/** The sentence under a phase that ends a timed step early, or null. */
export function shortfallSentence(phases, pi, actionById) {
const short = timedPhaseShortfall(phases, pi, actionById)
if (!short) return null
const last = pi === (phases || []).length - 1
const ends = short.floor === 0
? (last ? 'This phase ends as soon as its steps are done, and the run with it' : 'The rest of this run can be over as soon as its steps are done')
: (last ? `This phase ends after ${minutesWord(short.floor)}, and the run with it` : `The rest of this run can be over ${minutesWord(short.floor)} after this phase starts`)
return `${ends}; what is held for ${minutesWord(short.longest)} is given back then, not after ${minutesWord(short.longest)}.`
}
/**
* Everything the meter needs out of the form, and nothing else.
*

View File

@@ -11,6 +11,10 @@
// that the two files can drift, so a test asserts they agree
// (client/test/moduleRegistry.test.js) rather than trusting a bump to remember
// both.
// 1.13.0 — the event step editor (Rust PLAN_REDESIGNS §2): event templates, a
// param's `duration`, a lease's `example` and an option's `note`. All four are
// server-side declarations the editor, core's own page, renders. This file bumps
// for the reason at the top.
// 1.12.0 — an event action's `progress()`: a live run's public page shows how
// its steps stand. Server-side registration; the client half is core's own event
// page rendering the lines. This file bumps for the reason at the top.
@@ -81,4 +85,4 @@
// but the two halves state ONE version: a module declares a single coreApi range
// and is served one chunk, so a client that claimed 1.0.0 while the server
// answered 1.1.0 would be two answers to one question.
export const MODULE_API_VERSION = '1.12.0'
export const MODULE_API_VERSION = '1.13.0'

View File

@@ -25,8 +25,14 @@ import {
datetimeInputValue,
priceBodyFrom,
worthPricing,
boolValue,
isSegmentable,
noteFor,
missingParams,
guardedAdvance,
shortfallSentence,
} from '../../../lib/eventAuthoring.js'
import { operatorsForType } from '../../../lib/engagementRules.js'
import { operatorsForType, coerceLiteral } from '../../../lib/engagementRules.js'
// Admin → Events → the definition editor (EVENTS.md §I, Phase 3).
//
@@ -215,7 +221,34 @@ function SearchableOptions({ sourceId, label, disabled, onPick }) {
}
/**
* One declared param, as the control its type implies (Phase 13).
* 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
@@ -223,32 +256,66 @@ function SearchableOptions({ sourceId, label, disabled, onPick }) {
* the source — `check:modules` fails core's build on a UO identifier, so there is
* nowhere for a game word to be written here even by accident.
*
* Two of the controls are worth their own sentence:
* **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 three-value select, not a checkbox.** A checkbox cannot say
* *"not set"*, and for an OPTIONAL boolean that is a real third state — the
* action's own default. A checkbox would post `false` for every param an author
* never touched.
* • **A source-backed param keeps its free-text field.** The dropdown writes
* into it; it does not replace it. §F: a source is answered by a module that may
* be talking to a sidecar, and an authoring form a shard outage can make
* unusable is a worse failure than the typo the dropdown exists to prevent.
* • **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 }) {
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') {
control = (
<select
{...common}
value={value === true ? 'true' : value === false ? 'false' : ''}
onChange={(e) => onChange(e.target.value)}
>
<option value="">Not set{param.required ? '' : ' — the action decides'}</option>
<option value="true">Yes</option>
<option value="false">No</option>
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') {
@@ -266,37 +333,67 @@ function ParamField({ param, value, sourceEntry, disabled, onChange }) {
{...common}
type="number"
step={param.type === 'int' ? '1' : 'any'}
min={range?.min ?? undefined}
max={range?.max ?? undefined}
value={asText}
placeholder={param.example === undefined ? '' : String(param.example)}
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={param.example === undefined ? '' : String(param.example)}
<input {...common} type="url" value={asText} placeholder={placeholder}
onChange={(e) => onChange(e.target.value)} />
)
} else {
control = (
<input {...common} value={asText}
placeholder={param.example === undefined ? '' : String(param.example)}
<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 = param.source ? noteFor(sourceEntry, value) : null
return (
<label style={{ display: 'block', marginBottom: 12 }}>
<span className="field-label">
{param.name}
{param.required && <span style={{ color: '#d98b84' }}> *</span>}
<span className="dim" style={{ fontWeight: 'normal' }}> · {param.type}</span>
</span>
{control}
{param.description && (
<span className="sans dim" style={{ fontSize: '0.74rem' }}>{param.description}</span>
<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 }))} />
)}
{param.source && (
{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
@@ -315,7 +412,223 @@ function ParamField({ param, value, sourceEntry, disabled, onChange }) {
/>
)
)}
</label>
</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>
)
}
@@ -404,7 +717,7 @@ function WhereBuilder({ advance, trigger, operators, disabled, onChange }) {
{!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' ? 'Yew, Britain, Vesper' : ''}
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}
@@ -525,11 +838,17 @@ function CapMeter({ report, budgets, stale }) {
* 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 }) {
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
@@ -546,13 +865,43 @@ function StartDialog({ onStart, onCancel, busy }) {
return
}
}
onStart({ rehearsal, scope: scope || undefined, ...(params ? { params } : {}) })
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' }}>
@@ -684,6 +1033,14 @@ export default function EventEditor() {
// 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 }.
//
@@ -747,10 +1104,16 @@ export default function EventEditor() {
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, loadSource])
}, [form, actionById, leaseById, loadSource])
/**
* The live cap meter (§I, Phase 13).
@@ -806,14 +1169,6 @@ export default function EventEditor() {
phases: f.phases.map((p, i) => (i === pi ? { ...p, ...patch } : p)),
}))
const setStep = (pi, si, patch) =>
setForm((f) => ({
...f,
phases: f.phases.map((p, i) =>
i === pi ? { ...p, steps: p.steps.map((s, j) => (j === si ? { ...s, ...patch } : s)) } : p,
),
}))
const movePhase = (pi, delta) =>
setForm((f) => {
const next = [...f.phases]
@@ -848,13 +1203,65 @@ export default function EventEditor() {
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: '' })
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 () => {
setBusy(true)
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)
@@ -946,10 +1353,17 @@ export default function EventEditor() {
* 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) => {
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) {
@@ -1113,7 +1527,8 @@ export default function EventEditor() {
)}
{starting && (
<StartDialog busy={busy} onCancel={() => setStarting(false)} onStart={start} />
<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>}
@@ -1127,6 +1542,19 @@ export default function EventEditor() {
</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 }}>
@@ -1399,6 +1827,14 @@ export default function EventEditor() {
? '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 }}>
@@ -1479,14 +1915,31 @@ export default function EventEditor() {
{mode.mode === PARAM_FORM ? (
<div style={{ marginTop: 8 }}>
{(action?.params || []).map((p) => (
{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) => (
<ParamField
key={p.name}
param={p}
value={paramValue(step, p.name)}
sourceEntry={p.source ? sources[p.source] : null}
disabled={archived}
onChange={(raw) => setStep(pi, si, { paramsText: setParam(step, p.name, raw, p.type) })}
missing={checkMissing && missingParams(step, action, leaseById).includes(p.name)}
onChange={(raw) => setStepParam(pi, si, step, p, raw)}
/>
))}
{action && (action.params || []).length === 0 && (
@@ -1520,7 +1973,7 @@ export default function EventEditor() {
})}
<button type="button" className="pill" style={{ fontSize: '0.72rem', marginTop: 12 }} disabled={archived}
onClick={() => setPhase(pi, { steps: [...phase.steps, blankStep(actions[0])] })}>
onClick={() => addStep(pi)}>
Add step
</button>
</div>

View File

@@ -0,0 +1,144 @@
// The step editor's own rules (MODULE_API 1.13.0; Rust PLAN_REDESIGNS §2): which
// control a field gets, which required fields are empty, and whether a phase
// gives a timed step back before its time is up.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import {
boolValue,
isSegmentable,
noteFor,
missingParams,
afterMinutes,
stepMinutes,
runFloorMinutes,
timedPhaseShortfall,
guardedAdvance,
shortfallSentence,
minutesWord,
blankAdvance,
} from '../src/lib/eventAuthoring.js'
const LEASE_ACTION = {
id: 'core.lease',
params: [
{ name: 'lease', type: 'string', required: true },
{ name: 'value', type: 'string', required: true },
{ name: 'target', type: 'string', required: false },
{ name: 'minutes', type: 'int', required: true, duration: 'minutes' },
],
}
const SAY = { id: 'test.say', params: [{ name: 'message', type: 'string', required: true }] }
const actionById = new Map([[LEASE_ACTION.id, LEASE_ACTION], [SAY.id, SAY]])
const step = (actionId, params) => ({ actionId, paramsText: JSON.stringify(params) })
const lease = (minutes) => step('core.lease', { lease: 'x.flag', value: 'true', minutes })
const phase = (steps, advance = blankAdvance()) => ({ key: 'p', label: 'P', steps, advance })
const after = (a) => ({ ...blankAdvance(), kind: 'after', after: a })
const on = () => ({ ...blankAdvance(), kind: 'on', on: 'x.happened' })
test('a lease\'s "true" is a boolean to the control', () => {
assert.equal(boolValue('true'), true)
assert.equal(boolValue(false), false)
assert.equal(boolValue(''), null)
assert.equal(boolValue(undefined), null)
})
test('two or three answered choices are buttons; a search or a long list is not', () => {
const ok = (n, extra = {}) => ({ state: 'ok', options: Array.from({ length: n }, (_, i) => ({ value: String(i) })), ...extra })
assert.equal(isSegmentable(ok(2)), true)
assert.equal(isSegmentable(ok(3)), true)
assert.equal(isSegmentable(ok(1)), false)
assert.equal(isSegmentable(ok(4)), false)
assert.equal(isSegmentable(ok(2, { searchable: true })), false)
assert.equal(isSegmentable({ state: 'failed', options: [] }), false)
})
test('the chosen option\'s note, and nothing for one without', () => {
const entry = { state: 'ok', options: [{ value: 'vip', note: '9 of 41 used it up' }, { value: 'free' }] }
assert.equal(noteFor(entry, 'vip'), '9 of 41 used it up')
assert.equal(noteFor(entry, 'free'), null)
assert.equal(noteFor(entry, ''), null)
})
test('required and empty, including the target a targeted lease needs', () => {
assert.deepEqual(missingParams(step('test.say', {}), SAY), ['message'])
assert.deepEqual(missingParams(step('test.say', { message: 'hi' }), SAY), [])
const leases = new Map([['x.perm', { id: 'x.perm', target: { label: 'Group' } }], ['x.flag', { id: 'x.flag' }]])
assert.deepEqual(
missingParams(step('core.lease', { lease: 'x.perm', value: 'true', minutes: 5 }), LEASE_ACTION, leases),
['target'],
)
assert.deepEqual(missingParams(lease(5), LEASE_ACTION, leases), [])
// A step whose JSON does not parse is the JSON box's to report, not this.
assert.deepEqual(missingParams({ actionId: 'test.say', paramsText: '{' }, SAY), [])
})
test('the server\'s after grammar, in minutes', () => {
assert.equal(afterMinutes('30m'), 30)
assert.equal(afterMinutes('2h'), 120)
assert.equal(afterMinutes('90s'), 1.5)
assert.equal(afterMinutes('1d'), 1440)
assert.equal(afterMinutes('soon'), null)
})
test('a step is as long as its longest declared duration', () => {
assert.equal(stepMinutes(lease(10), LEASE_ACTION), 10)
assert.equal(stepMinutes(step('test.say', { message: 'x' }), SAY), 0)
assert.equal(stepMinutes(lease('ten'), LEASE_ACTION), 0)
})
test('the run\'s floor counts every later phase, and a game trigger is unbounded', () => {
const phases = [phase([]), phase([], after('5m')), phase([], after('1h'))]
assert.equal(runFloorMinutes(phases, 0), 65)
assert.equal(runFloorMinutes(phases, 2), 60)
assert.equal(runFloorMinutes([phase([]), phase([], on())], 0), Infinity)
})
test('U-6: a one-phase event with no rule gives a 10-minute lease back at once', () => {
const phases = [phase([lease(10)])]
assert.deepEqual(timedPhaseShortfall(phases, 0, actionById), { longest: 10, floor: 0 })
assert.equal(
shortfallSentence(phases, 0, actionById),
'This phase ends as soon as its steps are done, and the run with it; what is held for 10 minutes is given back then, not after 10 minutes.',
)
})
test('a phase with no rule is given after: <longest>, and a chosen rule is left alone', () => {
const advance = guardedAdvance([phase([lease(10)])], 0, actionById)
assert.equal(advance.kind, 'after')
assert.equal(advance.after, '10m')
// The author's 5 minutes stays; the sentence says what it costs instead.
const chosen = [phase([lease(10)], after('5m'))]
assert.equal(guardedAdvance(chosen, 0, actionById), null)
assert.equal(
shortfallSentence(chosen, 0, actionById),
'This phase ends after 5 minutes, and the run with it; what is held for 10 minutes is given back then, not after 10 minutes.',
)
})
test('later phases long enough to cover the hold are no shortfall at all', () => {
const phases = [phase([lease(10)]), phase([], after('15m'))]
assert.equal(timedPhaseShortfall(phases, 0, actionById), null)
assert.equal(guardedAdvance(phases, 0, actionById), null)
assert.equal(shortfallSentence(phases, 0, actionById), null)
// A later phase that waits on the game cannot be counted, and is not warned about.
assert.equal(timedPhaseShortfall([phase([lease(10)]), phase([], on())], 0, actionById), null)
const short = [phase([lease(10)]), phase([], after('3m'))]
assert.match(shortfallSentence(short, 0, actionById), /^The rest of this run can be over 3 minutes after this phase starts;/)
})
test('an untimed phase is never touched', () => {
const phases = [phase([step('test.say', { message: 'x' })])]
assert.equal(guardedAdvance(phases, 0, actionById), null)
assert.equal(shortfallSentence(phases, 0, actionById), null)
})
test('minutes read as words', () => {
assert.equal(minutesWord(1), '1 minute')
assert.equal(minutesWord(45), '45 minutes')
assert.equal(minutesWord(120), '2 hours')
assert.equal(minutesWord(90), '90 minutes')
})