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) {
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)

View File

@@ -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'

View File

@@ -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 = (
<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).
*
@@ -1971,7 +2123,17 @@ export default function EventEditor() {
{ 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
key={p.name}
param={p}
@@ -1981,7 +2143,7 @@ export default function EventEditor() {
missing={checkMissing && missingParams(step, action, leaseById).includes(p.name)}
onChange={(raw) => setStepParam(pi, si, step, p, raw)}
/>
))}
)))}
{action && (action.params || []).length === 0 && (
<p className="sans dim" style={{ fontSize: '0.78rem', margin: 0 }}>
This action takes no params.
@@ -2002,7 +2164,7 @@ export default function EventEditor() {
{action && (action.params || []).length > 0 && (
<span className="sans dim" style={{ fontSize: '0.74rem' }}>
<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>
)}
</>

View File

@@ -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() {
<td className="adm-td" style={{ fontSize: '0.82rem' }}>
<code style={{ fontSize: '0.78rem' }}>{step.actionId}</code>
<div className="dim" style={{ fontSize: '0.74rem', maxWidth: 320, overflowWrap: 'anywhere' }}>
{JSON.stringify(step.params)}
{describeParams(step.params)}
</div>
</td>
<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), '')
})