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')
})

View File

@@ -1,6 +1,6 @@
{
"_comment": "Generated event-trigger inventory - the authoritative freeze of CORE's engagement contract (docs/website/ENGAGEMENT.md 4.3). Regenerate with `npm run engagement:manifest` in website/server. A renamed variable, a changed type or a widened ceiling breaks stored templates and rules, so the diff here is the review signal. A module ships its own copy in its bundle; this file never contains one.",
"moduleApiVersion": "1.12.0",
"moduleApiVersion": "1.13.0",
"triggers": [
{
"id": "event.phase.changed",

View File

@@ -545,6 +545,15 @@
"requireAuth"
]
},
{
"method": "POST",
"path": "/api/v1/admin/events/catalog/templates/:templateId",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "POST",
"path": "/api/v1/admin/events/price",

View File

@@ -241,6 +241,10 @@
"method": "GET",
"path": "/api/v1/admin/events/catalog/options/:sourceId"
},
{
"method": "POST",
"path": "/api/v1/admin/events/catalog/templates/:templateId"
},
{
"method": "POST",
"path": "/api/v1/admin/events/price"

View File

@@ -137,14 +137,14 @@ const ACTIONS = [
name: 'title',
type: 'string',
required: false,
example: 'The gates of Britain open at dusk',
example: 'The gates open at dusk',
description: 'Optional heading, for legs that render one.',
},
{
name: 'body',
type: 'string',
required: true,
example: 'A caravan has been sighted on the road east of Cove.',
example: 'A caravan has been sighted on the east road.',
description: 'The announcement itself. Plain text.',
},
],
@@ -250,7 +250,7 @@ const ACTIONS = [
name: 'instruction',
type: 'string',
required: true,
example: 'Open the north gate and read the herald script in Britain bank.',
example: 'Open the north gate and read the herald script in the town square.',
description: 'What the staff member is being asked to do.',
},
{
@@ -306,7 +306,10 @@ const ACTIONS = [
name: 'lease',
type: 'string',
required: true,
example: 'uo.rate.skillgain',
// Game-neutral (Rust PLAN_REDESIGNS §0.9): this was a UO rate, shown to
// every deployment's authors. The editor now draws the chosen lease's own
// form and examples (MODULE_API 1.13.0), so this is only ever a fallback.
example: 'mygame.rate.gather',
source: 'core.options.leases',
description: 'Which declared value to borrow.',
},
@@ -347,6 +350,10 @@ const ACTIONS = [
type: 'int',
required: true,
example: 120,
// A timed step (MODULE_API 1.13.0): the editor keeps the phase open at
// least this long, because teardown gives the value back when the run
// ends, whatever this says.
duration: 'minutes',
description: 'How long to hold it. Core refuses more than the lease allows.',
},
],

View File

@@ -603,6 +603,9 @@ const emptySpec = () => ({
module.exports = {
validate,
// An event template's inputs are params, and are checked as one (MODULE_API
// 1.13.0): eventDefinitions.model `buildFromTemplate`.
checkParams,
publishable,
actionIdsIn,
emptySpec,

View File

@@ -31,6 +31,7 @@ const verifier = require('../../events/verify')
const registries = require('../../modules/registries')
const { slugify, uniqueSlug } = require('../teams/teamSlug')
const { cleanBody } = require('../../utils/sanitizeHtml')
const log = require('../../utils/logger')('events')
const MAX_TITLE = 200
const MAX_SUMMARY = 500
@@ -399,9 +400,111 @@ async function verify(id, user) {
}
}
// How long a template's `build()` may take. It may read the module's own data or
// ask a sidecar, and an author is waiting on the other side of the click.
const TEMPLATE_BUILD_MS = 10_000
/**
* Fill an event template in (MODULE_API 1.13.0; Rust PLAN_REDESIGNS §2.5, D192).
*
* `{ ok: true, draft: { title, summary, spec } }`, or `{ ok: false, status,
* errors }`. **Nothing is saved.** The draft goes back to the editor, which opens
* it like any new event; the author saves it through `create()` and its rules,
* so a template cannot make a definition the save path would refuse.
*
* The module is not trusted at either end. The inputs are checked against the
* template's declaration before `build()` sees them, and what `build()` returns
* is validated as a spec after. A `build()` that refuses says why in
* `{ ok: false, error }` — the author's sentence — and one that throws or hangs
* is logged and answered with a sentence of core's, because a stack trace is
* not something to put on an author's screen.
*/
async function buildFromTemplate(templateId, rawInputs) {
const template = registries.eventTemplate(String(templateId || ''))
if (!template) {
return { ok: false, status: 404, errors: [`no module registers the event template "${templateId}"`] }
}
const checked = spec.checkParams({ id: template.id, params: template.inputs }, rawInputs, 'inputs')
if (checked.errors.length) {
return { ok: false, status: 400, errors: checked.errors.map((e) => e.replace(/^inputs\.params: /, '')) }
}
let built
let timer
try {
built = await Promise.race([
Promise.resolve(template.build({ inputs: checked.params })),
new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error(`build() took longer than ${TEMPLATE_BUILD_MS} ms`)), TEMPLATE_BUILD_MS)
}),
])
} catch (err) {
log.error('event template build failed', { template: template.id, owner: template.owner, message: err.message })
return { ok: false, status: 502, errors: [`"${template.label}" could not be filled in — the module that provides it failed`] }
} finally {
clearTimeout(timer)
}
if (!built || typeof built !== 'object') {
return { ok: false, status: 502, errors: [`"${template.label}" answered with no draft`] }
}
if (built.ok === false) {
return { ok: false, status: 400, errors: [String(built.error || `"${template.label}" refused those inputs`)] }
}
const checkedSpec = spec.validate(built.spec)
if (!checkedSpec.ok) {
// The module's mistake, not the author's, and named as such: the author
// cannot fix a template by retyping its inputs.
log.error('event template built an invalid spec', { template: template.id, errors: checkedSpec.errors })
return {
ok: false,
status: 502,
errors: [`"${template.label}" made a plan core cannot accept`, ...checkedSpec.errors],
}
}
return {
ok: true,
draft: {
title: String(built.title || template.label).slice(0, MAX_TITLE),
summary: built.summary ? String(built.summary).slice(0, MAX_SUMMARY) : null,
spec: checkedSpec.spec,
},
}
}
/**
* Does the working copy differ from the published version? (Rust PLAN_REDESIGNS
* §2.3, U-5.)
*
* A run is pinned to the PUBLISHED version, so an author who edited, saved and
* pressed Start ran the old plan without being told. The editor asks only when
* this is true. Compared as data, keys sorted, because the two are stored as
* JSON by different writes and a key order is not an edit. A definition never
* published has nothing to be ahead of, and answers false.
*/
async function draftAhead(row) {
if (!row || !row.current_version_id) return false
const version = await versionsDb.getById(row.current_version_id)
if (!version) return false
return stableJson(row.spec) !== stableJson(version.spec)
}
function stableJson(value) {
if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`
if (value && typeof value === 'object') {
return `{${Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${stableJson(value[k])}`).join(',')}}`
}
return JSON.stringify(value === undefined ? null : value)
}
module.exports = {
validate,
create,
buildFromTemplate,
draftAhead,
save,
publish,
archive,

View File

@@ -459,6 +459,13 @@ function buildApi(record) {
once('registerEventOptionSources')
record.staged.registerEventOptionSources(sources)
},
// API 1.13.0. A named starting point for a new event (Rust PLAN_REDESIGNS
// §2.5): inputs in, an ordinary draft out. Its own id space, like the four
// above, and `once` for the same reason.
registerEventTemplates(templates) {
once('registerEventTemplates')
record.staged.registerEventTemplates(templates)
},
// The two lifecycle hooks (§2.5). Registered here, dispatched from
// lifecycle.js — this file runs with no database and the hooks run with one.
// Both are optional: a module with no warm-up and nothing to close simply

View File

@@ -176,6 +176,16 @@ const eventLeases = new Map()
// is worse than the typo the dropdown existed to prevent.
const eventOptionSources = new Map()
// template id → { owner, id, label, description, inputs, build } (MODULE_API
// 1.13.0; Rust PLAN_REDESIGNS §2.5, D192).
//
// **An authoring shortcut and nothing else.** A template turns a handful of
// inputs into an ordinary draft — phases, steps, params — and the draft is the
// only thing that survives: no column records which template made it, and the
// runner never sees one. That is what lets a template change, or its module go,
// without touching a single definition it was ever used for.
const eventTemplates = new Map()
// owner → { templates: [...], ruleGroups: [...] } (ENGAGEMENT.md Phase 11b,
// decision 7). What a module ships as CONTENT rather than as contract: the
// bodies its triggers render through, and the rules an operator switches on.
@@ -531,6 +541,9 @@ const allEventOptionSources = () =>
// still answers something, where refusing would blank a form mid-keystroke.
const MAX_OPTION_QUERY = 120
// The longest note an option may carry, in characters.
const MAX_OPTION_NOTE = 300
/**
* Resolve one option source, or say why not. Never throws.
*
@@ -584,6 +597,12 @@ async function resolveOptionSource(id, { q = '' } = {}) {
if (o.value === undefined || o.value === null || o.value === '') continue
const option = { value: String(o.value), label: String(o.label ?? o.value) }
if (o.group) option.group = String(o.group)
// A sentence the editor shows under the field while this option is the
// chosen one (MODULE_API 1.13.0; Rust PLAN_REDESIGNS §2.6) — advice about
// the value, like "9 of 41 linked players have used this kit up". Bounded,
// because it is rendered as-is and one option's essay is a form nobody
// can read.
if (o.note) option.note = String(o.note).slice(0, MAX_OPTION_NOTE)
options.push(option)
}
// `searchable` is reported so the authoring form knows which field is a
@@ -1005,9 +1024,18 @@ const DEFAULT_BUDGET_MS = 10_000
// and Phase 2's lease has to be longer than this to mean anything.
const MAX_BUDGET_MS = 3_600_000
function checkActionParam(actionId, entry, seen) {
const { name, type, required, example, description, source } = entry || {}
const where = `registerEventActions: ${actionId}`
// A param that is a length of time says which unit it is in (MODULE_API 1.13.0;
// Rust PLAN_REDESIGNS §2.2). One unit, minutes, because every timed param
// registered so far is one — a lease's `minutes`, a zone's `minutes` — and a
// vocabulary of one is a vocabulary that can grow without a version.
const PARAM_DURATIONS = ['minutes']
// `call` names the registration in the error, because a template's inputs are
// checked by this same function (they ARE params: one control each, with a
// name, a type, an example and maybe a source).
function checkActionParam(actionId, entry, seen, call = 'registerEventActions') {
const { name, type, required, example, description, source, duration } = entry || {}
const where = `${call}: ${actionId}`
if (!PARAM_NAME.test(name || '')) throw new Error(`${where}: bad param name "${name}"`)
if (seen.has(name)) throw new Error(`${where}: param "${name}" declared twice`)
seen.add(name)
@@ -1030,6 +1058,20 @@ function checkActionParam(actionId, entry, seen) {
if (source !== undefined && !ACTION_ID.test(source || '')) {
throw new Error(`${where}: param "${name}" has a bad option source "${source}"`)
}
// **A duration is what lets the editor see a timed step** (§2.2). A phase that
// ends before its longest timed step gives the thing back early — a lease held
// "for 10 minutes" in a phase that ends at once lasted one second — and the
// editor can only say so about a param that says it is a length of time.
// Numeric only: a duration typed as a string would be a number the editor
// has to parse with rules nobody declared.
if (duration !== undefined && duration !== null) {
if (!PARAM_DURATIONS.includes(duration)) {
throw new Error(`${where}: param "${name}" has duration "${duration}"; the one unit is ${PARAM_DURATIONS.join(', ')}`)
}
if (type !== 'int' && type !== 'float') {
throw new Error(`${where}: param "${name}" is a duration and must be int or float, not ${type}`)
}
}
return {
name,
type,
@@ -1037,6 +1079,7 @@ function checkActionParam(actionId, entry, seen) {
example,
source: source === undefined ? null : source,
description: description || '',
...(duration ? { duration } : {}),
}
}
@@ -1330,6 +1373,19 @@ function checkEventLeaseShape(entry) {
values = l.values.map((v) => String(v))
}
// **The lease's own example for its value** (MODULE_API 1.13.0; Rust
// PLAN_REDESIGNS §2.1, U-3). `core.lease`'s `value` param has one example for
// every lease there is, and a Rust admin borrowing a group permission was shown
// a skill-gain rate. Optional; a scalar, because it is a placeholder in one
// field. Not range-checked: it is shown, never sent.
let example = null
if (l.example !== undefined && l.example !== null && l.example !== '') {
if (!['string', 'number', 'boolean'].includes(typeof l.example)) {
throw new Error(`registerEventLeases: ${l.id} example must be a single value`)
}
example = l.example
}
return {
id: l.id,
label: l.label,
@@ -1338,6 +1394,7 @@ function checkEventLeaseShape(entry) {
min,
max,
values,
example,
target,
maxDurationMs,
read: l.read,
@@ -1384,6 +1441,56 @@ function checkEventOptionSourceShape(entry) {
}
}
// The most inputs a template may ask for. A template is a shortcut past the
// editor, and one that asks for a dozen things is the editor with fewer labels.
const MAX_TEMPLATE_INPUTS = 8
/**
* `registerEventTemplates([{ id, label, description, inputs, build }])`
* (MODULE_API 1.13.0; Rust PLAN_REDESIGNS §2.5, D192).
*
* A named starting point for a new event: the author fills in `inputs`, core
* calls `build({ inputs })`, and what comes back is a DRAFT the editor opens like
* any other — `{ title, summary?, spec: { phases } }`. Nothing about the template
* survives into the definition.
*
* **`inputs` are params, checked by the same function**, because they are drawn
* by the same widget: a name, a type, an example, maybe a source. That gives an
* input the dropdown, the search box and the duration unit for free, and gives
* the author one form language rather than two.
*
* `build` may be async and may read the module's own data (the kit weekend needs
* the kit's permission). It is never trusted: core checks the inputs before the
* call and validates the spec after it, and a throw is the author's error
* message rather than a 500 (`buildEventTemplate` in eventDefinitions.model).
*/
function checkEventTemplateShape(entry) {
const t = entry || {}
if (!EVENT_ID.test(t.id || '')) throw new Error(`registerEventTemplates: bad template id "${t.id}"`)
if (!t.label) throw new Error(`registerEventTemplates: template "${t.id}" has no label`)
if (typeof t.build !== 'function') throw new Error(`registerEventTemplates: ${t.id} has no build()`)
const rawInputs = t.inputs === undefined ? [] : t.inputs
if (!Array.isArray(rawInputs)) throw new Error(`registerEventTemplates: ${t.id} inputs must be an array`)
if (rawInputs.length > MAX_TEMPLATE_INPUTS) {
throw new Error(`registerEventTemplates: ${t.id} asks for ${rawInputs.length} inputs; the most is ${MAX_TEMPLATE_INPUTS}`)
}
const seen = new Set()
return {
id: t.id,
label: t.label,
description: t.description || '',
inputs: rawInputs.map((p) => checkActionParam(t.id, p, seen, 'registerEventTemplates')),
build: t.build,
}
}
/** Every template WITHOUT its `build` — what the catalog serves. */
const allEventTemplates = () =>
[...eventTemplates.values()].map(({ build, ...rest }) => rest)
/** One template, `build` included. */
const eventTemplate = (id) => eventTemplates.get(id) || null
// ── Engagement seeds (Phase 11b, decision 7) ───────────────────────────────
//
@@ -1628,6 +1735,7 @@ function stage(owner) {
eventBudgets: [],
eventLeases: [],
eventOptionSources: [],
eventTemplates: [],
}
return {
staged,
@@ -1686,6 +1794,12 @@ function stage(owner) {
if (!Array.isArray(entries)) throw new Error('registerEventOptionSources: expected an array')
for (const e of entries) staged.eventOptionSources.push(checkEventOptionSourceShape(e))
},
// MODULE_API 1.13.0. Core registers none; the first is Module-Rust's kit
// weekend (Rust PLAN_REDESIGNS §2.5).
registerEventTemplates(entries) {
if (!Array.isArray(entries)) throw new Error('registerEventTemplates: expected an array')
for (const e of entries) staged.eventTemplates.push(checkEventTemplateShape(e))
},
registerEngagementSeeds(entry) {
staged.engagementSeeds.push(checkEngagementSeeds(owner, entry))
},
@@ -1715,6 +1829,7 @@ function apply({
eventBudgets: newEventBudgets = [],
eventLeases: newEventLeases = [],
eventOptionSources: newEventOptionSources = [],
eventTemplates: newEventTemplates = [],
engagementSeeds: newSeeds = [],
}) {
// ── validate ──
@@ -1819,6 +1934,17 @@ function apply({
seenLeases.add(l.id)
}
const seenTemplates = new Set()
for (const t of newEventTemplates) {
const held = eventTemplates.get(t.id)
if (held) throw new Error(`event template "${t.id}" is already registered by "${held.owner}"`)
if (seenTemplates.has(t.id)) throw new Error(`event template "${t.id}" registered twice`)
if (!namespaced(owner, t.id, {})) {
throw new Error(`event template "${t.id}" is not namespaced "${owner}."`)
}
seenTemplates.add(t.id)
}
const seenSources = new Set()
for (const s of newEventOptionSources) {
const held = eventOptionSources.get(s.id)
@@ -1898,6 +2024,7 @@ function apply({
for (const b of newEventBudgets) eventBudgets.set(b.id, { owner, ...b })
for (const l of newEventLeases) eventLeases.set(l.id, { owner, ...l })
for (const s of newEventOptionSources) eventOptionSources.set(s.id, { owner, ...s })
for (const t of newEventTemplates) eventTemplates.set(t.id, { owner, ...t })
for (const seeds of newSeeds) engagementSeeds.set(owner, seeds)
}
@@ -1993,6 +2120,7 @@ function _reset() {
eventBudgets.clear()
eventLeases.clear()
eventOptionSources.clear()
eventTemplates.clear()
engagementSeeds.clear()
coreRegistered = false
}
@@ -2033,6 +2161,9 @@ module.exports = {
parseLeaseRef,
leaseRef,
allEventOptionSources,
allEventTemplates,
eventTemplate,
PARAM_DURATIONS,
resolveOptionSource,
allEngagementSeeds,
engagementSeedsFor,

View File

@@ -9,6 +9,20 @@
// Deliberately separate from PROTOCOL_VERSION (which versions the shard wire and
// has nothing to say about a website module) and from any module's own version.
// 1.13.0 — the event step editor (Rust PLAN_REDESIGNS §2; docs/website/EVENTS.md
// §F, §I). Four additions, each optional:
// • `api.registerEventTemplates([{ id, label, description, inputs, build }])` —
// a named starting point for a new event; inputs in, an ordinary draft out,
// and nothing about the template kept in the definition (D192);
// • a param's `duration: 'minutes'`, so the editor can see a timed step and
// keep its phase open long enough for it;
// • a lease's `example` for its value, so a lease step shows that lease's
// example rather than core's one for every lease;
// • an option row's `note`, shown under the field while it is the chosen one.
// Additions only, so minor. Module-uo declares `^1.10.0` and uses none of them:
// its actions, leases and sources come out of the registry exactly as they went
// in, and its steps render with the new controls and nothing else changed.
//
// 1.12.0 — an event action may declare `progress(envelope)`: how a live run's
// step stands, for its public page (docs/website/EVENTS.md §I; RunicNPC D304,
// D305). Core accepts two shapes, `{ label, left, of }` and `{ label, percent }`,
@@ -174,6 +188,6 @@
// an admin action a module performs belongs in core's one audit log, the
// extension slot needs the user its prefix names, and §2.7 forbids a module
// reading core's `APP_BASE_URL` for itself. Additions only, so minor.
const MODULE_API_VERSION = '1.12.0'
const MODULE_API_VERSION = '1.13.0'
module.exports = { MODULE_API_VERSION }

View File

@@ -78,6 +78,16 @@ const shapeDefinition = (d) => ({
updatedAt: d.updated_at,
})
/**
* The definition as the EDITOR reads it: the shape above, and whether the working
* copy has moved on from the published version (Rust PLAN_REDESIGNS §2.3). One
* extra read, so only the three routes the editor loads from carry it.
*/
const shapeEditable = async (row) => ({
...shapeDefinition(row),
draftAhead: await definitions.draftAhead(row),
})
const shapeRun = (r) => ({
id: r.id,
definitionId: r.definition_id,
@@ -203,6 +213,9 @@ exports.catalog = (_req, res) => {
budgets: registries.allEventBudgets(),
leases: registries.allEventLeases(),
optionSources: registries.allEventOptionSources(),
// MODULE_API 1.13.0 (Rust PLAN_REDESIGNS §2.5). Without `build`, like every
// callable above; filling one in is its own request.
templates: registries.allEventTemplates(),
limits: {
maxPhases: spec.MAX_PHASES,
maxStepsPerPhase: spec.MAX_STEPS_PER_PHASE,
@@ -363,7 +376,7 @@ exports.get = async (req, res) => {
if (!id) return res.status(400).json({ error: 'bad event id' })
const row = await definitionsDb.getById(id)
if (!row) return res.status(404).json({ error: 'no such event definition' })
return res.json({ event: shapeDefinition(row) })
return res.json({ event: await shapeEditable(row) })
}
/** GET /api/v1/admin/events/:id/versions */
@@ -408,7 +421,7 @@ exports.update = async (req, res) => {
action: 'event.definition.updated',
detail: { id, title: result.definition.title },
})
return res.json({ event: shapeDefinition(result.definition) })
return res.json({ event: await shapeEditable(result.definition) })
}
/** POST /api/v1/admin/events/:id/publish */
@@ -423,7 +436,7 @@ exports.publish = async (req, res) => {
detail: { id, version: result.version, versionId: result.versionId, repinned: result.repinned },
})
return res.json({
event: shapeDefinition(result.definition),
event: await shapeEditable(result.definition),
version: result.version,
versionId: result.versionId,
// How many already-materialised occurrences moved to this version. The
@@ -585,6 +598,19 @@ exports.options = async (req, res) => {
return res.json(result)
}
/**
* POST /api/v1/admin/events/catalog/templates/:templateId — fill a template in.
*
* MODULE_API 1.13.0 (Rust PLAN_REDESIGNS §2.5). Answers a draft and writes
* nothing, so there is nothing to put in the activity log: the definition the
* draft becomes is logged when it is created, by the route that creates it.
*/
exports.buildTemplate = async (req, res) => {
const result = await definitions.buildFromTemplate(req.params.templateId, req.body?.inputs)
if (!result.ok) return res.status(result.status || 400).json({ errors: result.errors })
return res.json({ draft: result.draft })
}
/**
* PUT /api/v1/admin/events/actions — set one action's switch and caps.
*

View File

@@ -51,7 +51,7 @@ eventsRouter.get(
// #swagger.summary = 'List every registered event action, with its param schema, risk class and reversibility'
// #swagger.description = 'Served from the module registries, not from a table: an action is declared in code by core or by an installed module, so this is whatever registered on this boot, and an uninstalled module simply stops appearing. Core always declares core.announce, core.wait and core.cue. Also carries the closed vocabularies the authoring form renders — risk classes, reversibility classes, param types, failure dispositions and the spec size limits — so the editor offers exactly the set the save path checks against. Phase 5 added `triggers` and `operators`: the trigger catalog a module already ships IS the catalog of things a phase can advance on, and it is served here rather than borrowed from /admin/engagement/triggers because that route is admin-only while an event definition is authored by admin AND editor. Each trigger is reduced to its id, label and declared variables — a trigger's audience and ceiling are about who gets mailed, which is not this screen's question. Phase 7 added `budgets`, `leases` and `optionSources`: the other three registrations of the module contract, served beside the actions because the step editor needs all four to draw ONE step — the action says what params it takes, a param source names a dropdown, and a cap box is a budget label and unit. A source resolves its VALUES on a request of its own (/options/:sourceId), because a source can be slow or down and must not take the catalog with it.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The registered actions and triggers, and the vocabularies over them', content: { "application/json": { schema: { type: "object", properties: { actions: { type: "array", items: { type: "object", additionalProperties: true } }, triggers: { type: "array", items: { type: "object", additionalProperties: true } }, operators: { type: "array", items: { type: "object", additionalProperties: true } }, risks: { type: "array", items: { type: "string" } }, reversible: { type: "array", items: { type: "string" } }, paramTypes: { type: "array", items: { type: "string" } }, onFailure: { type: "array", items: { type: "string" } }, onFailureByRisk: { type: "object", additionalProperties: true }, scheduleKinds: { type: "array", items: { type: "string" } }, advanceKinds: { type: "array", items: { type: "string" } }, limits: { type: "object", additionalProperties: true } } } } } } */
/* #swagger.responses[200] = { description: 'The registered actions and triggers, and the vocabularies over them', content: { "application/json": { schema: { type: "object", properties: { actions: { type: "array", items: { type: "object", additionalProperties: true } }, triggers: { type: "array", items: { type: "object", additionalProperties: true } }, operators: { type: "array", items: { type: "object", additionalProperties: true } }, risks: { type: "array", items: { type: "string" } }, reversible: { type: "array", items: { type: "string" } }, paramTypes: { type: "array", items: { type: "string" } }, onFailure: { type: "array", items: { type: "string" } }, onFailureByRisk: { type: "object", additionalProperties: true }, scheduleKinds: { type: "array", items: { type: "string" } }, advanceKinds: { type: "array", items: { type: "string" } }, limits: { type: "object", additionalProperties: true }, templates: { type: "array", description: "Event templates (MODULE_API 1.13.0): id, label, description, owner and inputs (each shaped like an action param). build() is never served.", items: { type: "object", additionalProperties: true } } } } } } } */
/* #swagger.responses[403] = { description: 'Not staff', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
controller.catalog,
)
@@ -78,11 +78,35 @@ eventsRouter.get(
// object schema whose properties are `type` and `maxLength`, which documents a
// query parameter that takes a JSON object. The bound is stated in the description.
/* #swagger.parameters['q'] = { in: 'query', description: 'Narrow the list. Honoured only by a source that declares itself searchable; ignored, never refused, by the rest. Bounded to 120 characters.', required: false, schema: { type: 'string' } } */
/* #swagger.responses[200] = { description: 'The options, or the reason there are none', content: { "application/json": { schema: { type: "object", properties: { ok: { type: "boolean" }, id: { type: "string" }, label: { type: "string" }, owner: { type: "string" }, searchable: { type: "boolean" }, q: { type: "string" }, reason: { type: "string" }, options: { type: "array", items: { type: "object", properties: { value: { type: "string" }, label: { type: "string" }, group: { type: "string" } } } } } } } } } */
/* #swagger.responses[200] = { description: 'The options, or the reason there are none', content: { "application/json": { schema: { type: "object", properties: { ok: { type: "boolean" }, id: { type: "string" }, label: { type: "string" }, owner: { type: "string" }, searchable: { type: "boolean" }, q: { type: "string" }, reason: { type: "string" }, options: { type: "array", items: { type: "object", properties: { value: { type: "string" }, label: { type: "string" }, group: { type: "string" }, note: { type: "string", description: "Advice shown under the field while this option is chosen (MODULE_API 1.13.0). At most 300 characters." } } } } } } } } } */
/* #swagger.responses[403] = { description: 'Not staff', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
controller.options,
)
// ── Event templates (MODULE_API 1.13.0; Rust PLAN_REDESIGNS §2.5) ─────────
//
// Under `/catalog` for the reason the option sources are: a template is catalog
// data, and the nesting keeps it out of `/:id`'s way. A POST because the inputs
// are a body, not because anything is written — the answer is a draft the
// editor opens, and saving it is `POST /` with every rule that route has.
// `admin, editor`, because filling a template in is authoring.
eventsRouter.post(
'/catalog/templates/:templateId',
adminOrEditor,
// #swagger.tags = ['Admin · Events']
// #swagger.summary = 'Fill an event template in, and get back a draft'
// #swagger.description = 'A module may register named event templates (MODULE_API 1.13.0). The catalog lists them with their inputs; this route checks the inputs against the template, calls the module to build the plan, validates the plan as a spec and answers it as a draft: a title, a summary and a spec. Nothing is saved, and nothing about the template is kept: the editor opens the draft like any new event and the author saves it through POST /admin/events. A refusal of the inputs is a 400 naming each problem; a module that fails or builds a plan core cannot accept is a 502.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { inputs: { type: "object", additionalProperties: true, description: "One value per declared input, keyed by its name" } } } } } } */
/* #swagger.responses[200] = { description: 'The draft', content: { "application/json": { schema: { type: "object", properties: { draft: { type: "object", properties: { title: { type: "string" }, summary: { type: "string", nullable: true }, spec: { type: "object", additionalProperties: true } } } } } } } } */
/* #swagger.responses[400] = { description: 'The inputs were refused', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */
/* #swagger.responses[403] = { description: 'Not an admin or editor', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[404] = { description: 'No module registers that template', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */
/* #swagger.responses[502] = { description: 'The module failed to build the plan, or built one core cannot accept', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */
controller.buildTemplate,
)
// ── The live cap meter (Phase 13) ──────────────────────────────────
//
// A literal path for the same reason `/actions` is one, and `admin, editor` for

View File

@@ -3931,6 +3931,14 @@
"limits": {
"type": "object",
"additionalProperties": true
},
"templates": {
"type": "array",
"description": "Event templates (MODULE_API 1.13.0): id, label, description, owner and inputs (each shaped like an action param). build() is never served.",
"items": {
"type": "object",
"additionalProperties": true
}
}
}
}
@@ -4026,6 +4034,10 @@
},
"group": {
"type": "string"
},
"note": {
"type": "string",
"description": "Advice shown under the field while this option is chosen (MODULE_API 1.13.0). At most 300 characters."
}
}
}
@@ -4056,6 +4068,144 @@
]
}
},
"/api/v1/admin/events/catalog/templates/{templateId}": {
"post": {
"tags": [
"Admin · Events"
],
"summary": "Fill an event template in, and get back a draft",
"description": "A module may register named event templates (MODULE_API 1.13.0). The catalog lists them with their inputs; this route checks the inputs against the template, calls the module to build the plan, validates the plan as a spec and answers it as a draft: a title, a summary and a spec. Nothing is saved, and nothing about the template is kept: the editor opens the draft like any new event and the author saves it through POST /admin/events. A refusal of the inputs is a 400 naming each problem; a module that fails or builds a plan core cannot accept is a 502.",
"parameters": [
{
"name": "templateId",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "The draft",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"draft": {
"type": "object",
"properties": {
"title": {
"type": "string"
},
"summary": {
"type": "string",
"nullable": true
},
"spec": {
"type": "object",
"additionalProperties": true
}
}
}
}
}
}
}
},
"400": {
"description": "The inputs were refused",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"errors": {
"type": "array",
"items": {
"type": "string"
}
}
}
}
}
}
},
"403": {
"description": "Not an admin or editor",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "No module registers that template",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"errors": {
"type": "array",
"items": {
"type": "string"
}
}
}
}
}
}
},
"502": {
"description": "The module failed to build the plan, or built one core cannot accept",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"errors": {
"type": "array",
"items": {
"type": "string"
}
}
}
}
}
}
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"inputs": {
"type": "object",
"additionalProperties": true,
"description": "One value per declared input, keyed by its name"
}
}
}
}
}
}
}
},
"/api/v1/admin/events/price": {
"post": {
"tags": [

View File

@@ -112,7 +112,7 @@ test('the version a module declares against satisfies ^1.10.0', () => {
// 1.10.0 loading — Module-uo declares exactly `^1.10.0` — and this is the
// assertion that says it does. The number itself is pinned in version.js.
assert.ok(semver.satisfies(MODULE_API_VERSION, '^1.10.0'), MODULE_API_VERSION)
assert.equal(MODULE_API_VERSION, '1.12.0')
assert.equal(MODULE_API_VERSION, '1.13.0')
})
test('a module registers actions, budgets, leases and option sources', () => {

View File

@@ -0,0 +1,302 @@
// ── The step editor's contract additions (MODULE_API 1.13.0) ───────────────
//
// Rust PLAN_REDESIGNS §2: event templates (D192), a param's `duration`, a lease's
// `example` and an option row's `note`. Proved the way eventModuleContract.test.js
// proves the rest of the seam — a real module in a real directory, scanned and
// registered by the real loader — because a test that staged by hand would pass
// against a loader that forwards none of it.
//
// The pool points at a closed port before anything is required. `draftAhead`
// reads a version, and that read is stubbed on the module object the model holds.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const fs = require('fs')
const os = require('os')
const path = require('path')
const { test, beforeEach, after } = require('node:test')
const assert = require('node:assert/strict')
const express = require('express')
const db = require('../src/utils/db')
const registries = require('../src/modules/registries')
const definitions = require('../src/model/events/eventDefinitions.model')
const versionsDb = require('../src/model/events/eventVersions.db')
after(() => db.close())
let tmpRoot
const emptyTiers = () => ({
public: express.Router(),
admin: express.Router(),
player: express.Router(),
})
function loadModule(id, source, manifest = {}) {
const dir = path.join(tmpRoot, id)
fs.mkdirSync(dir, { recursive: true })
fs.writeFileSync(
path.join(dir, 'module.json'),
JSON.stringify({ id, name: id, version: '1.0.0', coreApi: '^1.13.0', server: 'index.js', ...manifest }),
)
fs.writeFileSync(path.join(dir, 'index.js'), source)
// A test that loads twice rewrites the same file, and require() would hand the
// loader the first one back.
delete require.cache[path.join(dir, 'index.js')]
process.env.MODULES_DIR = tmpRoot
registries._reset()
delete require.cache[require.resolve('../src/modules/loader')]
// eslint-disable-next-line global-require
const loader = require('../src/modules/loader')
loader.load(emptyTiers())
return loader.list().find((m) => m.id === id)
}
const assertRegistered = (record) => {
assert.equal(record.state, 'registered', record.reason || 'expected the module to register')
}
const assertFailed = (record, pattern) => {
assert.notEqual(record.state, 'registered', 'expected the module to be refused')
assert.match(record.reason || '', pattern)
}
beforeEach(() => {
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-step-editor-'))
})
// A template whose build() answers whatever the module's `mode` input says, so
// one registration covers every path build() can take.
const TEMPLATE_MODULE = `module.exports = (ctx, api) => {
api.registerEventTemplates([{
id: 'demo.template.weekend',
label: 'Weekend',
description: 'Two days of something.',
inputs: [
{ name: 'what', type: 'string', required: true, example: 'double gather' },
{ name: 'minutes', type: 'int', required: true, example: 60, duration: 'minutes' },
{ name: 'mode', type: 'string', required: false, example: 'ok' },
],
async build({ inputs }) {
if (inputs.mode === 'refuse') return { ok: false, error: 'that is not a weekend' }
if (inputs.mode === 'throw') throw new Error('database exploded at row 7')
if (inputs.mode === 'nothing') return null
if (inputs.mode === 'bad') return { title: 'Bad', spec: { phases: [{ key: 'Not A Key', steps: [] }] } }
return {
title: 'Weekend: ' + inputs.what,
summary: 'For ' + inputs.minutes + ' minutes.',
spec: {
phases: [{
key: 'hold',
label: 'Hold',
advance: { after: inputs.minutes + 'm' },
steps: [{ actionId: 'core.wait', params: { seconds: 5 } }],
}],
},
}
},
}])
}`
// ── Templates ─────────────────────────────────────────────────────────────
test('a module registers a template, and the catalog lists it without build()', () => {
assertRegistered(loadModule('demo', TEMPLATE_MODULE))
const [t] = registries.allEventTemplates()
assert.equal(t.id, 'demo.template.weekend')
assert.equal(t.owner, 'demo')
assert.equal(t.build, undefined)
// The inputs are params, checked by the same function: the duration survives.
assert.deepEqual(t.inputs.map((p) => [p.name, p.type, p.required, p.duration || null]), [
['what', 'string', true, null],
['minutes', 'int', true, 'minutes'],
['mode', 'string', false, null],
])
assert.equal(typeof registries.eventTemplate('demo.template.weekend').build, 'function')
})
test('a template outside its module\'s prefix, without build(), or asking too much is refused', () => {
assertFailed(loadModule('demo', `module.exports = (ctx, api) => {
api.registerEventTemplates([{ id: 'other.template.x', label: 'X', build() { return {} } }])
}`), /not namespaced "demo\."/)
assertFailed(loadModule('demo', `module.exports = (ctx, api) => {
api.registerEventTemplates([{ id: 'demo.template.x', label: 'X' }])
}`), /has no build\(\)/)
const nine = Array.from({ length: 9 }, (_, i) => `{ name: 'p${i}', type: 'string', example: 'x' }`).join(',')
assertFailed(loadModule('demo', `module.exports = (ctx, api) => {
api.registerEventTemplates([{ id: 'demo.template.x', label: 'X', inputs: [${nine}], build() { return {} } }])
}`), /asks for 9 inputs/)
// An input is a param, so it needs an example like every param does.
assertFailed(loadModule('demo', `module.exports = (ctx, api) => {
api.registerEventTemplates([{ id: 'demo.template.x', label: 'X', inputs: [{ name: 'a', type: 'string' }], build() { return {} } }])
}`), /registerEventTemplates: demo\.template\.x: param "a" needs an example/)
})
test('a filled-in template is a draft: validated, normalised, and saved nowhere', async () => {
assertRegistered(loadModule('demo', TEMPLATE_MODULE))
registries.registerCore()
const result = await definitions.buildFromTemplate('demo.template.weekend', { what: 'wood', minutes: 90 })
assert.equal(result.ok, true, JSON.stringify(result.errors))
assert.equal(result.draft.title, 'Weekend: wood')
assert.equal(result.draft.summary, 'For 90 minutes.')
assert.equal(result.draft.spec.phases[0].advance.after, '90m')
assert.equal(result.draft.spec.phases[0].steps[0].actionId, 'core.wait')
})
test('the inputs are checked before build() is called, in the author\'s words', async () => {
assertRegistered(loadModule('demo', TEMPLATE_MODULE))
registries.registerCore()
const missing = await definitions.buildFromTemplate('demo.template.weekend', { minutes: 10 })
assert.equal(missing.ok, false)
assert.equal(missing.status, 400)
assert.deepEqual(missing.errors, ['"what" is required'])
const extra = await definitions.buildFromTemplate('demo.template.weekend', { what: 'x', minutes: 10, colour: 'red' })
assert.equal(extra.ok, false)
assert.match(extra.errors[0], /"colour" is not a param of demo\.template\.weekend/)
const unknown = await definitions.buildFromTemplate('demo.template.nope', {})
assert.equal(unknown.status, 404)
})
test('every way build() can fail is an answer, and only a refusal reaches the author verbatim', async () => {
assertRegistered(loadModule('demo', TEMPLATE_MODULE))
registries.registerCore()
const base = { what: 'x', minutes: 10 }
const refused = await definitions.buildFromTemplate('demo.template.weekend', { ...base, mode: 'refuse' })
assert.equal(refused.status, 400)
assert.deepEqual(refused.errors, ['that is not a weekend'])
// A throw is logged, not shown: the module's internals are not the author's.
const threw = await definitions.buildFromTemplate('demo.template.weekend', { ...base, mode: 'throw' })
assert.equal(threw.status, 502)
assert.doesNotMatch(threw.errors.join(' '), /database exploded/)
assert.match(threw.errors[0], /"Weekend" could not be filled in/)
const nothing = await definitions.buildFromTemplate('demo.template.weekend', { ...base, mode: 'nothing' })
assert.equal(nothing.status, 502)
const bad = await definitions.buildFromTemplate('demo.template.weekend', { ...base, mode: 'bad' })
assert.equal(bad.status, 502)
assert.match(bad.errors[0], /made a plan core cannot accept/)
assert.ok(bad.errors.length > 1, 'the spec errors ride along for whoever reads the response')
})
// ── A param's duration ────────────────────────────────────────────────────
test('a duration is minutes, on a number, and the catalog carries it', () => {
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
api.registerEventActions([{
id: 'demo.hold', label: 'Hold', risk: 'notify', reversible: 'none', version: 1,
params: [{ name: 'minutes', type: 'int', required: true, example: 5, duration: 'minutes' }],
async perform() { return { ok: true } },
}])
}`))
assert.equal(registries.eventAction('demo.hold').params[0].duration, 'minutes')
assertFailed(loadModule('demo', `module.exports = (ctx, api) => {
api.registerEventActions([{
id: 'demo.hold', label: 'Hold', risk: 'notify', reversible: 'none', version: 1,
params: [{ name: 'for', type: 'string', example: '5m', duration: 'minutes' }],
async perform() { return { ok: true } },
}])
}`), /is a duration and must be int or float/)
assertFailed(loadModule('demo', `module.exports = (ctx, api) => {
api.registerEventActions([{
id: 'demo.hold', label: 'Hold', risk: 'notify', reversible: 'none', version: 1,
params: [{ name: 'hours', type: 'int', example: 2, duration: 'hours' }],
async perform() { return { ok: true } },
}])
}`), /has duration "hours"; the one unit is minutes/)
})
test('core.lease\'s minutes is a duration, and its examples name no game', () => {
registries._reset()
registries.registerCore()
const lease = registries.eventAction('core.lease')
assert.equal(lease.params.find((p) => p.name === 'minutes').duration, 'minutes')
// The params without a duration do not grow the key at all.
assert.equal('duration' in lease.params.find((p) => p.name === 'value'), false)
const words = registries.allEventActions()
.filter((a) => a.owner === 'core')
.flatMap((a) => a.params.map((p) => String(p.example)))
.join(' ')
assert.doesNotMatch(words, /\buo\.|Britain|Cove|skillgain/)
})
// ── A lease's example ─────────────────────────────────────────────────────
const leaseModule = (example) => `module.exports = (ctx, api) => {
api.registerEventLeases([{
id: 'demo.flag', label: 'Flag', type: 'bool', maxDurationMs: 3600000,
${example === undefined ? '' : `example: ${example},`}
async read() { return { ok: true, value: false } },
async apply() { return { ok: true } },
async restore() { return { ok: true } },
}])
}`
test('a lease may carry an example for its value, and it is served', () => {
assertRegistered(loadModule('demo', leaseModule('true')))
assert.equal(registries.allEventLeases()[0].example, true)
assertRegistered(loadModule('demo', leaseModule(undefined)))
assert.equal(registries.allEventLeases()[0].example, null)
assertFailed(loadModule('demo', leaseModule('{ on: true }')), /example must be a single value/)
})
// ── An option's note ──────────────────────────────────────────────────────
test('an option may carry a note, kept as text and bounded', async () => {
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
api.registerEventOptionSources([{
id: 'demo.options.kits', label: 'Kits',
async resolve() {
return [
{ value: 'vip', label: 'VIP', note: '9 of 41 linked players have used it up' },
{ value: 'long', note: 'x'.repeat(500) },
{ value: 'plain' },
]
},
}])
}`))
const answer = await registries.resolveOptionSource('demo.options.kits')
assert.equal(answer.options[0].note, '9 of 41 linked players have used it up')
assert.equal(answer.options[1].note.length, 300)
assert.equal('note' in answer.options[2], false)
})
// ── The draft ahead of its published version ──────────────────────────────
test('draftAhead compares the working copy with the published spec as data', async (t) => {
const published = { phases: [{ key: 'a', steps: [{ actionId: 'core.wait', params: { seconds: 5 } }] }] }
const original = versionsDb.getById
t.after(() => { versionsDb.getById = original })
versionsDb.getById = async (id) => (id === 7 ? { id: 7, spec: published } : null)
// Never published: nothing to be ahead of.
assert.equal(await definitions.draftAhead({ current_version_id: null, spec: published }), false)
// The same plan with its keys in another order is not an edit.
const reordered = { phases: [{ steps: [{ params: { seconds: 5 }, actionId: 'core.wait' }], key: 'a' }] }
assert.equal(await definitions.draftAhead({ current_version_id: 7, spec: reordered }), false)
const edited = { phases: [{ key: 'a', steps: [{ actionId: 'core.wait', params: { seconds: 6 } }] }] }
assert.equal(await definitions.draftAhead({ current_version_id: 7, spec: edited }), true)
// A version that cannot be found answers false rather than asking the author
// to choose between two plans one of which is missing.
assert.equal(await definitions.draftAhead({ current_version_id: 8, spec: edited }), false)
})