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