feat(events): the step editor reworked, and event templates (MODULE_API 1.13.0)
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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
144
client/test/eventStepEditor.test.js
Normal file
144
client/test/eventStepEditor.test.js
Normal 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')
|
||||
})
|
||||
Reference in New Issue
Block a user