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:
@@ -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.',
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user