// 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: , 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') })