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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user