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

@@ -625,6 +625,152 @@ export function setParam(step, name, raw, type) {
*/
export const datetimeInputValue = (value) => (typeof value === 'string' ? value.slice(0, 16) : '')
// ── The step editor's own rules (MODULE_API 1.13.0; Rust PLAN_REDESIGNS §2) ──
//
// What the controls need to decide and nothing the server re-decides: which
// control a field gets, which required fields are empty, and whether a phase
// gives a timed step back before its time is up. The server still refuses a
// missing param by name — these exist so the form can say it first, in place.
/**
* A boolean as the control holds it. A lease's value is a string param that the
* lease reads as a boolean, so "true" and "false" are booleans here too.
*/
export function boolValue(value) {
if (value === true || value === 'true') return true
if (value === false || value === 'false') return false
return null
}
/**
* Should this source be drawn as a row of buttons? (§2.4)
*
* Two or three choices that fit on one line, from a source that answered and is
* not searched. A dropdown of two hides the other choice behind a click.
*/
export function isSegmentable(entry) {
return Boolean(
entry && entry.state === 'ok' && !entry.searchable &&
entry.options.length >= 2 && entry.options.length <= 3,
)
}
/** The note on the chosen option, if it carries one (§2.6). */
export function noteFor(entry, value) {
if (!entry || entry.state !== 'ok' || value === undefined || value === null || value === '') return null
const chosen = entry.options.find((o) => o.value === String(value))
return chosen?.note || null
}
/**
* The required params a step leaves empty, by name. `core.lease`'s `target` is
* optional on the action and required by a targeted LEASE, so the lease is
* asked too when there is one.
*/
export function missingParams(step, action, leasesById = new Map()) {
if (!action) return []
const parsed = parseParams(step?.paramsText)
if (parsed.error) return []
const empty = (v) => v === undefined || v === null || v === ''
const missing = (action.params || []).filter((p) => p.required && empty(parsed.params[p.name])).map((p) => p.name)
if (action.id === 'core.lease') {
const lease = leasesById.get(parsed.params.lease)
if (lease?.target && empty(parsed.params.target)) missing.push('target')
}
return missing
}
/** `30m`, `2h`, `90s`, `1d` → minutes. Null for anything the server would refuse. */
export function afterMinutes(after) {
const m = /^(\d{1,6})(s|m|h|d)$/.exec(String(after ?? '').trim())
if (!m) return null
const n = Number(m[1])
return { s: n / 60, m: n, h: n * 60, d: n * 1440 }[m[2]]
}
/** The longest declared duration in a step, in minutes; 0 for an untimed one. */
export function stepMinutes(step, action) {
const parsed = parseParams(step?.paramsText)
if (parsed.error || !action) return 0
let longest = 0
for (const p of action.params || []) {
if (p.duration !== 'minutes') continue
const n = Number(parsed.params[p.name])
if (Number.isFinite(n) && n > longest) longest = n
}
return longest
}
/** The longest timed step in a phase, in minutes. */
export const phaseMinutes = (phase, actionById) =>
Math.max(0, ...(phase?.steps || []).map((s) => stepMinutes(s, actionById.get(s.actionId))))
/**
* How long, at the least, the run goes on from the start of phase `pi`, in
* minutes — or Infinity when a phase from there on waits for something in the
* game, which may take any time at all.
*
* **Why the RUN and not the phase.** Teardown gives everything back when the run
* ends, so a 10-minute hold in phase 1 of three is safe if phases 2 and 3 take
* ten minutes between them. A phase with no advance rule ends when its steps do,
* which is counted as no time: a floor, never a promise.
*/
export function runFloorMinutes(phases, pi) {
let total = 0
for (const phase of (phases || []).slice(pi)) {
const kind = phase.advance?.kind || ''
if (kind === 'on') return Infinity
if (kind === 'after') total += afterMinutes(phase.advance.after) || 0
}
return total
}
/**
* Does phase `pi` give a timed step back before its time? (§2.2, U-6)
*
* `null`, or `{ longest, floor }` in minutes. Only a run that can END sooner than
* the longest hold counts: teardown is what gives a lease back, not the phase.
*/
export function timedPhaseShortfall(phases, pi, actionById) {
const longest = phaseMinutes(phases?.[pi], actionById)
if (!longest) return null
const floor = runFloorMinutes(phases, pi)
return floor < longest ? { longest, floor } : null
}
/**
* The phase's advance after a timed step was added or changed (§2.2).
*
* A phase with NO advance rule that would give the step back early gets
* `after: '<longest>m'`, visibly, and the author may change it. A phase the
* author already gave a rule keeps it: the warning under the phase says what it
* costs, and overriding an author's choice is not this function's to do.
*/
export function guardedAdvance(phases, pi, actionById) {
const phase = phases?.[pi]
if (!phase || (phase.advance?.kind || '') !== '') return null
const short = timedPhaseShortfall(phases, pi, actionById)
if (!short) return null
return { ...(phase.advance || blankAdvance()), kind: 'after', after: `${Math.ceil(short.longest)}m` }
}
/** "1 minute", "90 minutes", "2 hours". */
export function minutesWord(n) {
if (n >= 120 && n % 60 === 0) return `${n / 60} hours`
return `${n} minute${n === 1 ? '' : 's'}`
}
/** The sentence under a phase that ends a timed step early, or null. */
export function shortfallSentence(phases, pi, actionById) {
const short = timedPhaseShortfall(phases, pi, actionById)
if (!short) return null
const last = pi === (phases || []).length - 1
const ends = short.floor === 0
? (last ? 'This phase ends as soon as its steps are done, and the run with it' : 'The rest of this run can be over as soon as its steps are done')
: (last ? `This phase ends after ${minutesWord(short.floor)}, and the run with it` : `The rest of this run can be over ${minutesWord(short.floor)} after this phase starts`)
return `${ends}; what is held for ${minutesWord(short.longest)} is given back then, not after ${minutesWord(short.longest)}.`
}
/**
* Everything the meter needs out of the form, and nothing else.
*