diff --git a/client/src/lib/eventAuthoring.js b/client/src/lib/eventAuthoring.js index cd99f9d..2867eb3 100644 --- a/client/src/lib/eventAuthoring.js +++ b/client/src/lib/eventAuthoring.js @@ -133,7 +133,9 @@ const nextPhaseKey = (phases) => { export function blankStep(action) { const params = {} for (const p of action?.params || []) { - if (p.required || p.example !== undefined) params[p.name] = p.example + // A list's example is one value (MODULE_API 1.14.0), so the step starts + // holding that one. + if (p.required || p.example !== undefined) params[p.name] = p.multiple ? [p.example] : p.example } return { actionId: action?.id || '', @@ -545,7 +547,9 @@ export const PARAM_JSON = 'json' * is what the author needs to see — and a form that dropped it would post a * step that saves cleanly having deleted something they typed. * • **a value no single control can hold** — an object or an array against a - * scalar declaration. + * scalar declaration, or anything but a flat list against a `multiple` one + * (MODULE_API 1.14.0). A bare value there is a list of one, as the server + * reads it, and the multi-select shows it as one chip. */ export function paramsRenderable(action, params) { if (!action) return { ok: false, reason: 'the module that registered this action is not installed' } @@ -554,6 +558,13 @@ export function paramsRenderable(action, params) { if (!declared.has(name)) { return { ok: false, reason: `this step carries "${name}", which ${action.id} does not declare` } } + if (declared.get(name).multiple) { + const items = Array.isArray(value) ? value : [value] + if (items.some((v) => v !== null && typeof v === 'object')) { + return { ok: false, reason: `"${name}" holds a structure, which a list of values cannot hold` } + } + continue + } if (value !== null && typeof value === 'object') { return { ok: false, reason: `"${name}" holds a ${Array.isArray(value) ? 'list' : 'structure'}, which no single field can hold` } } @@ -625,6 +636,81 @@ export function setParam(step, name, raw, type) { */ export const datetimeInputValue = (value) => (typeof value === 'string' ? value.slice(0, 16) : '') +// ── A param that holds several values (MODULE_API 1.14.0, Rust PLAN_REDESIGNS §12.3) ── + +/** + * A `multiple` param's value as the list the multi-select draws: an array as + * stored, a bare value as a list of one (the server's own reading), and nothing + * as the empty list. + */ +export function listValue(value) { + if (value === undefined || value === null || value === '') return [] + return Array.isArray(value) ? value : [value] +} + +/** + * Write a `multiple` param's whole list, and give back the whole box. + * + * Each item is coerced as `setParam` coerces one value, duplicates dropped + * keeping the first, and **an empty list removes the key** — `setParam`'s rule, + * for its reason: the server reads absent and `[]` alike, and "is required" is + * the error a required list left empty should get. + */ +export function setParamList(step, name, items, type) { + const parsed = parseParams(step?.paramsText) + if (parsed.error) return step?.paramsText || '{}' + const next = { ...parsed.params } + const out = [] + for (const raw of items || []) { + if (raw === '' || raw === undefined || raw === null) continue + const value = coerceLiteral(type, raw) + if (!out.some((v) => v === value)) out.push(value) + } + if (out.length) next[name] = out + else delete next[name] + return JSON.stringify(next, null, 2) +} + +/** + * Add one value to a list, or say why not. `{ list }` or `{ refused }`, so the + * control can put the sentence where the click was rather than silently doing + * nothing past `max`. + */ +export function addToList(list, value, max) { + const text = String(value ?? '').trim() + if (!text) return { list } + if (list.some((v) => String(v) === text)) return { list } + if (max && list.length >= max) return { refused: `This takes at most ${max} — remove one first.` } + return { list: [...list, text] } +} + +/** + * The options a filter box leaves, matched on value, label and group alike, + * case-insensitively. The order is the source's own — a source sorts its rows + * (§12.2) and the editor keeps it. + */ +export function filterOptions(options, term) { + const t = String(term || '').trim().toLowerCase() + if (!t) return options || [] + return (options || []).filter((o) => + [o.value, o.label, o.group].some((s) => String(s || '').toLowerCase().includes(t))) +} + +/** + * One step's params as a line the run console can show: `name: value`, a list + * joined by commas, anything else as its JSON. Shown rather than the raw object + * so a list of ten permissions reads as ten names, not as brackets and quotes. + */ +export function describeParams(params) { + if (!params || typeof params !== 'object') return '' + return Object.entries(params) + .map(([k, v]) => { + if (Array.isArray(v) && v.every((x) => x === null || typeof x !== 'object')) return `${k}: ${v.join(', ')}` + return `${k}: ${typeof v === 'string' ? v : JSON.stringify(v)}` + }) + .join(' · ') +} + // ── 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 @@ -671,7 +757,7 @@ 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 empty = (v) => v === undefined || v === null || v === '' || (Array.isArray(v) && v.length === 0) 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) diff --git a/client/src/modules/version.js b/client/src/modules/version.js index 8ed171c..15f1ecd 100644 --- a/client/src/modules/version.js +++ b/client/src/modules/version.js @@ -11,6 +11,9 @@ // that the two files can drift, so a test asserts they agree // (client/test/moduleRegistry.test.js) rather than trusting a bump to remember // both. +// 1.14.0 — an action param's `multiple` (Rust PLAN_REDESIGNS §12.3): a param +// that holds several values, drawn by core's step editor as a multi-select. +// Server-side declaration; this file bumps for the reason at the top. // 1.13.0 — the event step editor (Rust PLAN_REDESIGNS §2): event templates, a // param's `duration`, a lease's `example` and an option's `note`. All four are // server-side declarations the editor, core's own page, renders. This file bumps @@ -85,4 +88,4 @@ // but the two halves state ONE version: a module declares a single coreApi range // and is served one chunk, so a client that claimed 1.0.0 while the server // answered 1.1.0 would be two answers to one question. -export const MODULE_API_VERSION = '1.13.0' +export const MODULE_API_VERSION = '1.14.0' diff --git a/client/src/routes/admin/views/EventEditor.jsx b/client/src/routes/admin/views/EventEditor.jsx index f48bc25..d418fa7 100644 --- a/client/src/routes/admin/views/EventEditor.jsx +++ b/client/src/routes/admin/views/EventEditor.jsx @@ -22,6 +22,10 @@ import { paramsMode, paramValue, setParam, + setParamList, + listValue, + addToList, + filterOptions, datetimeInputValue, priceBodyFrom, worthPricing, @@ -456,6 +460,154 @@ function ParamField({ param, value, sourceEntry, disabled, onChange, missing, la ) } +/** + * A param that holds several values, as a multi-select (MODULE_API 1.14.0; Rust + * PLAN_REDESIGNS §12.3, D344). + * + * The chosen values are chips above the control, each with its remove button, + * and the control is whichever the param's source allows: + * + * • **a source that answered** — a grouped checklist in the source's own order, + * with a filter box over it, because a server's few hundred permissions are a + * list to narrow, not to scroll; + * • **a searchable source** — the typeahead, each pick adding a chip; + * • **no source, or one that failed** — a tag box, a value per Enter. A failure + * is said and the field stays usable, the single-value field's rule (§F). + * + * Ticking past `max` is refused in words, where the click was. The order the + * author picked in is the order stored, and the server keeps it. + */ +function MultiParamField({ param, value, sourceEntry, disabled, onChange, missing }) { + const list = listValue(value) + const [term, setTerm] = useState('') + const [typed, setTyped] = useState('') + const [refused, setRefused] = useState(null) + const max = param.max || 20 + + const answered = sourceEntry && sourceEntry.state === 'ok' + const byValue = new Map(answered ? sourceEntry.options.map((o) => [o.value, o]) : []) + const add = (v) => { + const next = addToList(list, v, max) + if (next.refused) setRefused(next.refused) + else { + setRefused(null) + onChange(next.list) + } + } + const remove = (v) => { + setRefused(null) + onChange(list.filter((x) => String(x) !== String(v))) + } + const toggle = (v) => (list.some((x) => String(x) === v) ? remove(v) : add(v)) + + const shown = answered && !sourceEntry.searchable ? filterOptions(sourceEntry.options, term) : [] + const groups = [...new Set(shown.map((o) => o.group || ''))] + const chosenNotes = list + .map((v) => ({ v, note: byValue.get(String(v))?.note })) + .filter((x) => x.note) + + const tagBox = ( +
+ setTyped(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + add(typed) + setTyped('') + } + }} /> + +
+ ) + + let control + if (param.source && sourceEntry?.searchable) { + control = ( + + ) + } else if (param.source && answered && sourceEntry.options.length) { + control = ( +
+ setTerm(e.target.value)} /> +
+ {shown.length === 0 && ( +
Nothing matches “{term}”.
+ )} + {groups.map((g) => ( +
+ {g &&
{g}
} + {shown.filter((o) => (o.group || '') === g).map((o) => ( + + ))} +
+ ))} +
+
+ ) + } else { + control = ( + <> + {param.source && ( + + )} + {tagBox} + + ) + } + + return ( +
+ + {param.name} · {list.length} of at most {max} + +
+ {list.length === 0 && None chosen yet.} + {list.map((v) => ( + + {byValue.get(String(v))?.label || String(v)} + + + ))} +
+ {refused && ( + {refused} + )} + {control} + {missing && ( + + This is required — choose at least one before saving. + + )} + {param.description && ( + {param.description} + )} + {chosenNotes.map(({ v, note }) => ( + + {byValue.get(String(v))?.label || String(v)}: {note} + + ))} +
+ ) +} + /** * `core.lease`, drawn as the chosen lease (Rust PLAN_REDESIGNS §2.1, U-4, F11). * @@ -1971,7 +2123,17 @@ export default function EventEditor() { { timed: name === 'lease' || Boolean(declared.duration) }) }} /> - ) : (action?.params || []).map((p) => ( + ) : (action?.params || []).map((p) => (p.multiple ? ( + setStep(pi, si, { paramsText: setParamList(step, p.name, list, p.type) })} + /> + ) : ( setStepParam(pi, si, step, p, raw)} /> - ))} + )))} {action && (action.params || []).length === 0 && (

This action takes no params. @@ -2002,7 +2164,7 @@ export default function EventEditor() { {action && (action.params || []).length > 0 && ( {action.id} declares{' '} - {(action.params || []).map((p) => `${p.name} (${p.type}${p.required ? ', required' : ''})`).join(', ')}. + {(action.params || []).map((p) => `${p.name} (${p.multiple ? `a list of ${p.type}, at most ${p.max}` : p.type}${p.required ? ', required' : ''})`).join(', ')}. )} diff --git a/client/src/routes/admin/views/EventRun.jsx b/client/src/routes/admin/views/EventRun.jsx index 69c3e59..ed4f660 100644 --- a/client/src/routes/admin/views/EventRun.jsx +++ b/client/src/routes/admin/views/EventRun.jsx @@ -9,6 +9,7 @@ import { runControlsFor, stepControlsFor, describeLogLine, + describeParams, } from '../../../lib/eventAuthoring.js' // Admin → Events → the run console (EVENTS.md §I, Phase 3). @@ -655,7 +656,7 @@ export default function EventRun() { {step.actionId}

- {JSON.stringify(step.params)} + {describeParams(step.params)}
diff --git a/client/test/eventParamMultiple.test.js b/client/test/eventParamMultiple.test.js new file mode 100644 index 0000000..c717e97 --- /dev/null +++ b/client/test/eventParamMultiple.test.js @@ -0,0 +1,94 @@ +// A param that holds several values (MODULE_API 1.14.0; Rust PLAN_REDESIGNS +// §12.3): what the multi-select stores, when it gives way to JSON, and how the +// run console reads a list back. +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { + blankStep, + paramsMode, + PARAM_FORM, + PARAM_JSON, + listValue, + setParamList, + addToList, + filterOptions, + missingParams, + describeParams, +} from '../src/lib/eventAuthoring.js' + +const LEND = { + id: 'test.perm.lend', + params: [ + { name: 'server', type: 'string', required: true, example: 'main' }, + { name: 'perms', type: 'string', required: true, example: 'nightvision.allowed', multiple: true, max: 3 }, + { name: 'ids', type: 'int', required: false, example: 7, multiple: true, max: 20 }, + ], +} +const step = (params) => ({ actionId: LEND.id, paramsText: JSON.stringify(params) }) + +test('a new step holds the example as a list of one', () => { + const fresh = JSON.parse(blankStep(LEND).paramsText) + assert.deepEqual(fresh.perms, ['nightvision.allowed']) + assert.deepEqual(fresh.ids, [7]) + assert.equal(fresh.server, 'main') +}) + +test('a list is a value the form can hold; a structure in it is not', () => { + assert.equal(paramsMode(step({ perms: ['a', 'b'] }), LEND).mode, PARAM_FORM) + assert.equal(paramsMode(step({ perms: 'a' }), LEND).mode, PARAM_FORM) + const nested = paramsMode(step({ perms: [{ a: 1 }] }), LEND) + assert.equal(nested.mode, PARAM_JSON) + assert.match(nested.reason, /"perms" holds a structure/) + // A list against a single-value param still forces JSON, as before 1.14.0. + assert.equal(paramsMode(step({ server: ['main'] }), LEND).mode, PARAM_JSON) +}) + +test('listValue reads a bare value as a list of one, and nothing as none', () => { + assert.deepEqual(listValue(['a', 'b']), ['a', 'b']) + assert.deepEqual(listValue('a'), ['a']) + for (const empty of [undefined, null, '']) assert.deepEqual(listValue(empty), []) +}) + +test('setParamList coerces, drops duplicates in order, and removes an empty list', () => { + const s = step({ server: 'main' }) + assert.deepEqual(JSON.parse(setParamList(s, 'ids', ['3', '1', '3'], 'int')).ids, [3, 1]) + assert.deepEqual(JSON.parse(setParamList(s, 'perms', ['b', 'a', 'b', ''], 'string')).perms, ['b', 'a']) + const cleared = JSON.parse(setParamList(step({ server: 'main', perms: ['a'] }), 'perms', [], 'string')) + assert.equal('perms' in cleared, false) + // A box that does not parse is handed back untouched. + assert.equal(setParamList({ paramsText: '{nope' }, 'perms', ['a'], 'string'), '{nope') +}) + +test('addToList ignores blanks and repeats, and refuses past max in words', () => { + assert.deepEqual(addToList(['a'], ' b ', 3).list, ['a', 'b']) + assert.deepEqual(addToList(['a'], 'a', 3).list, ['a']) + assert.deepEqual(addToList(['a'], ' ', 3).list, ['a']) + assert.match(addToList(['a', 'b', 'c'], 'd', 3).refused, /at most 3/) +}) + +test('filterOptions matches value, label or group and keeps the source order', () => { + const options = [ + { value: 'nightvision.allowed', label: 'nightvision.allowed', group: 'NightVision' }, + { value: 'betterchat.color', label: 'betterchat.color', group: 'BetterChat' }, + { value: 'kits.vip', label: 'kits.vip', group: 'Kits' }, + ] + assert.equal(filterOptions(options, '').length, 3) + assert.deepEqual(filterOptions(options, 'NIGHT').map((o) => o.value), ['nightvision.allowed']) + assert.deepEqual(filterOptions(options, 'chat').map((o) => o.value), ['betterchat.color']) + assert.deepEqual(filterOptions(options, 'i').map((o) => o.value), ['nightvision.allowed', 'kits.vip']) +}) + +test('an empty required list is missing', () => { + assert.deepEqual(missingParams(step({ server: 'main', perms: [] }), LEND), ['perms']) + assert.deepEqual(missingParams(step({ server: 'main' }), LEND), ['perms']) + assert.deepEqual(missingParams(step({ server: 'main', perms: ['a'] }), LEND), []) +}) + +test('the run console shows a list as its values joined by commas', () => { + assert.equal( + describeParams({ server: 'main', perms: ['nightvision.allowed', 'betterchat.color'], confirm: true }), + 'server: main · perms: nightvision.allowed, betterchat.color · confirm: true', + ) + assert.equal(describeParams({ where: { a: 1 } }), 'where: {"a":1}') + assert.equal(describeParams(null), '') +}) diff --git a/server/engagement-triggers.json b/server/engagement-triggers.json index 46bc521..430f963 100644 --- a/server/engagement-triggers.json +++ b/server/engagement-triggers.json @@ -1,6 +1,6 @@ { "_comment": "Generated event-trigger inventory - the authoritative freeze of CORE's engagement contract (docs/website/ENGAGEMENT.md 4.3). Regenerate with `npm run engagement:manifest` in website/server. A renamed variable, a changed type or a widened ceiling breaks stored templates and rules, so the diff here is the review signal. A module ships its own copy in its bundle; this file never contains one.", - "moduleApiVersion": "1.13.0", + "moduleApiVersion": "1.14.0", "triggers": [ { "id": "event.phase.changed", diff --git a/server/src/events/authorize.js b/server/src/events/authorize.js index acf88b7..c3b5130 100644 --- a/server/src/events/authorize.js +++ b/server/src/events/authorize.js @@ -144,7 +144,9 @@ function priceOf(action, params) { function dimensionsOf(action) { const params = {} for (const p of action?.params || []) { - if (p.example !== undefined && p.example !== null) params[p.name] = p.example + // A `multiple` param's example is one value and `cost()` is handed a list + // (1.14.0), so it is priced as a list of one. + if (p.example !== undefined && p.example !== null) params[p.name] = p.multiple ? [p.example] : p.example } const priced = priceOf(action, params) return priced ? Object.keys(priced).sort() : [] diff --git a/server/src/events/spec.js b/server/src/events/spec.js index 8b1afa9..a9f3401 100644 --- a/server/src/events/spec.js +++ b/server/src/events/spec.js @@ -323,6 +323,37 @@ function validateAdvance(raw, path, errors) { return { on: triggerId, where: checked.conditions, count, dormant: false } } +/** + * A `multiple` param's value as the list it is (MODULE_API 1.14.0, Rust + * PLAN_REDESIGNS §12.3). + * + * `{ value: [...] }` or `{ error }`. **A bare value is a list of one**, so a + * spec written by hand or over the API before its author knew the param took + * several does not trip on it. Absent, `null`, `''` and `[]` are all the empty + * list, which the caller reads as unset — `required` means at least one. + * + * Each element is the type's literal, checked by the same `checkLiteral` a + * single value is; an empty string inside the list is refused rather than kept, + * because a blank chip is nothing anyone picked. Duplicates are dropped keeping + * the first, so the order the author picked in is the order the module reads. + */ +function checkList(p, raw) { + if (raw === undefined || raw === null || raw === '') return { value: [] } + const items = Array.isArray(raw) ? raw : [raw] + const out = [] + const seen = new Set() + for (let i = 0; i < items.length; i++) { + if (items[i] === '') return { error: `item ${i + 1} is empty` } + const checked = checkLiteral(p.type, items[i]) + if (checked.error) return { error: `item ${i + 1} ${checked.error}` } + if (seen.has(checked.value)) continue + seen.add(checked.value) + out.push(checked.value) + } + if (out.length > p.max) return { error: `holds ${out.length} values; the most is ${p.max}` } + return { value: out } +} + /** * Check one authored param object against an action's declared params. * @@ -349,6 +380,14 @@ function checkParams(declaration, raw, path) { for (const p of declaration.params || []) { const value = given[p.name] + if (p.multiple) { + const listed = checkList(p, value) + if (listed.error) errors.push(`${path}.params: "${p.name}" ${listed.error}`) + else if (!listed.value.length) { + if (p.required) errors.push(`${path}.params: "${p.name}" is required`) + } else params[p.name] = listed.value + continue + } if (value === undefined || value === null || value === '') { if (p.required) errors.push(`${path}.params: "${p.name}" is required`) continue diff --git a/server/src/modules/registries.js b/server/src/modules/registries.js index 5d152ad..8bdf12c 100644 --- a/server/src/modules/registries.js +++ b/server/src/modules/registries.js @@ -1030,11 +1030,23 @@ const MAX_BUDGET_MS = 3_600_000 // vocabulary of one is a vocabulary that can grow without a version. const PARAM_DURATIONS = ['minutes'] +// A param that holds several values (MODULE_API 1.14.0; Rust PLAN_REDESIGNS +// §12.3, D344). Two types, because the one step that needed it picks strings +// from a source and an int list is the same check one type over; a list of +// booleans, dates or links is a shape nothing has asked for, and every control +// the editor draws for a list is a picker or a tag box. +const MULTIPLE_PARAM_TYPES = ['string', 'int'] +// `max` when the declaration leaves it out, and the most it may say. The ceiling +// is a bound on one step's params, not a number anyone should reach: a list of a +// hundred is a step nobody can read back. +const DEFAULT_MULTIPLE_MAX = 20 +const MAX_MULTIPLE_MAX = 100 + // `call` names the registration in the error, because a template's inputs are // checked by this same function (they ARE params: one control each, with a // name, a type, an example and maybe a source). function checkActionParam(actionId, entry, seen, call = 'registerEventActions') { - const { name, type, required, example, description, source, duration } = entry || {} + const { name, type, required, example, description, source, duration, multiple, max } = entry || {} const where = `${call}: ${actionId}` if (!PARAM_NAME.test(name || '')) throw new Error(`${where}: bad param name "${name}"`) if (seen.has(name)) throw new Error(`${where}: param "${name}" declared twice`) @@ -1072,6 +1084,35 @@ function checkActionParam(actionId, entry, seen, call = 'registerEventActions') throw new Error(`${where}: param "${name}" is a duration and must be int or float, not ${type}`) } } + // **`multiple` makes the value a list** (1.14.0, §12.3). The example stays ONE + // value — it is the placeholder of the box a value is typed into — and the + // value a step stores is a JSON array of them (`events/spec.js`). + // + // Refused on a duration, because a list of lengths of time is not a hold the + // editor can keep a phase open for, and on a template's inputs, because + // `build()` is a module's own code reading `inputs.x` as one value and this + // version does not change what it is handed. + if (multiple !== undefined && multiple !== null && multiple !== false) { + if (multiple !== true) throw new Error(`${where}: param "${name}" multiple must be true or left out`) + if (call === 'registerEventTemplates') { + throw new Error(`${where}: input "${name}" cannot be multiple; a template's inputs are single values`) + } + if (!MULTIPLE_PARAM_TYPES.includes(type)) { + throw new Error(`${where}: param "${name}" is multiple and must be ${MULTIPLE_PARAM_TYPES.join(' or ')}, not ${type}`) + } + if (duration !== undefined && duration !== null) { + throw new Error(`${where}: param "${name}" cannot be both multiple and a duration`) + } + if (Array.isArray(example)) { + throw new Error(`${where}: param "${name}" needs ONE example, not a list (it is the placeholder of one value)`) + } + } + if (max !== undefined && max !== null) { + if (multiple !== true) throw new Error(`${where}: param "${name}" has a max but is not multiple`) + if (!Number.isInteger(max) || max < 1 || max > MAX_MULTIPLE_MAX) { + throw new Error(`${where}: param "${name}" max must be 1..${MAX_MULTIPLE_MAX}, got "${max}"`) + } + } return { name, type, @@ -1080,6 +1121,9 @@ function checkActionParam(actionId, entry, seen, call = 'registerEventActions') source: source === undefined ? null : source, description: description || '', ...(duration ? { duration } : {}), + // Absent rather than `false` on every other param, as `duration` is, so a + // module that never says it reads back exactly the declaration it wrote. + ...(multiple === true ? { multiple: true, max: max ?? DEFAULT_MULTIPLE_MAX } : {}), } } @@ -2164,6 +2208,9 @@ module.exports = { allEventTemplates, eventTemplate, PARAM_DURATIONS, + MULTIPLE_PARAM_TYPES, + DEFAULT_MULTIPLE_MAX, + MAX_MULTIPLE_MAX, resolveOptionSource, allEngagementSeeds, engagementSeedsFor, diff --git a/server/src/modules/version.js b/server/src/modules/version.js index 619cd95..0b1110f 100644 --- a/server/src/modules/version.js +++ b/server/src/modules/version.js @@ -9,6 +9,16 @@ // Deliberately separate from PROTOCOL_VERSION (which versions the shard wire and // has nothing to say about a website module) and from any module's own version. +// 1.14.0 — an action param may hold several values: `multiple: true`, with an +// optional `max` (1–100, 20 if left out), on a `string` or `int` param (Rust +// PLAN_REDESIGNS §12.3, D344; docs/website/EVENTS.md §F). The step stores a JSON +// array; a bare value is read as a list of one; `required` means at least one; +// `perform()` and `cost()` are handed the array. Refused on a duration and on a +// template's inputs. The editor draws it as a multi-select. +// One optional flag, so minor. Module-uo declares `^1.10.0` and uses none of it: +// a param that does not say `multiple` is checked, stored and drawn exactly as it +// was, and the declaration the registry hands back does not grow the key. +// // 1.13.0 — the event step editor (Rust PLAN_REDESIGNS §2; docs/website/EVENTS.md // §F, §I). Four additions, each optional: // • `api.registerEventTemplates([{ id, label, description, inputs, build }])` — @@ -188,6 +198,6 @@ // an admin action a module performs belongs in core's one audit log, the // extension slot needs the user its prefix names, and §2.7 forbids a module // reading core's `APP_BASE_URL` for itself. Additions only, so minor. -const MODULE_API_VERSION = '1.13.0' +const MODULE_API_VERSION = '1.14.0' module.exports = { MODULE_API_VERSION } diff --git a/server/src/router/v1/admin/events.router.js b/server/src/router/v1/admin/events.router.js index 33af059..e84bd5d 100644 --- a/server/src/router/v1/admin/events.router.js +++ b/server/src/router/v1/admin/events.router.js @@ -51,7 +51,7 @@ eventsRouter.get( // #swagger.summary = 'List every registered event action, with its param schema, risk class and reversibility' // #swagger.description = 'Served from the module registries, not from a table: an action is declared in code by core or by an installed module, so this is whatever registered on this boot, and an uninstalled module simply stops appearing. Core always declares core.announce, core.wait and core.cue. Also carries the closed vocabularies the authoring form renders — risk classes, reversibility classes, param types, failure dispositions and the spec size limits — so the editor offers exactly the set the save path checks against. Phase 5 added `triggers` and `operators`: the trigger catalog a module already ships IS the catalog of things a phase can advance on, and it is served here rather than borrowed from /admin/engagement/triggers because that route is admin-only while an event definition is authored by admin AND editor. Each trigger is reduced to its id, label and declared variables — a trigger's audience and ceiling are about who gets mailed, which is not this screen's question. Phase 7 added `budgets`, `leases` and `optionSources`: the other three registrations of the module contract, served beside the actions because the step editor needs all four to draw ONE step — the action says what params it takes, a param source names a dropdown, and a cap box is a budget label and unit. A source resolves its VALUES on a request of its own (/options/:sourceId), because a source can be slow or down and must not take the catalog with it.' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - /* #swagger.responses[200] = { description: 'The registered actions and triggers, and the vocabularies over them', content: { "application/json": { schema: { type: "object", properties: { actions: { type: "array", items: { type: "object", additionalProperties: true } }, triggers: { type: "array", items: { type: "object", additionalProperties: true } }, operators: { type: "array", items: { type: "object", additionalProperties: true } }, risks: { type: "array", items: { type: "string" } }, reversible: { type: "array", items: { type: "string" } }, paramTypes: { type: "array", items: { type: "string" } }, onFailure: { type: "array", items: { type: "string" } }, onFailureByRisk: { type: "object", additionalProperties: true }, scheduleKinds: { type: "array", items: { type: "string" } }, advanceKinds: { type: "array", items: { type: "string" } }, limits: { type: "object", additionalProperties: true }, templates: { type: "array", description: "Event templates (MODULE_API 1.13.0): id, label, description, owner and inputs (each shaped like an action param). build() is never served.", items: { type: "object", additionalProperties: true } } } } } } } */ + /* #swagger.responses[200] = { description: 'The registered actions and triggers, and the vocabularies over them', content: { "application/json": { schema: { type: "object", properties: { actions: { type: "array", description: "Each action with its declared params. MODULE_API 1.14.0: a param may carry multiple: true and max (1 to 100, 20 when left out), and its value is then a JSON array of at most max values.", items: { type: "object", additionalProperties: true } }, triggers: { type: "array", items: { type: "object", additionalProperties: true } }, operators: { type: "array", items: { type: "object", additionalProperties: true } }, risks: { type: "array", items: { type: "string" } }, reversible: { type: "array", items: { type: "string" } }, paramTypes: { type: "array", items: { type: "string" } }, onFailure: { type: "array", items: { type: "string" } }, onFailureByRisk: { type: "object", additionalProperties: true }, scheduleKinds: { type: "array", items: { type: "string" } }, advanceKinds: { type: "array", items: { type: "string" } }, limits: { type: "object", additionalProperties: true }, templates: { type: "array", description: "Event templates (MODULE_API 1.13.0): id, label, description, owner and inputs (each shaped like an action param). build() is never served.", items: { type: "object", additionalProperties: true } } } } } } } */ /* #swagger.responses[403] = { description: 'Not staff', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ controller.catalog, ) diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json index 9964cd1..ce9ae0a 100644 --- a/server/swagger/swagger-output.json +++ b/server/swagger/swagger-output.json @@ -3869,6 +3869,7 @@ "properties": { "actions": { "type": "array", + "description": "Each action with its declared params. MODULE_API 1.14.0: a param may carry multiple: true and max (1 to 100, 20 when left out), and its value is then a JSON array of at most max values.", "items": { "type": "object", "additionalProperties": true diff --git a/server/test/eventModuleContract.test.js b/server/test/eventModuleContract.test.js index d329130..68f5e3c 100644 --- a/server/test/eventModuleContract.test.js +++ b/server/test/eventModuleContract.test.js @@ -112,7 +112,7 @@ test('the version a module declares against satisfies ^1.10.0', () => { // 1.10.0 loading — Module-uo declares exactly `^1.10.0` — and this is the // assertion that says it does. The number itself is pinned in version.js. assert.ok(semver.satisfies(MODULE_API_VERSION, '^1.10.0'), MODULE_API_VERSION) - assert.equal(MODULE_API_VERSION, '1.13.0') + assert.equal(MODULE_API_VERSION, '1.14.0') }) test('a module registers actions, budgets, leases and option sources', () => { diff --git a/server/test/eventParamMultiple.test.js b/server/test/eventParamMultiple.test.js new file mode 100644 index 0000000..73682cd --- /dev/null +++ b/server/test/eventParamMultiple.test.js @@ -0,0 +1,157 @@ +// ── A param that holds several values (MODULE_API 1.14.0) ────────────────── +// +// Rust PLAN_REDESIGNS §12.3 (D344): `multiple: true` (+ `max`) on a string or int +// action param. The declaration is checked at registration, the value at save — +// a JSON array, a bare value read as a list of one, duplicates dropped in order, +// `required` meaning at least one — and `cost()` is priced with a list. A param +// that does not say `multiple` must come out of the registry exactly as it went +// in: that is what keeps Module-uo's `^1.10.0` honest. +process.env.DB_HOST = '127.0.0.1' +process.env.DB_PORT = '59999' + +const { test, beforeEach, afterEach, after } = require('node:test') +const assert = require('node:assert/strict') + +const registries = require('../src/modules/registries') +const spec = require('../src/events/spec') +const authorize = require('../src/events/authorize') +const db = require('../src/utils/db') + +after(() => db.close()) + +const register = (actions, budgets = []) => { + const api = registries.stage('demo') + if (budgets.length) api.registerEventBudgets(budgets) + api.registerEventActions(actions) + registries.apply(api.staged) +} + +const action = (params, extra = {}) => ({ + id: 'demo.perk.lend', + label: 'Lend perks', + risk: 'notify', + reversible: 'none', + params, + perform: async () => ({ ok: true }), + ...extra, +}) + +const perms = (extra = {}) => ({ name: 'perms', type: 'string', required: true, example: 'nightvision.allowed', multiple: true, ...extra }) + +beforeEach(() => { + registries._reset() + registries.registerCore() +}) +afterEach(() => registries._reset()) + +const oneStep = (params) => ({ + schedule: { kind: 'manual' }, + phases: [{ key: 'main', label: 'Main', steps: [{ actionId: 'demo.perk.lend', params }] }], +}) + +// ── The declaration ─────────────────────────────────────────────────────── + +test('multiple is served with its max, 20 when left out', () => { + register([action([perms(), { name: 'ids', type: 'int', example: 7, multiple: true, max: 5 }])]) + const [p, ids] = registries.eventAction('demo.perk.lend').params + assert.equal(p.multiple, true) + assert.equal(p.max, registries.DEFAULT_MULTIPLE_MAX) + assert.equal(ids.max, 5) + const served = registries.allEventActions().find((a) => a.id === 'demo.perk.lend') + assert.equal(served.params[0].multiple, true) + assert.equal(served.params[1].max, 5) +}) + +test('a param that does not say multiple does not grow the key', () => { + register([action([{ name: 'region', type: 'string', example: 'Yew' }, perms({ multiple: false })])]) + for (const p of registries.eventAction('demo.perk.lend').params) { + assert.equal('multiple' in p, false) + assert.equal('max' in p, false) + } + // Core's own actions say none of it either. + for (const a of registries.allEventActions().filter((x) => x.owner === 'core')) { + for (const p of a.params) assert.equal('multiple' in p, false, `${a.id}.${p.name}`) + } +}) + +test('multiple is refused on a type, a duration, a bad max and a list example', () => { + const refused = (param, pattern) => assert.throws(() => register([action([param])]), pattern) + refused(perms({ type: 'boolean', example: true }), /is multiple and must be string or int, not boolean/) + refused(perms({ type: 'float', example: 1.5 }), /is multiple and must be string or int, not float/) + refused(perms({ type: 'int', example: 5, duration: 'minutes' }), /cannot be both multiple and a duration/) + refused(perms({ max: 0 }), /max must be 1\.\.100/) + refused(perms({ max: 101 }), /max must be 1\.\.100/) + refused(perms({ max: 2.5 }), /max must be 1\.\.100/) + refused(perms({ multiple: 'yes' }), /multiple must be true or left out/) + refused(perms({ example: ['a', 'b'] }), /needs ONE example, not a list/) + refused({ name: 'region', type: 'string', example: 'Yew', max: 3 }, /has a max but is not multiple/) +}) + +test('a template input cannot be multiple', () => { + const api = registries.stage('demo') + assert.throws( + () => api.registerEventTemplates([{ + id: 'demo.template.perks', + label: 'Perks', + inputs: [perms()], + build: async () => null, + }]), + /input "perms" cannot be multiple/, + ) +}) + +// ── The value ───────────────────────────────────────────────────────────── + +test('a list is kept in order, deduplicated, and a bare value is a list of one', () => { + register([action([perms(), { name: 'ids', type: 'int', example: 7, multiple: true }])]) + const listed = spec.validate(oneStep({ perms: ['b.two', 'a.one', 'b.two'], ids: [3, 1, 3] })) + assert.equal(listed.ok, true, listed.errors?.join('; ')) + assert.deepEqual(listed.spec.phases[0].steps[0].params, { perms: ['b.two', 'a.one'], ids: [3, 1] }) + + const bare = spec.validate(oneStep({ perms: 'nightvision.allowed', ids: 9 })) + assert.equal(bare.ok, true, bare.errors?.join('; ')) + assert.deepEqual(bare.spec.phases[0].steps[0].params, { perms: ['nightvision.allowed'], ids: [9] }) +}) + +test('required means at least one; an optional empty list is left out', () => { + register([action([perms(), { name: 'ids', type: 'int', example: 7, multiple: true }])]) + for (const empty of [undefined, null, '', []]) { + const r = spec.validate(oneStep(empty === undefined ? {} : { perms: empty })) + assert.equal(r.ok, false, JSON.stringify(empty)) + assert.match(r.errors.join('\n'), /"perms" is required/) + } + const r = spec.validate(oneStep({ perms: ['a.one'], ids: [] })) + assert.equal(r.ok, true, r.errors?.join('; ')) + assert.equal('ids' in r.spec.phases[0].steps[0].params, false) +}) + +test('each item is the type, none is empty, and max is a ceiling', () => { + register([action([perms({ max: 2 }), { name: 'ids', type: 'int', example: 7, multiple: true }])]) + const errs = (params) => spec.validate(oneStep(params)).errors?.join('\n') || '' + assert.match(errs({ perms: ['a', 4] }), /"perms" item 2 expected a string/) + assert.match(errs({ perms: ['a', ''] }), /"perms" item 2 is empty/) + assert.match(errs({ perms: ['a'], ids: [1, 'two'] }), /"ids" item 2 expected an integer/) + assert.match(errs({ perms: ['a', 'b', 'c'] }), /"perms" holds 3 values; the most is 2/) + // A duplicate does not count against max. + assert.equal(spec.validate(oneStep({ perms: ['a', 'b', 'a'] })).ok, true) + assert.match(errs({ perms: [['nested']] }), /"perms" item 1 expected a string/) +}) + +// ── The price ───────────────────────────────────────────────────────────── + +test('cost() is handed the list, and its dimensions are found from a list of one', () => { + const seen = [] + register( + [action([perms()], { + cost: (params) => { + seen.push(params.perms) + return { 'demo.perks': (params.perms || []).length } + }, + })], + [{ id: 'demo.perks', label: 'Perks', unit: 'perks' }], + ) + const declared = registries.eventAction('demo.perk.lend') + assert.deepEqual(authorize.dimensionsOf(declared), ['demo.perks']) + assert.deepEqual(seen.at(-1), ['nightvision.allowed']) + assert.deepEqual(authorize.priceOf(declared, { perms: ['a', 'b', 'c'] }), { 'demo.perks': 3 }) +})