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

@@ -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,