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
303 lines
14 KiB
JavaScript
303 lines
14 KiB
JavaScript
// ── The step editor's contract additions (MODULE_API 1.13.0) ───────────────
|
|
//
|
|
// Rust PLAN_REDESIGNS §2: event templates (D192), a param's `duration`, a lease's
|
|
// `example` and an option row's `note`. Proved the way eventModuleContract.test.js
|
|
// proves the rest of the seam — a real module in a real directory, scanned and
|
|
// registered by the real loader — because a test that staged by hand would pass
|
|
// against a loader that forwards none of it.
|
|
//
|
|
// The pool points at a closed port before anything is required. `draftAhead`
|
|
// reads a version, and that read is stubbed on the module object the model holds.
|
|
process.env.DB_HOST = '127.0.0.1'
|
|
process.env.DB_PORT = '59999'
|
|
|
|
const fs = require('fs')
|
|
const os = require('os')
|
|
const path = require('path')
|
|
|
|
const { test, beforeEach, after } = require('node:test')
|
|
const assert = require('node:assert/strict')
|
|
|
|
const express = require('express')
|
|
|
|
const db = require('../src/utils/db')
|
|
const registries = require('../src/modules/registries')
|
|
const definitions = require('../src/model/events/eventDefinitions.model')
|
|
const versionsDb = require('../src/model/events/eventVersions.db')
|
|
|
|
after(() => db.close())
|
|
|
|
let tmpRoot
|
|
|
|
const emptyTiers = () => ({
|
|
public: express.Router(),
|
|
admin: express.Router(),
|
|
player: express.Router(),
|
|
})
|
|
|
|
function loadModule(id, source, manifest = {}) {
|
|
const dir = path.join(tmpRoot, id)
|
|
fs.mkdirSync(dir, { recursive: true })
|
|
fs.writeFileSync(
|
|
path.join(dir, 'module.json'),
|
|
JSON.stringify({ id, name: id, version: '1.0.0', coreApi: '^1.13.0', server: 'index.js', ...manifest }),
|
|
)
|
|
fs.writeFileSync(path.join(dir, 'index.js'), source)
|
|
// A test that loads twice rewrites the same file, and require() would hand the
|
|
// loader the first one back.
|
|
delete require.cache[path.join(dir, 'index.js')]
|
|
|
|
process.env.MODULES_DIR = tmpRoot
|
|
registries._reset()
|
|
delete require.cache[require.resolve('../src/modules/loader')]
|
|
// eslint-disable-next-line global-require
|
|
const loader = require('../src/modules/loader')
|
|
loader.load(emptyTiers())
|
|
return loader.list().find((m) => m.id === id)
|
|
}
|
|
|
|
const assertRegistered = (record) => {
|
|
assert.equal(record.state, 'registered', record.reason || 'expected the module to register')
|
|
}
|
|
|
|
const assertFailed = (record, pattern) => {
|
|
assert.notEqual(record.state, 'registered', 'expected the module to be refused')
|
|
assert.match(record.reason || '', pattern)
|
|
}
|
|
|
|
beforeEach(() => {
|
|
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-step-editor-'))
|
|
})
|
|
|
|
// A template whose build() answers whatever the module's `mode` input says, so
|
|
// one registration covers every path build() can take.
|
|
const TEMPLATE_MODULE = `module.exports = (ctx, api) => {
|
|
api.registerEventTemplates([{
|
|
id: 'demo.template.weekend',
|
|
label: 'Weekend',
|
|
description: 'Two days of something.',
|
|
inputs: [
|
|
{ name: 'what', type: 'string', required: true, example: 'double gather' },
|
|
{ name: 'minutes', type: 'int', required: true, example: 60, duration: 'minutes' },
|
|
{ name: 'mode', type: 'string', required: false, example: 'ok' },
|
|
],
|
|
async build({ inputs }) {
|
|
if (inputs.mode === 'refuse') return { ok: false, error: 'that is not a weekend' }
|
|
if (inputs.mode === 'throw') throw new Error('database exploded at row 7')
|
|
if (inputs.mode === 'nothing') return null
|
|
if (inputs.mode === 'bad') return { title: 'Bad', spec: { phases: [{ key: 'Not A Key', steps: [] }] } }
|
|
return {
|
|
title: 'Weekend: ' + inputs.what,
|
|
summary: 'For ' + inputs.minutes + ' minutes.',
|
|
spec: {
|
|
phases: [{
|
|
key: 'hold',
|
|
label: 'Hold',
|
|
advance: { after: inputs.minutes + 'm' },
|
|
steps: [{ actionId: 'core.wait', params: { seconds: 5 } }],
|
|
}],
|
|
},
|
|
}
|
|
},
|
|
}])
|
|
}`
|
|
|
|
// ── Templates ─────────────────────────────────────────────────────────────
|
|
|
|
test('a module registers a template, and the catalog lists it without build()', () => {
|
|
assertRegistered(loadModule('demo', TEMPLATE_MODULE))
|
|
const [t] = registries.allEventTemplates()
|
|
assert.equal(t.id, 'demo.template.weekend')
|
|
assert.equal(t.owner, 'demo')
|
|
assert.equal(t.build, undefined)
|
|
// The inputs are params, checked by the same function: the duration survives.
|
|
assert.deepEqual(t.inputs.map((p) => [p.name, p.type, p.required, p.duration || null]), [
|
|
['what', 'string', true, null],
|
|
['minutes', 'int', true, 'minutes'],
|
|
['mode', 'string', false, null],
|
|
])
|
|
assert.equal(typeof registries.eventTemplate('demo.template.weekend').build, 'function')
|
|
})
|
|
|
|
test('a template outside its module\'s prefix, without build(), or asking too much is refused', () => {
|
|
assertFailed(loadModule('demo', `module.exports = (ctx, api) => {
|
|
api.registerEventTemplates([{ id: 'other.template.x', label: 'X', build() { return {} } }])
|
|
}`), /not namespaced "demo\."/)
|
|
|
|
assertFailed(loadModule('demo', `module.exports = (ctx, api) => {
|
|
api.registerEventTemplates([{ id: 'demo.template.x', label: 'X' }])
|
|
}`), /has no build\(\)/)
|
|
|
|
const nine = Array.from({ length: 9 }, (_, i) => `{ name: 'p${i}', type: 'string', example: 'x' }`).join(',')
|
|
assertFailed(loadModule('demo', `module.exports = (ctx, api) => {
|
|
api.registerEventTemplates([{ id: 'demo.template.x', label: 'X', inputs: [${nine}], build() { return {} } }])
|
|
}`), /asks for 9 inputs/)
|
|
|
|
// An input is a param, so it needs an example like every param does.
|
|
assertFailed(loadModule('demo', `module.exports = (ctx, api) => {
|
|
api.registerEventTemplates([{ id: 'demo.template.x', label: 'X', inputs: [{ name: 'a', type: 'string' }], build() { return {} } }])
|
|
}`), /registerEventTemplates: demo\.template\.x: param "a" needs an example/)
|
|
})
|
|
|
|
test('a filled-in template is a draft: validated, normalised, and saved nowhere', async () => {
|
|
assertRegistered(loadModule('demo', TEMPLATE_MODULE))
|
|
registries.registerCore()
|
|
|
|
const result = await definitions.buildFromTemplate('demo.template.weekend', { what: 'wood', minutes: 90 })
|
|
assert.equal(result.ok, true, JSON.stringify(result.errors))
|
|
assert.equal(result.draft.title, 'Weekend: wood')
|
|
assert.equal(result.draft.summary, 'For 90 minutes.')
|
|
assert.equal(result.draft.spec.phases[0].advance.after, '90m')
|
|
assert.equal(result.draft.spec.phases[0].steps[0].actionId, 'core.wait')
|
|
})
|
|
|
|
test('the inputs are checked before build() is called, in the author\'s words', async () => {
|
|
assertRegistered(loadModule('demo', TEMPLATE_MODULE))
|
|
registries.registerCore()
|
|
|
|
const missing = await definitions.buildFromTemplate('demo.template.weekend', { minutes: 10 })
|
|
assert.equal(missing.ok, false)
|
|
assert.equal(missing.status, 400)
|
|
assert.deepEqual(missing.errors, ['"what" is required'])
|
|
|
|
const extra = await definitions.buildFromTemplate('demo.template.weekend', { what: 'x', minutes: 10, colour: 'red' })
|
|
assert.equal(extra.ok, false)
|
|
assert.match(extra.errors[0], /"colour" is not a param of demo\.template\.weekend/)
|
|
|
|
const unknown = await definitions.buildFromTemplate('demo.template.nope', {})
|
|
assert.equal(unknown.status, 404)
|
|
})
|
|
|
|
test('every way build() can fail is an answer, and only a refusal reaches the author verbatim', async () => {
|
|
assertRegistered(loadModule('demo', TEMPLATE_MODULE))
|
|
registries.registerCore()
|
|
const base = { what: 'x', minutes: 10 }
|
|
|
|
const refused = await definitions.buildFromTemplate('demo.template.weekend', { ...base, mode: 'refuse' })
|
|
assert.equal(refused.status, 400)
|
|
assert.deepEqual(refused.errors, ['that is not a weekend'])
|
|
|
|
// A throw is logged, not shown: the module's internals are not the author's.
|
|
const threw = await definitions.buildFromTemplate('demo.template.weekend', { ...base, mode: 'throw' })
|
|
assert.equal(threw.status, 502)
|
|
assert.doesNotMatch(threw.errors.join(' '), /database exploded/)
|
|
assert.match(threw.errors[0], /"Weekend" could not be filled in/)
|
|
|
|
const nothing = await definitions.buildFromTemplate('demo.template.weekend', { ...base, mode: 'nothing' })
|
|
assert.equal(nothing.status, 502)
|
|
|
|
const bad = await definitions.buildFromTemplate('demo.template.weekend', { ...base, mode: 'bad' })
|
|
assert.equal(bad.status, 502)
|
|
assert.match(bad.errors[0], /made a plan core cannot accept/)
|
|
assert.ok(bad.errors.length > 1, 'the spec errors ride along for whoever reads the response')
|
|
})
|
|
|
|
// ── A param's duration ────────────────────────────────────────────────────
|
|
|
|
test('a duration is minutes, on a number, and the catalog carries it', () => {
|
|
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
|
|
api.registerEventActions([{
|
|
id: 'demo.hold', label: 'Hold', risk: 'notify', reversible: 'none', version: 1,
|
|
params: [{ name: 'minutes', type: 'int', required: true, example: 5, duration: 'minutes' }],
|
|
async perform() { return { ok: true } },
|
|
}])
|
|
}`))
|
|
assert.equal(registries.eventAction('demo.hold').params[0].duration, 'minutes')
|
|
|
|
assertFailed(loadModule('demo', `module.exports = (ctx, api) => {
|
|
api.registerEventActions([{
|
|
id: 'demo.hold', label: 'Hold', risk: 'notify', reversible: 'none', version: 1,
|
|
params: [{ name: 'for', type: 'string', example: '5m', duration: 'minutes' }],
|
|
async perform() { return { ok: true } },
|
|
}])
|
|
}`), /is a duration and must be int or float/)
|
|
|
|
assertFailed(loadModule('demo', `module.exports = (ctx, api) => {
|
|
api.registerEventActions([{
|
|
id: 'demo.hold', label: 'Hold', risk: 'notify', reversible: 'none', version: 1,
|
|
params: [{ name: 'hours', type: 'int', example: 2, duration: 'hours' }],
|
|
async perform() { return { ok: true } },
|
|
}])
|
|
}`), /has duration "hours"; the one unit is minutes/)
|
|
})
|
|
|
|
test('core.lease\'s minutes is a duration, and its examples name no game', () => {
|
|
registries._reset()
|
|
registries.registerCore()
|
|
const lease = registries.eventAction('core.lease')
|
|
assert.equal(lease.params.find((p) => p.name === 'minutes').duration, 'minutes')
|
|
// The params without a duration do not grow the key at all.
|
|
assert.equal('duration' in lease.params.find((p) => p.name === 'value'), false)
|
|
const words = registries.allEventActions()
|
|
.filter((a) => a.owner === 'core')
|
|
.flatMap((a) => a.params.map((p) => String(p.example)))
|
|
.join(' ')
|
|
assert.doesNotMatch(words, /\buo\.|Britain|Cove|skillgain/)
|
|
})
|
|
|
|
// ── A lease's example ─────────────────────────────────────────────────────
|
|
|
|
const leaseModule = (example) => `module.exports = (ctx, api) => {
|
|
api.registerEventLeases([{
|
|
id: 'demo.flag', label: 'Flag', type: 'bool', maxDurationMs: 3600000,
|
|
${example === undefined ? '' : `example: ${example},`}
|
|
async read() { return { ok: true, value: false } },
|
|
async apply() { return { ok: true } },
|
|
async restore() { return { ok: true } },
|
|
}])
|
|
}`
|
|
|
|
test('a lease may carry an example for its value, and it is served', () => {
|
|
assertRegistered(loadModule('demo', leaseModule('true')))
|
|
assert.equal(registries.allEventLeases()[0].example, true)
|
|
|
|
assertRegistered(loadModule('demo', leaseModule(undefined)))
|
|
assert.equal(registries.allEventLeases()[0].example, null)
|
|
|
|
assertFailed(loadModule('demo', leaseModule('{ on: true }')), /example must be a single value/)
|
|
})
|
|
|
|
// ── An option's note ──────────────────────────────────────────────────────
|
|
|
|
test('an option may carry a note, kept as text and bounded', async () => {
|
|
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
|
|
api.registerEventOptionSources([{
|
|
id: 'demo.options.kits', label: 'Kits',
|
|
async resolve() {
|
|
return [
|
|
{ value: 'vip', label: 'VIP', note: '9 of 41 linked players have used it up' },
|
|
{ value: 'long', note: 'x'.repeat(500) },
|
|
{ value: 'plain' },
|
|
]
|
|
},
|
|
}])
|
|
}`))
|
|
const answer = await registries.resolveOptionSource('demo.options.kits')
|
|
assert.equal(answer.options[0].note, '9 of 41 linked players have used it up')
|
|
assert.equal(answer.options[1].note.length, 300)
|
|
assert.equal('note' in answer.options[2], false)
|
|
})
|
|
|
|
// ── The draft ahead of its published version ──────────────────────────────
|
|
|
|
test('draftAhead compares the working copy with the published spec as data', async (t) => {
|
|
const published = { phases: [{ key: 'a', steps: [{ actionId: 'core.wait', params: { seconds: 5 } }] }] }
|
|
const original = versionsDb.getById
|
|
t.after(() => { versionsDb.getById = original })
|
|
versionsDb.getById = async (id) => (id === 7 ? { id: 7, spec: published } : null)
|
|
|
|
// Never published: nothing to be ahead of.
|
|
assert.equal(await definitions.draftAhead({ current_version_id: null, spec: published }), false)
|
|
|
|
// The same plan with its keys in another order is not an edit.
|
|
const reordered = { phases: [{ steps: [{ params: { seconds: 5 }, actionId: 'core.wait' }], key: 'a' }] }
|
|
assert.equal(await definitions.draftAhead({ current_version_id: 7, spec: reordered }), false)
|
|
|
|
const edited = { phases: [{ key: 'a', steps: [{ actionId: 'core.wait', params: { seconds: 6 } }] }] }
|
|
assert.equal(await definitions.draftAhead({ current_version_id: 7, spec: edited }), true)
|
|
|
|
// A version that cannot be found answers false rather than asking the author
|
|
// to choose between two plans one of which is missing.
|
|
assert.equal(await definitions.draftAhead({ current_version_id: 8, spec: edited }), false)
|
|
})
|