Files
website/client/test/eventStepEditor.test.js
wtclaude 3e243ce772
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
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
2026-10-12 02:08:04 -05:00

145 lines
6.2 KiB
JavaScript

// The step editor's own rules (MODULE_API 1.13.0; Rust PLAN_REDESIGNS §2): which
// control a field gets, which required fields are empty, and whether a phase
// gives a timed step back before its time is up.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import {
boolValue,
isSegmentable,
noteFor,
missingParams,
afterMinutes,
stepMinutes,
runFloorMinutes,
timedPhaseShortfall,
guardedAdvance,
shortfallSentence,
minutesWord,
blankAdvance,
} from '../src/lib/eventAuthoring.js'
const LEASE_ACTION = {
id: 'core.lease',
params: [
{ name: 'lease', type: 'string', required: true },
{ name: 'value', type: 'string', required: true },
{ name: 'target', type: 'string', required: false },
{ name: 'minutes', type: 'int', required: true, duration: 'minutes' },
],
}
const SAY = { id: 'test.say', params: [{ name: 'message', type: 'string', required: true }] }
const actionById = new Map([[LEASE_ACTION.id, LEASE_ACTION], [SAY.id, SAY]])
const step = (actionId, params) => ({ actionId, paramsText: JSON.stringify(params) })
const lease = (minutes) => step('core.lease', { lease: 'x.flag', value: 'true', minutes })
const phase = (steps, advance = blankAdvance()) => ({ key: 'p', label: 'P', steps, advance })
const after = (a) => ({ ...blankAdvance(), kind: 'after', after: a })
const on = () => ({ ...blankAdvance(), kind: 'on', on: 'x.happened' })
test('a lease\'s "true" is a boolean to the control', () => {
assert.equal(boolValue('true'), true)
assert.equal(boolValue(false), false)
assert.equal(boolValue(''), null)
assert.equal(boolValue(undefined), null)
})
test('two or three answered choices are buttons; a search or a long list is not', () => {
const ok = (n, extra = {}) => ({ state: 'ok', options: Array.from({ length: n }, (_, i) => ({ value: String(i) })), ...extra })
assert.equal(isSegmentable(ok(2)), true)
assert.equal(isSegmentable(ok(3)), true)
assert.equal(isSegmentable(ok(1)), false)
assert.equal(isSegmentable(ok(4)), false)
assert.equal(isSegmentable(ok(2, { searchable: true })), false)
assert.equal(isSegmentable({ state: 'failed', options: [] }), false)
})
test('the chosen option\'s note, and nothing for one without', () => {
const entry = { state: 'ok', options: [{ value: 'vip', note: '9 of 41 used it up' }, { value: 'free' }] }
assert.equal(noteFor(entry, 'vip'), '9 of 41 used it up')
assert.equal(noteFor(entry, 'free'), null)
assert.equal(noteFor(entry, ''), null)
})
test('required and empty, including the target a targeted lease needs', () => {
assert.deepEqual(missingParams(step('test.say', {}), SAY), ['message'])
assert.deepEqual(missingParams(step('test.say', { message: 'hi' }), SAY), [])
const leases = new Map([['x.perm', { id: 'x.perm', target: { label: 'Group' } }], ['x.flag', { id: 'x.flag' }]])
assert.deepEqual(
missingParams(step('core.lease', { lease: 'x.perm', value: 'true', minutes: 5 }), LEASE_ACTION, leases),
['target'],
)
assert.deepEqual(missingParams(lease(5), LEASE_ACTION, leases), [])
// A step whose JSON does not parse is the JSON box's to report, not this.
assert.deepEqual(missingParams({ actionId: 'test.say', paramsText: '{' }, SAY), [])
})
test('the server\'s after grammar, in minutes', () => {
assert.equal(afterMinutes('30m'), 30)
assert.equal(afterMinutes('2h'), 120)
assert.equal(afterMinutes('90s'), 1.5)
assert.equal(afterMinutes('1d'), 1440)
assert.equal(afterMinutes('soon'), null)
})
test('a step is as long as its longest declared duration', () => {
assert.equal(stepMinutes(lease(10), LEASE_ACTION), 10)
assert.equal(stepMinutes(step('test.say', { message: 'x' }), SAY), 0)
assert.equal(stepMinutes(lease('ten'), LEASE_ACTION), 0)
})
test('the run\'s floor counts every later phase, and a game trigger is unbounded', () => {
const phases = [phase([]), phase([], after('5m')), phase([], after('1h'))]
assert.equal(runFloorMinutes(phases, 0), 65)
assert.equal(runFloorMinutes(phases, 2), 60)
assert.equal(runFloorMinutes([phase([]), phase([], on())], 0), Infinity)
})
test('U-6: a one-phase event with no rule gives a 10-minute lease back at once', () => {
const phases = [phase([lease(10)])]
assert.deepEqual(timedPhaseShortfall(phases, 0, actionById), { longest: 10, floor: 0 })
assert.equal(
shortfallSentence(phases, 0, actionById),
'This phase ends as soon as its steps are done, and the run with it; what is held for 10 minutes is given back then, not after 10 minutes.',
)
})
test('a phase with no rule is given after: <longest>, and a chosen rule is left alone', () => {
const advance = guardedAdvance([phase([lease(10)])], 0, actionById)
assert.equal(advance.kind, 'after')
assert.equal(advance.after, '10m')
// The author's 5 minutes stays; the sentence says what it costs instead.
const chosen = [phase([lease(10)], after('5m'))]
assert.equal(guardedAdvance(chosen, 0, actionById), null)
assert.equal(
shortfallSentence(chosen, 0, actionById),
'This phase ends after 5 minutes, and the run with it; what is held for 10 minutes is given back then, not after 10 minutes.',
)
})
test('later phases long enough to cover the hold are no shortfall at all', () => {
const phases = [phase([lease(10)]), phase([], after('15m'))]
assert.equal(timedPhaseShortfall(phases, 0, actionById), null)
assert.equal(guardedAdvance(phases, 0, actionById), null)
assert.equal(shortfallSentence(phases, 0, actionById), null)
// A later phase that waits on the game cannot be counted, and is not warned about.
assert.equal(timedPhaseShortfall([phase([lease(10)]), phase([], on())], 0, actionById), null)
const short = [phase([lease(10)]), phase([], after('3m'))]
assert.match(shortfallSentence(short, 0, actionById), /^The rest of this run can be over 3 minutes after this phase starts;/)
})
test('an untimed phase is never touched', () => {
const phases = [phase([step('test.say', { message: 'x' })])]
assert.equal(guardedAdvance(phases, 0, actionById), null)
assert.equal(shortfallSentence(phases, 0, actionById), null)
})
test('minutes read as words', () => {
assert.equal(minutesWord(1), '1 minute')
assert.equal(minutesWord(45), '45 minutes')
assert.equal(minutesWord(120), '2 hours')
assert.equal(minutesWord(90), '90 minutes')
})