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

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