feat(events): the step editor reworked, and event templates (MODULE_API 1.13.0)
Rust PLAN_REDESIGNS §2 (U-3-U-6, F11; D192, D334), core half. Game-agnostic:
Module-uo's steps get the new controls and nothing else.
Contract (MODULE_API 1.12.0 -> 1.13.0, four optional additions):
- api.registerEventTemplates([{ id, label, description, inputs, build }]):
inputs are params (<= 8); core checks them, calls build() (10 s), validates
the result as a spec and answers an unsaved draft.
POST /admin/events/catalog/templates/:templateId (admin, editor).
- a param's duration: 'minutes' (int/float only); core.lease's minutes has it.
- a lease's example for its value.
- an option row's note (<= 300 chars), shown under the chosen field.
Editor:
- no type tags or bare asterisks; a required field says so in words once a
save has been tried with it empty (and the save stops before the round trip).
- boolean: toggle when required, [Not set] [Yes] [No] when optional (D334).
- a choice of two or three is a row of buttons (lease values, small sources).
- core.lease drawn as the chosen lease: picked by label; value by the lease's
type, range, values and example; target picker over target.source (F11);
minutes with the lease's longest hold.
- a timed step keeps its run open: a phase with no advance rule gets
after: <longest hold> when a timed step is added; a sentence under any phase
whose run can end sooner (U-6).
- Start asks "Publish and start" or "Start version N" when the working copy is
ahead of the published version (draftAhead on GET/PUT/publish; U-5).
- New event can start from a module's template.
- core's own examples name no game (§0.9).
Swagger, routes.manifest/guards and engagement-triggers.json regenerated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user