feat(events): a param that holds several values (MODULE_API 1.14.0)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 47s
PR Checks / client-build (pull_request) Successful in 48s
PR Checks / server-tests (pull_request) Successful in 13m55s

Rust PLAN_REDESIGNS §12.3 (D344): an event action param may declare
`multiple: true`, with an optional `max` (1-100, 20 when left out), on a
`string` or `int` param. The permission reward steps take several permissions
in one step through it.

- registries: the flag and `max` are checked at registration and served in the
  catalog; refused on other types, on a duration, on a template's inputs, and
  with a list as the example. A param that does not say it reads back exactly
  as declared, without the keys.
- spec: the value is a JSON array, each item the type's literal, duplicates
  dropped in order, at most `max`; a bare value is a list of one; `required`
  means at least one; an empty item is refused.
- authorize: a multiple param's example is priced as a list of one, since
  cost() is handed the array.
- editor: a multi-select. Chips above a grouped, filterable checklist for a
  source that answered; the typeahead for a searchable source; a tag box
  otherwise. Ticking past max is refused in words. A new step holds the
  example as a list of one.
- run console: params read as `name: value`, a list joined by commas.
- MODULE_API 1.13.0 -> 1.14.0 on both halves; engagement-triggers.json and the
  catalog's OpenAPI note regenerated.

Additive only: Module-uo (`^1.10.0`) declares no multiple param.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-10-12 15:40:30 -05:00
parent ca7ec53b14
commit 743f1bc8b2
14 changed files with 616 additions and 14 deletions

View File

@@ -133,7 +133,9 @@ const nextPhaseKey = (phases) => {
export function blankStep(action) { export function blankStep(action) {
const params = {} const params = {}
for (const p of action?.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 { return {
actionId: action?.id || '', 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 * 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. * step that saves cleanly having deleted something they typed.
* • **a value no single control can hold** — an object or an array against a * • **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) { export function paramsRenderable(action, params) {
if (!action) return { ok: false, reason: 'the module that registered this action is not installed' } 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)) { if (!declared.has(name)) {
return { ok: false, reason: `this step carries "${name}", which ${action.id} does not declare` } 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') { if (value !== null && typeof value === 'object') {
return { ok: false, reason: `"${name}" holds a ${Array.isArray(value) ? 'list' : 'structure'}, which no single field can hold` } 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) : '') 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) ── // ── 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 // 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 [] if (!action) return []
const parsed = parseParams(step?.paramsText) const parsed = parseParams(step?.paramsText)
if (parsed.error) return [] 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) const missing = (action.params || []).filter((p) => p.required && empty(parsed.params[p.name])).map((p) => p.name)
if (action.id === 'core.lease') { if (action.id === 'core.lease') {
const lease = leasesById.get(parsed.params.lease) const lease = leasesById.get(parsed.params.lease)

View File

@@ -11,6 +11,9 @@
// that the two files can drift, so a test asserts they agree // that the two files can drift, so a test asserts they agree
// (client/test/moduleRegistry.test.js) rather than trusting a bump to remember // (client/test/moduleRegistry.test.js) rather than trusting a bump to remember
// both. // 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 // 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 // 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 // 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 // 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 // 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. // 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'

View File

@@ -22,6 +22,10 @@ import {
paramsMode, paramsMode,
paramValue, paramValue,
setParam, setParam,
setParamList,
listValue,
addToList,
filterOptions,
datetimeInputValue, datetimeInputValue,
priceBodyFrom, priceBodyFrom,
worthPricing, 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 = (
<div style={{ display: 'flex', gap: 6, marginTop: 4 }}>
<input className="input" style={{ fontSize: '0.8rem' }} disabled={disabled} value={typed}
placeholder={param.example === undefined || param.example === null ? '' : String(param.example)}
onChange={(e) => setTyped(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
add(typed)
setTyped('')
}
}} />
<button type="button" className="pill" style={{ fontSize: '0.72rem' }} disabled={disabled || !typed.trim()}
onClick={() => { add(typed); setTyped('') }}>
Add
</button>
</div>
)
let control
if (param.source && sourceEntry?.searchable) {
control = (
<SearchableOptions sourceId={param.source} label={sourceEntry.label || param.source}
disabled={disabled} onPick={add} />
)
} else if (param.source && answered && sourceEntry.options.length) {
control = (
<div style={{ marginTop: 4 }}>
<input className="input" style={{ fontSize: '0.75rem' }} disabled={disabled} value={term}
placeholder={`Filter ${sourceEntry.label || param.source}…`} onChange={(e) => setTerm(e.target.value)} />
<div role="group" aria-label={param.name}
style={{ maxHeight: 240, overflowY: 'auto', marginTop: 4, border: '1px solid rgba(255,255,255,0.08)', padding: '4px 8px' }}>
{shown.length === 0 && (
<div className="dim" style={{ fontSize: '0.72rem' }}>Nothing matches “{term}”.</div>
)}
{groups.map((g) => (
<div key={g || '—'}>
{g && <div className="field-label" style={{ fontSize: '0.7rem', margin: '6px 0 2px' }}>{g}</div>}
{shown.filter((o) => (o.group || '') === g).map((o) => (
<label key={o.value} style={{ display: 'flex', alignItems: 'baseline', gap: 6, fontSize: '0.76rem' }}>
<input type="checkbox" disabled={disabled}
checked={list.some((x) => String(x) === o.value)}
onChange={() => toggle(o.value)} />
<span>{o.label}</span>
{o.note && <span className="dim" style={{ fontSize: '0.7rem' }}>{o.note}</span>}
</label>
))}
</div>
))}
</div>
</div>
)
} else {
control = (
<>
{param.source && (
<ParamOptions entry={sourceEntry} label={sourceEntry?.label || param.source} disabled={disabled} onPick={add} />
)}
{tagBox}
</>
)
}
return (
<div style={{ marginBottom: 12 }}>
<span className="field-label">
{param.name} <span className="dim" style={{ fontWeight: 'normal' }}>· {list.length} of at most {max}</span>
</span>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 4 }}>
{list.length === 0 && <span className="sans dim" style={{ fontSize: '0.76rem' }}>None chosen yet.</span>}
{list.map((v) => (
<span key={String(v)} className="pill" style={{ fontSize: '0.74rem', display: 'inline-flex', gap: 6, alignItems: 'center' }}>
{byValue.get(String(v))?.label || String(v)}
<button type="button" aria-label={`Remove ${v}`} disabled={disabled}
style={{ background: 'none', border: 'none', color: 'inherit', cursor: 'pointer', padding: 0 }}
onClick={() => remove(v)}>
×
</button>
</span>
))}
</div>
{refused && (
<span className="sans" style={{ display: 'block', fontSize: '0.74rem', color: '#d98b84', marginTop: 4 }}>{refused}</span>
)}
{control}
{missing && (
<span className="sans" style={{ display: 'block', fontSize: '0.74rem', color: '#d98b84' }}>
This is required — choose at least one before saving.
</span>
)}
{param.description && (
<span className="sans dim" style={{ display: 'block', fontSize: '0.74rem' }}>{param.description}</span>
)}
{chosenNotes.map(({ v, note }) => (
<span key={String(v)} className="sans" style={{ display: 'block', fontSize: '0.76rem', color: '#d9c184', marginTop: 4 }}>
{byValue.get(String(v))?.label || String(v)}: {note}
</span>
))}
</div>
)
}
/** /**
* `core.lease`, drawn as the chosen lease (Rust PLAN_REDESIGNS §2.1, U-4, F11). * `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) }) { timed: name === 'lease' || Boolean(declared.duration) })
}} }}
/> />
) : (action?.params || []).map((p) => ( ) : (action?.params || []).map((p) => (p.multiple ? (
<MultiParamField
key={p.name}
param={p}
value={paramValue(step, p.name)}
sourceEntry={p.source ? sources[p.source] : null}
disabled={archived}
missing={checkMissing && missingParams(step, action, leaseById).includes(p.name)}
onChange={(list) => setStep(pi, si, { paramsText: setParamList(step, p.name, list, p.type) })}
/>
) : (
<ParamField <ParamField
key={p.name} key={p.name}
param={p} param={p}
@@ -1981,7 +2143,7 @@ export default function EventEditor() {
missing={checkMissing && missingParams(step, action, leaseById).includes(p.name)} missing={checkMissing && missingParams(step, action, leaseById).includes(p.name)}
onChange={(raw) => setStepParam(pi, si, step, p, raw)} onChange={(raw) => setStepParam(pi, si, step, p, raw)}
/> />
))} )))}
{action && (action.params || []).length === 0 && ( {action && (action.params || []).length === 0 && (
<p className="sans dim" style={{ fontSize: '0.78rem', margin: 0 }}> <p className="sans dim" style={{ fontSize: '0.78rem', margin: 0 }}>
This action takes no params. This action takes no params.
@@ -2002,7 +2164,7 @@ export default function EventEditor() {
{action && (action.params || []).length > 0 && ( {action && (action.params || []).length > 0 && (
<span className="sans dim" style={{ fontSize: '0.74rem' }}> <span className="sans dim" style={{ fontSize: '0.74rem' }}>
<code>{action.id}</code> declares{' '} <code>{action.id}</code> 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(', ')}.
</span> </span>
)} )}
</> </>

View File

@@ -9,6 +9,7 @@ import {
runControlsFor, runControlsFor,
stepControlsFor, stepControlsFor,
describeLogLine, describeLogLine,
describeParams,
} from '../../../lib/eventAuthoring.js' } from '../../../lib/eventAuthoring.js'
// Admin → Events → the run console (EVENTS.md §I, Phase 3). // Admin → Events → the run console (EVENTS.md §I, Phase 3).
@@ -655,7 +656,7 @@ export default function EventRun() {
<td className="adm-td" style={{ fontSize: '0.82rem' }}> <td className="adm-td" style={{ fontSize: '0.82rem' }}>
<code style={{ fontSize: '0.78rem' }}>{step.actionId}</code> <code style={{ fontSize: '0.78rem' }}>{step.actionId}</code>
<div className="dim" style={{ fontSize: '0.74rem', maxWidth: 320, overflowWrap: 'anywhere' }}> <div className="dim" style={{ fontSize: '0.74rem', maxWidth: 320, overflowWrap: 'anywhere' }}>
{JSON.stringify(step.params)} {describeParams(step.params)}
</div> </div>
</td> </td>
<td className="adm-td" style={{ fontSize: '0.82rem', color: STEP_COLOR[step.status] || undefined }}> <td className="adm-td" style={{ fontSize: '0.82rem', color: STEP_COLOR[step.status] || undefined }}>

View File

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

View File

@@ -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.", "_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": [ "triggers": [
{ {
"id": "event.phase.changed", "id": "event.phase.changed",

View File

@@ -144,7 +144,9 @@ function priceOf(action, params) {
function dimensionsOf(action) { function dimensionsOf(action) {
const params = {} const params = {}
for (const p of action?.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) const priced = priceOf(action, params)
return priced ? Object.keys(priced).sort() : [] return priced ? Object.keys(priced).sort() : []

View File

@@ -323,6 +323,37 @@ function validateAdvance(raw, path, errors) {
return { on: triggerId, where: checked.conditions, count, dormant: false } 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. * 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 || []) { for (const p of declaration.params || []) {
const value = given[p.name] 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 (value === undefined || value === null || value === '') {
if (p.required) errors.push(`${path}.params: "${p.name}" is required`) if (p.required) errors.push(`${path}.params: "${p.name}" is required`)
continue continue

View File

@@ -1030,11 +1030,23 @@ const MAX_BUDGET_MS = 3_600_000
// vocabulary of one is a vocabulary that can grow without a version. // vocabulary of one is a vocabulary that can grow without a version.
const PARAM_DURATIONS = ['minutes'] 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 // `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 // checked by this same function (they ARE params: one control each, with a
// name, a type, an example and maybe a source). // name, a type, an example and maybe a source).
function checkActionParam(actionId, entry, seen, call = 'registerEventActions') { 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}` const where = `${call}: ${actionId}`
if (!PARAM_NAME.test(name || '')) throw new Error(`${where}: bad param name "${name}"`) 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`) 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}`) 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 { return {
name, name,
type, type,
@@ -1080,6 +1121,9 @@ function checkActionParam(actionId, entry, seen, call = 'registerEventActions')
source: source === undefined ? null : source, source: source === undefined ? null : source,
description: description || '', description: description || '',
...(duration ? { duration } : {}), ...(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, allEventTemplates,
eventTemplate, eventTemplate,
PARAM_DURATIONS, PARAM_DURATIONS,
MULTIPLE_PARAM_TYPES,
DEFAULT_MULTIPLE_MAX,
MAX_MULTIPLE_MAX,
resolveOptionSource, resolveOptionSource,
allEngagementSeeds, allEngagementSeeds,
engagementSeedsFor, engagementSeedsFor,

View File

@@ -9,6 +9,16 @@
// Deliberately separate from PROTOCOL_VERSION (which versions the shard wire and // 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. // 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 // 1.13.0 — the event step editor (Rust PLAN_REDESIGNS §2; docs/website/EVENTS.md
// §F, §I). Four additions, each optional: // §F, §I). Four additions, each optional:
// • `api.registerEventTemplates([{ id, label, description, inputs, build }])` — // • `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 // 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 // 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. // 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 } module.exports = { MODULE_API_VERSION }

View File

@@ -51,7 +51,7 @@ eventsRouter.get(
// #swagger.summary = 'List every registered event action, with its param schema, risk class and reversibility' // #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.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.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" } } } } */ /* #swagger.responses[403] = { description: 'Not staff', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
controller.catalog, controller.catalog,
) )

View File

@@ -3869,6 +3869,7 @@
"properties": { "properties": {
"actions": { "actions": {
"type": "array", "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": { "items": {
"type": "object", "type": "object",
"additionalProperties": true "additionalProperties": true

View File

@@ -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 // 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. // 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.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', () => { test('a module registers actions, budgets, leases and option sources', () => {

View File

@@ -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 })
})