From 8453762e3b1d292fba9f5a139047a0cd154797a1 Mon Sep 17 00:00:00 2001
From: wtclaude
Date: Mon, 7 Sep 2026 16:34:03 -0500
Subject: [PATCH] feat(events): the authoring UI proper (Phase 13)
Replaces the two raw JSON boxes Phase 3 shipped as explicit placeholders: a
step's params are a form rendered from the action's own declaration, and a
phase's advance condition is the engagement condition builder. Adds the live cap
meter, the searchable option source's first consumer, and a start dialog
carrying the three fields the route has taken since Phase 10.
One route: POST /admin/events/price, admin+editor. A module's cost() runs on the
server and only there, so a meter has nothing to add up until something asks --
and the dry run is the wrong thing to ask on a debounce twice over: it dispatches
every step through the module and a pass against a version is RECORDED, which is
the stamp K's unattended-start gate reads. This dispatches nothing and records
nothing, and takes the spec in the body because the plan being priced is unsaved
between keystrokes.
A form gives way to JSON on the condition builder's own rule: a value the editor
cannot round-trip is SHOWN rather than silently rewritten. Dropping a param the
action does not declare and flattening `A and (B or C)` are the same mistake.
Two defects fixed in already-merged code:
* Creating an event has been impossible since Phase 6. `events/new` was added
beside `events/:id` and binds no param, and React Router ranks a static
segment above a dynamic one whatever the order -- so the editor was handed no
id and fetched /admin/events/undefined. Worse, the failure was invisible:
`!form` is true for every failed load, so the error state sat behind a
spinner that never stopped.
* 12b's searchable sources had no consumer. The server half shipped and the
only UI that reads a source never sent a term, so the 6,707-entry spawner
list was picked from a 2,000-entry truncation with nothing saying so.
Server: 2113 tests, 2024 pass, 0 fail (89 DB-skipped). Client: 380 pass, 0 fail.
routes:manifest and swagger regenerated -- one route added, none moved.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
---
client/src/App.jsx | 6 +-
client/src/api/client.js | 15 +-
client/src/lib/eventAuthoring.js | 263 +++++-
client/src/routes/admin/views/EventEditor.jsx | 810 +++++++++++++++---
client/test/eventAuthoring.test.js | 257 +++++-
server/routes.guards.json | 9 +
server/routes.manifest.json | 4 +
server/src/events/price.js | 185 ++++
.../src/router/v1/admin/events.controller.js | 39 +
server/src/router/v1/admin/events.router.js | 24 +
server/swagger/swagger-output.json | 132 +++
server/test/eventPrice.test.js | 309 +++++++
server/test/eventsRoles.test.js | 9 +
13 files changed, 1913 insertions(+), 149 deletions(-)
create mode 100644 server/src/events/price.js
create mode 100644 server/test/eventPrice.test.js
diff --git a/client/src/App.jsx b/client/src/App.jsx
index fd7ab39..6a6bb69 100644
--- a/client/src/App.jsx
+++ b/client/src/App.jsx
@@ -212,7 +212,11 @@ export default function App() {
`/:id` — the same collision, on the other side of the wire. */}
} />
} />
- } />
+ {/* ONE route, and `new` is a value of `:id` rather than a
+ path beside it. A static `events/new` outranks the dynamic
+ segment in React Router whatever the order, so the editor
+ was handed no `id` at all and asked the API for
+ `/admin/events/undefined`. */}
} />
{/* Engagement (ENGAGEMENT.md Phases 4b and 5b). Admin-only, matching the
server: every route under /admin/engagement re-gates to `admin`
diff --git a/client/src/api/client.js b/client/src/api/client.js
index 4aa0a9c..e3d486a 100644
--- a/client/src/api/client.js
+++ b/client/src/api/client.js
@@ -492,12 +492,25 @@ export const api = {
// editor with it. A refusal comes back 200 with `ok: false`, so this never
// throws for the case the screen is meant to render: the field degrades to
// free text with the reason beside it.
- eventOptions: (sourceId) => req(`/admin/events/catalog/options/${encodeURIComponent(sourceId)}`),
+ // Phase 12b made a source SEARCHABLE and Phase 13 is what asks. `q` is
+ // ignored, never refused, by a source that does not declare itself
+ // searchable — so passing it is always safe and the field decides whether
+ // it is a typeahead by reading `searchable` off the answer.
+ eventOptions: (sourceId, q) => {
+ const qs = q ? `?${new URLSearchParams({ q }).toString()}` : ''
+ return req(`/admin/events/catalog/options/${encodeURIComponent(sourceId)}${qs}`)
+ },
// Phase 6. The dry run is admin+editor: it dispatches nothing, and the author
// who wrote the definition is who should be able to price it against the caps
// before asking an admin to publish it. A report with findings comes back 200
// — the request succeeded, the plan has problems.
verifyEvent: (id) => req(`/admin/events/${id}/verify`, { method: 'POST' }),
+ // Phase 13's live cap meter, and NOT a lighter dry run — it dispatches
+ // nothing, so it knows nothing a module knows. It takes the spec in the
+ // body rather than an id because the plan it prices is the one in the
+ // author's hands, which is unsaved between keystrokes, and it records
+ // nothing, which is what makes it safe to call on a debounce.
+ priceEvent: (body) => req('/admin/events/price', { method: 'POST', body }),
// The switchboard, admin only in BOTH directions: reading which actions a
// deployment permits is as much configuration as writing it (§K). One action
// per write rather than the whole board, so an action that appeared between
diff --git a/client/src/lib/eventAuthoring.js b/client/src/lib/eventAuthoring.js
index 59fbf0e..8a089e1 100644
--- a/client/src/lib/eventAuthoring.js
+++ b/client/src/lib/eventAuthoring.js
@@ -14,6 +14,13 @@
// meets at 2am while the thing they are trying to stop keeps running — so the
// guards are written twice on purpose and the copy is checked.
+// **The condition builder is borrowed, not rebuilt.** §I says the step editor
+// reuses "the condition builder, exactly" — and a phase's advance gate is
+// literally the engagement grammar, validated on the server by
+// `engagement/conditions.js`. Importing the row helpers is what keeps this screen
+// from becoming a second opinion about a grammar core owns.
+import { conditionRowsFrom, conditionsFromRows, coerceLiteral } from './engagementRules.js'
+
// A run that is over. Verbatim `eventRuns.db`'s TERMINAL.
export const TERMINAL_RUN_STATUSES = ['completed', 'cancelled', 'failed', 'missed']
@@ -115,14 +122,13 @@ const nextPhaseKey = (phases) => {
}
/**
- * A new step, with its params box PREFILLED from the action's declared examples.
+ * A new step, with its params PREFILLED from the action's declared examples.
*
* Every param carries a required `example` — that requirement is the reason this
- * works — so a fresh `core.announce` step arrives as a JSON object with the right
- * keys and plausible values rather than as an empty `{}` an author has to guess
- * the shape of. It is the nearest a raw JSON box gets to the schema-driven form
- * Phase 13 replaces it with, and it costs nothing the catalog was not already
- * serving.
+ * works — so a fresh `core.announce` step arrives with the right keys and
+ * plausible values rather than empty. Phase 13 turned the box into a form and
+ * this stayed exactly as it was: a form whose fields start at the declared
+ * example is a step an author edits rather than one they compose.
*/
export function blankStep(action) {
const params = {}
@@ -152,7 +158,18 @@ export function blankPhase(phases) {
* typed under the other option is one an operator learns to be afraid of.
*/
export function blankAdvance() {
- return { kind: '', after: '30m', on: '', count: 1, whereText: '' }
+ return { kind: '', after: '30m', on: '', count: 1, ...blankWhere() }
+}
+
+/**
+ * The `where` predicate as the BUILDER holds it (Phase 13).
+ *
+ * `whereText` survives beside the rows and is not vestigial: it is what a
+ * predicate the builder cannot render is shown as, and what is posted for one.
+ * See `whereFormFrom`.
+ */
+export function blankWhere() {
+ return { whereOp: 'and', whereRows: [], whereEditable: true, whereText: '' }
}
export const ADVANCE_KINDS = [
@@ -161,7 +178,7 @@ export const ADVANCE_KINDS = [
{ value: 'on', label: 'When something happens in the game' },
]
-/** The stored gate, as the form's three fields. */
+/** The stored gate, as the form's fields. */
export function advanceFormFrom(advance) {
const blank = blankAdvance()
if (!advance) return blank
@@ -171,7 +188,34 @@ export function advanceFormFrom(advance) {
kind: 'on',
on: advance.on || '',
count: advance.count ?? 1,
- whereText: advance.where ? JSON.stringify(advance.where, null, 2) : '',
+ ...whereFormFrom(advance.where),
+ }
+}
+
+/**
+ * A stored `where` tree → the builder's flat rows (Phase 13).
+ *
+ * **This is `conditionRowsFrom` and it is deliberately the same function**, not a
+ * second one shaped like it. The grammar behind a phase gate is the engagement
+ * condition grammar — the server validates it with `engagement/conditions.js`
+ * and renders the diagnosis panel's sentence with the same labels — so an editor
+ * here that re-decided what a tree looks like would be the second implementation
+ * §I refuses on the read side for exactly this reason.
+ *
+ * A tree the flat editor cannot hold (`A and (B or C)`) comes back
+ * `whereEditable: false` and is SHOWN as its JSON rather than silently
+ * flattened: `A and B and C` fires on different events, and an author would have
+ * no way to know the save had done it to them.
+ */
+export function whereFormFrom(where) {
+ const blank = blankWhere()
+ if (!where) return blank
+ const rows = conditionRowsFrom(where)
+ return {
+ whereOp: rows.op,
+ whereRows: rows.rows,
+ whereEditable: rows.editable,
+ whereText: JSON.stringify(where, null, 2),
}
}
@@ -208,25 +252,37 @@ export function formFromDefinition(event) {
/**
* One phase's advance gate, as the spec shape — or null when it has none.
*
- * Only the `where` JSON is checked, and only because text that is not JSON
- * cannot be put in a request at all. **Whether the predicate is VALID is the
- * server's answer**, and the whole trap of Phase 5 is that it is answered at
- * save with the offending variable named — re-deciding it here would be a second
- * validator drifting from the one that matters, exactly as with a step's params.
+ * **Whether the predicate is VALID is still the server's answer.** The builder
+ * coerces each literal to the type the trigger DECLARED — which is not a second
+ * validator but the thing that makes the first one's error useful: every value
+ * in an HTML input is a string, and `{ cmp: 'gt', value: \"5\" }` against an `int`
+ * variable is refused by `engagement/conditions.js`, rightly, at which point the
+ * author is reading an error about JSON rather than about what they typed.
+ *
+ * A predicate the builder could not render round-trips through `whereText`
+ * unchanged. That is the point of keeping the text: the alternative to posting it
+ * back verbatim is dropping an author's tree because this screen could not draw
+ * it.
*/
-export function advancePayload(advance, where, errors) {
+export function advancePayload(advance, where, errors, variables = []) {
if (!advance || !advance.kind) return null
if (advance.kind === 'after') return { after: advance.after }
const out = { on: advance.on, count: Number(advance.count) || 1 }
- const text = String(advance.whereText || '').trim()
- if (text) {
- try {
- out.where = JSON.parse(text)
- } catch (err) {
- errors.push(`${where}, advance condition: ${err.message}`)
+ if (advance.whereEditable === false) {
+ const text = String(advance.whereText || '').trim()
+ if (text) {
+ try {
+ out.where = JSON.parse(text)
+ } catch (err) {
+ errors.push(`${where}, advance condition: ${err.message}`)
+ }
}
+ return out
}
+
+ const built = conditionsFromRows(advance.whereOp || 'and', advance.whereRows || [], variables)
+ if (built) out.where = built
return out
}
@@ -244,10 +300,20 @@ export function advancePayload(advance, where, errors) {
* applies the action's risk-class default rather than being told a value the
* form invented.
*/
-export function payloadFromForm(form) {
+export function payloadFromForm(form, { triggersById = new Map() } = {}) {
const errors = []
const phases = (form.phases || []).map((phase, pi) => {
- const where = advancePayload(phase.advance, `Phase ${pi + 1} "${phase.label || phase.key}"`, errors)
+ const where = advancePayload(
+ phase.advance,
+ `Phase ${pi + 1} "${phase.label || phase.key}"`,
+ errors,
+ // The declared types the builder coerces against. A trigger nothing
+ // registers has none, and every literal then stays the string it was typed
+ // as — which is right: the gate is dormant, the server carries its `where`
+ // through unvalidated, and inventing types for it here would edit a
+ // predicate nobody can currently check.
+ triggersById.get(phase.advance?.on)?.variables || [],
+ )
return {
key: phase.key,
label: phase.label,
@@ -431,6 +497,157 @@ export function parseParams(text) {
return { params: value }
}
+// ── Step params, as a form (Phase 13) ─────────────────────────────
+//
+// §I: the step editor is *"the condition builder, exactly — core serves a
+// catalog, the module declared the schema, core renders a form it does not
+// understand"*. Phase 3 shipped the raw JSON box as an explicit placeholder for
+// this, and everything the form needs was already in the catalog: a param's
+// name, type, whether it is required, its description, its example, and the
+// option source behind it.
+//
+// **The JSON stays as the storage and as the escape hatch, and both halves of
+// that matter.** As storage, because `payloadFromForm` already builds a request
+// out of it and a second representation would be two things to keep in step. As
+// an escape hatch, because a form can only render what the declaration
+// describes — and a step may legitimately hold something it does not.
+//
+// The rule for when the form gives way is the CONDITION BUILDER'S rule, which is
+// the reason this reads as a port of it rather than as a new idea: a value the
+// editor cannot round-trip is SHOWN rather than silently rewritten. Flattening
+// `A and (B or C)` there and dropping an undeclared param here are the same
+// mistake — a save that looks clean and means something else.
+
+/** The two ways a step's params are edited. */
+export const PARAM_FORM = 'form'
+export const PARAM_JSON = 'json'
+
+/**
+ * Can this step's params be rendered as a form without losing anything?
+ *
+ * `{ ok: true }`, or `{ ok: false, reason }` naming what the form cannot hold.
+ * Three things make one, and none of them is an error — each is a step that has
+ * to be edited as JSON:
+ *
+ * • **the action is dormant.** There is no declaration, so there are no fields.
+ * A form here would render nothing and look like a step with no params.
+ * • **a param the action does not declare.** The save refuses it by name, which
+ * 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.
+ */
+export function paramsRenderable(action, params) {
+ if (!action) return { ok: false, reason: 'the module that registered this action is not installed' }
+ const declared = new Map((action.params || []).map((p) => [p.name, p]))
+ for (const [name, value] of Object.entries(params || {})) {
+ if (!declared.has(name)) {
+ return { ok: false, reason: `this step carries "${name}", which ${action.id} does not declare` }
+ }
+ 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: true }
+}
+
+/**
+ * Which mode should this step open in?
+ *
+ * The author's own choice wins whenever the form COULD render the step — an
+ * author who switched to JSON stays in JSON. What they cannot do is stay in a
+ * form that would lose something, so an unrenderable step is forced to JSON
+ * whatever the choice was, and the reason is returned so the screen can say it.
+ */
+export function paramsMode(step, action) {
+ const parsed = parseParams(step?.paramsText)
+ if (parsed.error) return { mode: PARAM_JSON, forced: true, reason: parsed.error }
+ const renderable = paramsRenderable(action, parsed.params)
+ if (!renderable.ok) return { mode: PARAM_JSON, forced: true, reason: renderable.reason }
+ return { mode: step?.paramsMode === PARAM_JSON ? PARAM_JSON : PARAM_FORM, forced: false, reason: null }
+}
+
+/** One declared param's current value, as the control holds it. */
+export function paramValue(step, name) {
+ const parsed = parseParams(step?.paramsText)
+ if (parsed.error) return undefined
+ return parsed.params[name]
+}
+
+/**
+ * Write one param, and give back the whole box.
+ *
+ * **An empty field REMOVES the key rather than posting an empty string**, and
+ * that is the server's own reading rather than a convenience: `checkParams`
+ * treats `undefined`, `null` and `''` alike — absent — so a required param left
+ * blank comes back as *"is required"*, which is the error the author needs,
+ * instead of as a type complaint about `""`.
+ *
+ * **A value that does not parse is passed through as typed.** `coerceLiteral` is
+ * the engagement builder's, unchanged, and its rule is the one that matters
+ * here too: half of `-` is not a number, and turning it into `NaN` or `0` while
+ * somebody is still typing would either post a value they never wrote or make
+ * the field impossible to type a negative into. The server's type check then
+ * names the param.
+ *
+ * Re-serialising the whole object rather than splicing text, for `pickParam`'s
+ * reason: a string edit that produced valid-looking JSON with a duplicate key
+ * would be a value the editor and the server read differently.
+ */
+export function setParam(step, name, raw, type) {
+ const parsed = parseParams(step?.paramsText)
+ if (parsed.error) return step?.paramsText || '{}'
+ const next = { ...parsed.params }
+ if (raw === '' || raw === undefined || raw === null) delete next[name]
+ else next[name] = coerceLiteral(type, raw)
+ return JSON.stringify(next, null, 2)
+}
+
+/**
+ * A stored `datetime` as a `datetime-local` input wants it, and back.
+ *
+ * The server normalises a datetime param to an ISO string (`conditions.js`
+ * `checkLiteral`), and the input needs `YYYY-MM-DDTHH:mm` with no zone. The
+ * slice is the whole conversion in one direction; in the other the input's own
+ * text is a moment `new Date()` parses, so it is posted as typed and the server
+ * does the normalising — one implementation of what a datetime is, and it is
+ * not this one.
+ */
+export const datetimeInputValue = (value) => (typeof value === 'string' ? value.slice(0, 16) : '')
+
+/**
+ * Everything the meter needs out of the form, and nothing else.
+ *
+ * The price route takes a spec, not a definition: no title, no schedule, no
+ * series. Sending the whole payload would put a document in front of a route
+ * that reads two fields of it — and would fail the moment the rest of the form
+ * is mid-edit, which is exactly when the meter is being read.
+ *
+ * A step whose params do not parse is sent with none rather than dropped, so a
+ * half-typed JSON box costs its own step's draw and not the phase's.
+ */
+export function priceBodyFrom(form) {
+ return {
+ phases: (form?.phases || []).map((phase) => ({
+ key: phase.key || null,
+ steps: (phase.steps || []).map((step) => ({
+ actionId: step.actionId || '',
+ params: parseParams(step.paramsText).params || {},
+ })),
+ })),
+ }
+}
+
+/**
+ * Is this plan worth pricing at all?
+ *
+ * A meter that fires on an empty form asks the server what nothing costs, on
+ * every keystroke of the title field. One step with an action chosen is the
+ * threshold, because that is the first moment there is an answer.
+ */
+export const worthPricing = (form) =>
+ (form?.phases || []).some((p) => (p.steps || []).some((s) => s.actionId))
+
// ── Rendering what happened ────────────────────────────────────────────────
const STATUS_WORDS = {
diff --git a/client/src/routes/admin/views/EventEditor.jsx b/client/src/routes/admin/views/EventEditor.jsx
index e25c574..3ce4ea2 100644
--- a/client/src/routes/admin/views/EventEditor.jsx
+++ b/client/src/routes/admin/views/EventEditor.jsx
@@ -8,6 +8,8 @@ import {
payloadFromForm,
blankPhase,
blankAdvance,
+ blankWhere,
+ whereFormFrom,
ADVANCE_KINDS,
blankStep,
describeSchedule,
@@ -15,7 +17,16 @@ import {
SCHEDULE_KINDS,
MONTHLY_NTHS,
WEEKDAYS,
+ PARAM_FORM,
+ PARAM_JSON,
+ paramsMode,
+ paramValue,
+ setParam,
+ datetimeInputValue,
+ priceBodyFrom,
+ worthPricing,
} from '../../../lib/eventAuthoring.js'
+import { operatorsForType } from '../../../lib/engagementRules.js'
// Admin → Events → the definition editor (EVENTS.md §I, Phase 3).
//
@@ -32,13 +43,21 @@ import {
// knowing any of it, and `check:modules` already fails core's build on a UO
// identifier.
//
-// **The params box is a raw JSON field and it is captioned as a placeholder**,
-// because that is what it is: Phase 13 replaces it with the schema-driven form
-// the condition builder already models. What makes it usable in the meantime is
-// that a new step arrives PREFILLED from the action's declared examples, and the
-// declaration is rendered beside the box — every param's name, type, whether it
-// is required, and what a value looks like. All of that was already in the
-// catalog; none of it is a second copy of anything.
+// **The params are a FORM as of Phase 13**, one control per declared param,
+// rendered from a schema core does not understand — §I's *"the condition
+// builder, exactly"*. The JSON box did not go away: it is the escape hatch, and
+// a step opens in it automatically when the form could not hold what the step
+// carries. That rule is the condition builder's own, ported rather than
+// reinvented — dropping a param the action does not declare and flattening
+// `A and (B or C)` are the same mistake, a save that looks clean and means
+// something else.
+//
+// **The meter beside the timeline is not a lighter dry run.** `POST
+// /admin/events/price` dispatches nothing, so it knows nothing a module knows —
+// whether the landmark exists, whether the shard is up. It answers the half core
+// can answer alone, which is what a plan would SPEND, and it can therefore run on
+// a debounce while somebody types. The dry run stays the thing that asks the
+// modules, and the screen labels them apart.
/**
* The values behind one param's `source` (§F *Param option sources*, Phase 7).
@@ -88,9 +107,7 @@ function ParamOptions({ entry, label, disabled, onPick }) {
disabled={disabled}
onChange={(e) => { if (e.target.value) onPick(e.target.value) }}
>
-
+
{grouped
? groups.map((g) => (
+ {/* ── The live cap meter (Phase 13) ──
+ Above the timeline rather than under it, because what a plan draws is a
+ fact about the whole plan and an author scrolling twelve steps to find
+ out they are over is an author who finds out too late. */}
+
+
{/* ── The phase timeline ── */}
Phases
@@ -754,6 +1309,20 @@ export default function EventEditor() {
cannot change once runs exist.
+ {/* §I asks the timeline for "phases in order, each with its steps, its
+ advance condition, its cap draw and its failure policy". This is
+ the cap draw, and it is the phase's own rather than a share of the
+ total: an author moving a step between phases is asking exactly
+ this question. */}
+ {(drawByPhase.get(pi) || []).length > 0 && (
+
+ )}
+
{/* ── The advance condition (Phase 5) ──
A gate is an ADDITIONAL condition and never a replacement, which is
what the caption has to say: a phase whose steps are still running
@@ -779,8 +1348,16 @@ export default function EventEditor() {
<>
@@ -825,6 +1391,9 @@ export default function EventEditor() {
{phase.steps.map((step, si) => {
const action = actionById.get(step.actionId)
+ // Phase 13. Which editor this step gets, and — when it is not the
+ // author's choice — why.
+ const mode = paramsMode(step, action)
return (
- Pick an action and its parameters are listed here, straight from what the
- module declared.
-
+ {/* ── The params (Phase 13) ──
+ A form of the action's own declaration, with the JSON box
+ kept as the escape hatch. A step the form cannot hold
+ without losing something opens in JSON and says why — the
+ condition builder's rule, and the same one, because
+ dropping an undeclared param and flattening a nested
+ condition are the same failure: a save that looks clean
+ and means something else. */}
+
+ Pick an action and its fields appear here, straight from what the module
+ declared.
+
+ )}
+
+ ) : (
+ <>
+
)
diff --git a/client/test/eventAuthoring.test.js b/client/test/eventAuthoring.test.js
index 14a7477..04dc984 100644
--- a/client/test/eventAuthoring.test.js
+++ b/client/test/eventAuthoring.test.js
@@ -23,6 +23,17 @@ import {
WEEKDAYS,
MONTHLY_NTHS,
ADVANCE_KINDS,
+ blankWhere,
+ whereFormFrom,
+ paramsRenderable,
+ paramsMode,
+ paramValue,
+ setParam,
+ datetimeInputValue,
+ priceBodyFrom,
+ worthPricing,
+ PARAM_FORM,
+ PARAM_JSON,
} from '../src/lib/eventAuthoring.js'
// lib/eventAuthoring.js — what the three Events screens say and what they let
@@ -479,29 +490,97 @@ test('a gate round-trips through the form without losing the other shape', () =>
assert.equal(ADVANCE_KINDS[0].value, '')
})
-test('advancePayload sends one shape, and only reports a JSON error', () => {
+test('advancePayload sends one shape, built from the builder\u2019s rows', () => {
const errors = []
assert.equal(advancePayload({ kind: '' }, 'Phase 1', errors), null, 'no gate sends no key at all')
assert.deepEqual(advancePayload({ kind: 'after', after: '30m' }, 'Phase 1', errors), { after: '30m' })
assert.deepEqual(
- advancePayload({ kind: 'on', on: 'uo.champ.boss_up', count: '2', whereText: '' }, 'Phase 1', errors),
+ advancePayload({ kind: 'on', on: 'uo.champ.boss_up', count: '2', ...blankWhere() }, 'Phase 1', errors),
{ on: 'uo.champ.boss_up', count: 2 },
'an empty predicate is omitted, not sent as an empty object',
)
assert.equal(errors.length, 0)
- advancePayload({ kind: 'on', on: 'x', count: 1, whereText: '{ not json' }, 'Phase 2 "Boss"', errors)
- assert.equal(errors.length, 1)
- assert.match(errors[0], /Phase 2 "Boss", advance condition:/)
-
- // Whether the predicate is VALID is the server's answer, named variable and
- // all. This only refuses text that cannot be put in a request.
- const clean = []
+ // Whether the predicate is VALID is still the server's answer, named variable
+ // and all \u2014 the builder only offers what the trigger declares, and a variable
+ // that has gone away comes back named from the save.
assert.deepEqual(
- advancePayload({ kind: 'on', on: 'x', count: 1, whereText: '{"variable":"nope","cmp":"eq","value":1}' }, 'Phase 1', clean),
+ advancePayload(
+ {
+ kind: 'on',
+ on: 'x',
+ count: 1,
+ ...blankWhere(),
+ whereRows: [{ variable: 'nope', cmp: 'eq', value: '1' }],
+ },
+ 'Phase 1',
+ errors,
+ [{ name: 'nope', type: 'int' }],
+ ),
{ on: 'x', count: 1, where: { variable: 'nope', cmp: 'eq', value: 1 } },
)
- assert.equal(clean.length, 0)
+ assert.equal(errors.length, 0)
+})
+
+test('the builder coerces each literal to the type the trigger declared', () => {
+ // The trap this closes: every value in an HTML input is a string, and
+ // `{ cmp: 'gt', value: "5" }` against an int variable is refused by
+ // engagement/conditions.js. Without this the author reads an error about JSON
+ // rather than about what they typed.
+ const built = advancePayload(
+ {
+ kind: 'on',
+ on: 'x',
+ count: 1,
+ ...blankWhere(),
+ whereOp: 'or',
+ whereRows: [
+ { variable: 'tier', cmp: 'gte', value: '3' },
+ { variable: 'region', cmp: 'in', value: 'Yew, Britain' },
+ ],
+ },
+ 'Phase 1',
+ [],
+ [{ name: 'tier', type: 'int' }, { name: 'region', type: 'string' }],
+ )
+ assert.deepEqual(built.where, {
+ op: 'or',
+ nodes: [
+ { variable: 'tier', cmp: 'gte', value: 3 },
+ { variable: 'region', cmp: 'in', value: ['Yew', 'Britain'] },
+ ],
+ })
+})
+
+test('a predicate the builder cannot render is posted back unchanged, not flattened', () => {
+ // `A and (B or C)` is not `A and B and C` \u2014 they fire on different events \u2014
+ // and an author would have no way to know the save had done it. The condition
+ // builder's own rule, and this is the same function.
+ const nested = {
+ op: 'and',
+ nodes: [
+ { variable: 'region', cmp: 'eq', value: 'Yew' },
+ { op: 'or', nodes: [{ variable: 'tier', cmp: 'eq', value: 1 }, { variable: 'tier', cmp: 'eq', value: 2 }] },
+ ],
+ }
+ const form = whereFormFrom(nested)
+ assert.equal(form.whereEditable, false)
+ assert.deepEqual(form.whereRows, [])
+
+ const errors = []
+ const built = advancePayload({ kind: 'on', on: 'x', count: 1, ...form }, 'Phase 1', errors)
+ assert.deepEqual(built.where, nested, 'the tree survives a screen that cannot draw it')
+ assert.equal(errors.length, 0)
+
+ // And the text is still the thing that can fail to parse, which is the only
+ // reason this path keeps an error channel at all.
+ advancePayload(
+ { kind: 'on', on: 'x', count: 1, whereEditable: false, whereText: '{ not json' },
+ 'Phase 2 "Boss"',
+ errors,
+ )
+ assert.equal(errors.length, 1)
+ assert.match(errors[0], /Phase 2 "Boss", advance condition:/)
})
test('a phase with no gate sends no `advance` key', () => {
@@ -602,3 +681,159 @@ test("the log renders Phase 6's three kinds, and a refusal does not read as a fa
/Version 2 passed its dry run — scheduled occurrences may start/,
)
})
+
+
+// ── Step params as a form (Phase 13) ──────────────────────────────
+//
+// The form is not a boundary either — `events/spec.js` still decides what may be
+// saved. What is tested here is the thing that would be wrong SILENTLY: a form
+// that drops a param it cannot draw, or writes a value the author never typed.
+
+const spawn = {
+ id: 'test.spawn',
+ label: 'Spawn',
+ params: [
+ { name: 'creature', type: 'string', required: true, example: 'orc', source: 'test.creatures' },
+ { name: 'count', type: 'int', required: true, example: 8 },
+ { name: 'tame', type: 'boolean', required: false, example: false },
+ { name: 'at', type: 'datetime', required: false, example: '2026-09-07T20:00:00.000Z' },
+ ],
+}
+
+const stepWith = (params, over = {}) => ({
+ actionId: 'test.spawn',
+ paramsText: JSON.stringify(params, null, 2),
+ ...over,
+})
+
+test('a step whose params the form can hold opens as a form', () => {
+ const mode = paramsMode(stepWith({ creature: 'orc', count: 8 }), spawn)
+ assert.deepEqual(mode, { mode: PARAM_FORM, forced: false, reason: null })
+})
+
+test('an author who chose JSON stays in JSON', () => {
+ const mode = paramsMode(stepWith({ creature: 'orc' }, { paramsMode: PARAM_JSON }), spawn)
+ assert.equal(mode.mode, PARAM_JSON)
+ assert.equal(mode.forced, false, 'their choice, so no reason is shown')
+})
+
+test('a param the action does not declare FORCES the JSON box and says which', () => {
+ // The form would render four fields and post four values, having deleted
+ // `radius` — a save that looks clean and means something else. The save path
+ // refuses it by name, which is what the author needs to see.
+ const mode = paramsMode(stepWith({ creature: 'orc', count: 8, radius: 12 }), spawn)
+ assert.equal(mode.mode, PARAM_JSON)
+ assert.equal(mode.forced, true)
+ assert.match(mode.reason, /carries "radius", which test\.spawn does not declare/)
+})
+
+test('a value no single control can hold forces the JSON box', () => {
+ assert.match(paramsMode(stepWith({ creature: ['orc', 'troll'] }), spawn).reason, /holds a list/)
+ assert.match(paramsMode(stepWith({ creature: { id: 'orc' } }), spawn).reason, /holds a structure/)
+})
+
+test('a dormant step is edited as JSON, because there is no declaration to draw', () => {
+ const mode = paramsMode(stepWith({ creature: 'orc' }), undefined)
+ assert.equal(mode.mode, PARAM_JSON)
+ assert.equal(mode.forced, true)
+ assert.match(mode.reason, /not installed/)
+})
+
+test('a params box that is not JSON opens as JSON with the parse error', () => {
+ const mode = paramsMode({ actionId: 'test.spawn', paramsText: '{ not json' }, spawn)
+ assert.equal(mode.mode, PARAM_JSON)
+ assert.equal(mode.forced, true)
+ assert.match(mode.reason, /not valid JSON/)
+})
+
+test('paramsRenderable accepts a step with nothing in it', () => {
+ // A brand-new step with an optional-only action, and the empty case a form
+ // needs to survive before anybody has typed.
+ assert.deepEqual(paramsRenderable(spawn, {}), { ok: true })
+})
+
+test('setParam writes the type the param declared, not the string the input held', () => {
+ const step = stepWith({ creature: 'orc', count: 8 })
+ assert.deepEqual(JSON.parse(setParam(step, 'count', '12', 'int')), { creature: 'orc', count: 12 })
+ assert.deepEqual(JSON.parse(setParam(step, 'tame', 'true', 'boolean')), {
+ creature: 'orc',
+ count: 8,
+ tame: true,
+ })
+})
+
+test('a half-typed number is kept as typed rather than turned into NaN', () => {
+ // `coerceLiteral`'s rule, and the reason it is borrowed rather than rewritten:
+ // turning `-` into NaN while somebody types would either post a value they
+ // never wrote or make a negative impossible to enter. The server's type check
+ // then names the param.
+ const step = stepWith({ count: 8 })
+ assert.deepEqual(JSON.parse(setParam(step, 'count', '-', 'int')), { count: '-' })
+})
+
+test('clearing a field REMOVES the key rather than posting an empty string', () => {
+ // `checkParams` treats undefined, null and '' alike — absent — so a required
+ // param left blank comes back as "is required", which is the error the author
+ // needs, instead of a type complaint about "".
+ const step = stepWith({ creature: 'orc', count: 8 })
+ assert.deepEqual(JSON.parse(setParam(step, 'creature', '', 'string')), { count: 8 })
+})
+
+test('setParam leaves an unparseable box alone rather than overwriting it', () => {
+ // The only way to reach this is a race between the mode switch and a
+ // keystroke; silently replacing the text with `{ "count": 1 }` would destroy
+ // whatever the author was midway through writing.
+ const step = { actionId: 'test.spawn', paramsText: '{ not json' }
+ assert.equal(setParam(step, 'count', '1', 'int'), '{ not json')
+})
+
+test('paramValue reads one param, and answers nothing for a box that does not parse', () => {
+ assert.equal(paramValue(stepWith({ count: 8 }), 'count'), 8)
+ assert.equal(paramValue(stepWith({ count: 8 }), 'creature'), undefined)
+ assert.equal(paramValue({ paramsText: '{ not json' }, 'count'), undefined)
+})
+
+test('a datetime is sliced to what the input wants, and anything else is empty', () => {
+ assert.equal(datetimeInputValue('2026-09-07T20:00:00.000Z'), '2026-09-07T20:00')
+ assert.equal(datetimeInputValue(undefined), '')
+ assert.equal(datetimeInputValue(12), '')
+})
+
+// ── The meter's request (Phase 13) ────────────────────────────
+
+test('the price body carries the plan and nothing else', () => {
+ const form = formFromDefinition({
+ title: 'Invasion',
+ spec: {
+ schedule: { kind: 'manual' },
+ phases: [
+ { key: 'warn', label: 'Warn', steps: [{ actionId: 'core.announce', params: { trigger: 'x' } }] },
+ { key: 'assault', label: 'Assault', steps: [{ actionId: 'test.spawn', params: { count: 8 } }] },
+ ],
+ },
+ })
+ assert.deepEqual(priceBodyFrom(form), {
+ phases: [
+ { key: 'warn', steps: [{ actionId: 'core.announce', params: { trigger: 'x' } }] },
+ { key: 'assault', steps: [{ actionId: 'test.spawn', params: { count: 8 } }] },
+ ],
+ })
+})
+
+test('a step whose params do not parse is priced with none rather than dropped', () => {
+ // Dropping it would move every step after it up an ordinal, so the meter's
+ // "phase 2 step 3" would name a different step from the one on the screen.
+ const form = {
+ phases: [{ key: 'p', steps: [{ actionId: 'test.spawn', paramsText: '{ not json' }] }] ,
+ }
+ assert.deepEqual(priceBodyFrom(form).phases[0].steps, [{ actionId: 'test.spawn', params: {} }])
+})
+
+test('an empty plan is not worth pricing', () => {
+ // Otherwise the meter asks the server what nothing costs on every keystroke of
+ // the title field.
+ assert.equal(worthPricing({ phases: [] }), false)
+ assert.equal(worthPricing({ phases: [{ steps: [] }] }), false)
+ assert.equal(worthPricing({ phases: [{ steps: [{ actionId: '' }] }] }), false)
+ assert.equal(worthPricing({ phases: [{ steps: [{ actionId: 'test.spawn' }] }] }), true)
+})
diff --git a/server/routes.guards.json b/server/routes.guards.json
index b2c5dd5..3e83c5d 100644
--- a/server/routes.guards.json
+++ b/server/routes.guards.json
@@ -545,6 +545,15 @@
"requireAuth"
]
},
+ {
+ "method": "POST",
+ "path": "/api/v1/admin/events/price",
+ "handlers": 2,
+ "gates": [
+ "noindex",
+ "requireAuth"
+ ]
+ },
{
"method": "GET",
"path": "/api/v1/admin/events/runs",
diff --git a/server/routes.manifest.json b/server/routes.manifest.json
index 31caf15..df698f0 100644
--- a/server/routes.manifest.json
+++ b/server/routes.manifest.json
@@ -241,6 +241,10 @@
"method": "GET",
"path": "/api/v1/admin/events/catalog/options/:sourceId"
},
+ {
+ "method": "POST",
+ "path": "/api/v1/admin/events/price"
+ },
{
"method": "GET",
"path": "/api/v1/admin/events/runs"
diff --git a/server/src/events/price.js b/server/src/events/price.js
new file mode 100644
index 0000000..8561d51
--- /dev/null
+++ b/server/src/events/price.js
@@ -0,0 +1,185 @@
+// ── The live cap meter ─────────────────────────────────────────────────────
+//
+// EVENTS.md §I, Phase 13: the step editor's "live cap meter". What would this
+// plan spend, and what does this deployment allow?
+//
+// **It is not the dry run, and the difference is the whole reason it exists.**
+// `verify.js` dispatches every step with `verify: true` — through the module,
+// and through the module to a sidecar and a game tick — and a pass against a
+// published version is RECORDED, because that record is what §K's gate reads
+// before letting a schedule start something unattended. Both of those are right
+// for an act an author performs once, deliberately, when the plan is finished.
+// Neither is right for a number that has to move while somebody types: a meter
+// on the dry run's path would put a shard round trip behind every keystroke and
+// would stamp `verified_at` from a form still being edited.
+//
+// So this file answers the half of the question core can answer ON ITS OWN:
+// `cost()` is a pure function of params (§F), and the caps come from the
+// switchboard. Nothing is dispatched, nothing is written, and no definition need
+// exist — the body is the spec in the author's hands, saved or not.
+//
+// **What it therefore cannot tell you** is everything the module knows: whether
+// the landmark exists, whether the creature is on the allowlist, whether the
+// shard is reachable. That is the dry run's, and the meter must not read as a
+// substitute for it — which is why the editor keeps both and labels them apart.
+//
+// ## Why the per-phase subtotal is here rather than computed in the browser
+//
+// §I asks the timeline for "its cap draw" per phase, and the arithmetic is
+// trivial — but the *inputs* are not in the browser. `cost()` runs on the server
+// and only on the server; a client that summed anything would first have to be
+// handed per-step costs, which is this same call. Returning the phase rollup
+// beside the total costs one pass over a list core has already walked.
+
+const authorize = require('./authorize')
+const settingsDb = require('../model/events/eventActionSettings.db')
+const registries = require('../modules/registries')
+const spec = require('./spec')
+
+/**
+ * Flatten `{ phases: [{ key, steps: [...] }] }` into the priceable steps.
+ *
+ * Bounded by the spec's own limits rather than by a number invented here: this
+ * route takes an unsaved spec, so it is reachable with a body the save path
+ * would refuse, and the paste guard has to be the same one.
+ */
+function flatten(body) {
+ const phases = Array.isArray(body?.phases) ? body.phases : []
+ if (phases.length > spec.MAX_PHASES) {
+ return { ok: false, error: `at most ${spec.MAX_PHASES} phases` }
+ }
+ const flat = []
+ for (const [index, phase] of phases.entries()) {
+ const steps = Array.isArray(phase?.steps) ? phase.steps : []
+ if (steps.length > spec.MAX_STEPS_PER_PHASE) {
+ return { ok: false, error: `at most ${spec.MAX_STEPS_PER_PHASE} steps in one phase` }
+ }
+ for (const [seq, step] of steps.entries()) {
+ flat.push({
+ // The key is what the editor groups by, and an unsaved phase may not
+ // have a valid one yet — so the ordinal is what is echoed back. A meter
+ // that could only address a phase whose key already validates would go
+ // blank exactly while somebody is naming it.
+ phase: index,
+ phaseKey: typeof phase?.key === 'string' ? phase.key : null,
+ seq,
+ actionId: typeof step?.actionId === 'string' ? step.actionId : '',
+ params: step && typeof step.params === 'object' && !Array.isArray(step.params) ? step.params : {},
+ })
+ }
+ }
+ if (flat.length > spec.MAX_STEPS) {
+ return { ok: false, error: `at most ${spec.MAX_STEPS} steps in one definition` }
+ }
+ return { ok: true, flat }
+}
+
+/**
+ * Price a spec.
+ *
+ * **A step core cannot price is reported, never treated as free.** Three things
+ * make one: no module registers the action, the action's `cost()` failed its own
+ * contract (`priceOf` answers `null`), or it prices a dimension nobody declared.
+ * All three make the totals below an UNDER-count, and a meter that silently
+ * under-counts is worse than no meter — it is a number an author trusts that is
+ * smaller than what will happen. So each one comes back in `unpriced` with the
+ * step it belongs to, and the client shows the meter as incomplete.
+ *
+ * The third is not a refusal to price: an action that spends `uo.creatures`
+ * spends it whether or not a module declared the dimension, so the amount is
+ * still counted and the entry says the total is *unenforceable* rather than
+ * unknown. Same split `authorize.undeclaredDimensions` makes for the same
+ * reason.
+ */
+async function priceSpec(body) {
+ const flattened = flatten(body)
+ if (!flattened.ok) return { ok: false, error: flattened.error }
+ const { flat } = flattened
+
+ const settings = await settingsDb.byIds(flat.map((s) => s.actionId))
+ const totals = {}
+ const byPhase = new Map()
+ const unpriced = []
+ let priced = 0
+
+ const addTo = (bag, dimension, amount) => {
+ bag[dimension] = (bag[dimension] || 0) + amount
+ }
+
+ for (const step of flat) {
+ if (!byPhase.has(step.phase)) {
+ byPhase.set(step.phase, { phase: step.phase, key: step.phaseKey, steps: 0, draw: {} })
+ }
+ const phase = byPhase.get(step.phase)
+ phase.steps += 1
+
+ const where = { phase: step.phase, seq: step.seq, actionId: step.actionId || null }
+ const action = step.actionId ? registries.eventAction(step.actionId) : null
+ if (!action) {
+ // A step with no action chosen yet is not a problem — it is a form being
+ // filled in — so it is not reported. A step naming an action nothing
+ // registers is, because that is the dormant case and it under-counts.
+ if (step.actionId) {
+ unpriced.push({ ...where, code: 'dormant', message: `no module registers "${step.actionId}"` })
+ }
+ continue
+ }
+
+ const cost = authorize.priceOf(action, step.params)
+ if (cost === null) {
+ unpriced.push({ ...where, code: 'unpriceable', message: `"${action.label}" could not report what it costs` })
+ continue
+ }
+ priced += 1
+ for (const [dimension, amount] of Object.entries(cost)) {
+ addTo(totals, dimension, amount)
+ addTo(phase.draw, dimension, amount)
+ }
+ for (const dimension of authorize.undeclaredDimensions(cost)) {
+ unpriced.push({
+ ...where,
+ code: 'undeclared',
+ message: `spends "${dimension}", which no installed module declares as a budget — this step is refused at dispatch`,
+ })
+ }
+ }
+
+ const caps = authorize.effectiveCaps(
+ flat.map((s) => ({ actionId: s.actionId, params: s.params })),
+ settings,
+ )
+
+ const cost = Object.entries(totals)
+ .sort(([a], [b]) => a.localeCompare(b))
+ .map(([dimension, total]) => {
+ const cap = (caps[dimension] || {}).cap ?? null
+ return {
+ dimension,
+ total,
+ cap,
+ from: (caps[dimension] || {}).from || null,
+ over: cap !== null && total > cap,
+ }
+ })
+
+ return {
+ ok: true,
+ steps: flat.length,
+ priced,
+ cost,
+ // Ordinal order, because that is timeline order and the client draws it
+ // beside each phase. A phase whose steps price to nothing still appears, so
+ // the rollup and the timeline have the same number of rows.
+ phases: [...byPhase.values()].map((p) => ({
+ phase: p.phase,
+ key: p.key,
+ steps: p.steps,
+ draw: Object.entries(p.draw)
+ .sort(([a], [b]) => a.localeCompare(b))
+ .map(([dimension, total]) => ({ dimension, total })),
+ })),
+ unpriced,
+ }
+}
+
+module.exports = { priceSpec }
diff --git a/server/src/router/v1/admin/events.controller.js b/server/src/router/v1/admin/events.controller.js
index 9eb4eaf..2dce68f 100644
--- a/server/src/router/v1/admin/events.controller.js
+++ b/server/src/router/v1/admin/events.controller.js
@@ -33,6 +33,7 @@ const logDb = require('../../../model/events/eventRunLog.db')
const activity = require('../../../model/activity/activity.model')
const settingsDb = require('../../../model/events/eventActionSettings.db')
const authorize = require('../../../events/authorize')
+const price = require('../../../events/price')
const asId = (raw) => {
const n = Number(raw)
@@ -466,6 +467,44 @@ exports.verify = async (req, res) => {
})
}
+/**
+ * POST /api/v1/admin/events/price — the live cap meter (Phase 13).
+ *
+ * `admin, editor`, exactly as the dry run is and for the same reason: an author
+ * should be able to find out what their plan would cost before asking an admin
+ * to commit the deployment to it.
+ *
+ * **The spec is in the BODY, not looked up by id**, and that is the whole point
+ * of the route. The meter answers a question about the plan in the author's
+ * hands — half-typed, unsaved, and quite possibly not publishable yet — so a
+ * route that read the stored draft would be answering about a spec the author is
+ * no longer looking at.
+ *
+ * It dispatches nothing, unlike `verify`, and it records nothing, unlike a dry
+ * run that passes against a version — which is the stamp §K's unattended-start
+ * gate reads. Those two absences are exactly what make it safe to call while
+ * somebody is still typing.
+ *
+ * A body core cannot make sense of is a `400`; a plan that is over the caps is a
+ * **200**, for the dry run's reason — *"this asks for 45 and you allow 30"* is
+ * an answer, not a failed request.
+ *
+ * Not logged to the activity trail. It is a read that changes nothing and it
+ * fires on a debounce while a form is edited; an audit line per keystroke would
+ * bury the acts that matter under the act of looking.
+ */
+exports.price = async (req, res) => {
+ const result = await price.priceSpec(req.body || {})
+ if (!result.ok) return res.status(400).json({ error: result.error })
+ return res.json({
+ steps: result.steps,
+ priced: result.priced,
+ cost: result.cost,
+ phases: result.phases,
+ unpriced: result.unpriced,
+ })
+}
+
/**
* GET /api/v1/admin/events/actions — the deployment's switchboard.
*
diff --git a/server/src/router/v1/admin/events.router.js b/server/src/router/v1/admin/events.router.js
index 20c2b26..29015b1 100644
--- a/server/src/router/v1/admin/events.router.js
+++ b/server/src/router/v1/admin/events.router.js
@@ -83,6 +83,30 @@ eventsRouter.get(
controller.options,
)
+// ── The live cap meter (Phase 13) ──────────────────────────────────
+//
+// A literal path for the same reason `/actions` is one, and `admin, editor` for
+// the same reason `verify` is: it dispatches nothing and it prices an author's
+// own work.
+//
+// **It takes a spec rather than an id**, which is what separates it from the dry
+// run. A meter has to answer about the form as it stands, and the form is not
+// saved between keystrokes.
+
+eventsRouter.post(
+ '/price',
+ // #swagger.tags = ['Admin · Events']
+ // #swagger.summary = 'Price an unsaved spec against the per-run caps, dispatching nothing'
+ // #swagger.description = 'EVENTS.md I, the step editor live cap meter (Phase 13). What would this plan spend, and what does this deployment allow? The spec is in the BODY rather than looked up by id, and that is the whole point: the meter answers about the plan in the author hands -- half-typed, unsaved, quite possibly not publishable yet -- so a route that read the stored draft would be answering about a spec the author is no longer looking at. It is NOT the dry run and must not read as a substitute for one: nothing is dispatched, so nothing here knows whether the landmark exists or the shard is reachable, and nothing is recorded, so it never stamps the verification that EVENTS.md K unattended-start gate reads. Those two absences are exactly what make it safe to call on a debounce while somebody types. A step core cannot price is reported in `unpriced` rather than counted as free -- no module registers the action, its cost() failed its own contract, or it spends a dimension nobody declares -- because a meter that silently under-counts is worse than no meter. `phases` is the per-phase draw the timeline renders beside each phase. A plan over the caps is a 200, for the dry run reason: asking for 45 when 30 is allowed is an answer, not a failed request.'
+ // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
+ /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { phases: { type: "array", items: { type: "object", properties: { key: { type: "string" }, steps: { type: "array", items: { type: "object", properties: { actionId: { type: "string" }, params: { type: "object", additionalProperties: true } } } } } } } } } } } } */
+ /* #swagger.responses[200] = { description: 'The draw per dimension, the draw per phase, and every step that could not be priced', content: { "application/json": { schema: { type: "object", properties: { steps: { type: "integer" }, priced: { type: "integer" }, cost: { type: "array", items: { type: "object", properties: { dimension: { type: "string" }, total: { type: "integer" }, cap: { type: "integer" }, from: { type: "string" }, over: { type: "boolean" } } } }, phases: { type: "array", items: { type: "object", additionalProperties: true } }, unpriced: { type: "array", items: { type: "object", additionalProperties: true } } } } } } } */
+ /* #swagger.responses[400] = { description: 'The body is over the spec size limits', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ /* #swagger.responses[403] = { description: 'Not an admin or editor', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ adminOrEditor,
+ controller.price,
+)
+
// ── The switchboard (Phase 6) ──────────────────────────────────────────────
//
// A literal path, so it is declared up here with `/catalog` rather than beside
diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json
index 3b24427..4e42307 100644
--- a/server/swagger/swagger-output.json
+++ b/server/swagger/swagger-output.json
@@ -4056,6 +4056,138 @@
]
}
},
+ "/api/v1/admin/events/price": {
+ "post": {
+ "tags": [
+ "Admin · Events"
+ ],
+ "summary": "Price an unsaved spec against the per-run caps, dispatching nothing",
+ "description": "EVENTS.md I, the step editor live cap meter (Phase 13). What would this plan spend, and what does this deployment allow? The spec is in the BODY rather than looked up by id, and that is the whole point: the meter answers about the plan in the author hands -- half-typed, unsaved, quite possibly not publishable yet -- so a route that read the stored draft would be answering about a spec the author is no longer looking at. It is NOT the dry run and must not read as a substitute for one: nothing is dispatched, so nothing here knows whether the landmark exists or the shard is reachable, and nothing is recorded, so it never stamps the verification that EVENTS.md K unattended-start gate reads. Those two absences are exactly what make it safe to call on a debounce while somebody types. A step core cannot price is reported in `unpriced` rather than counted as free -- no module registers the action, its cost() failed its own contract, or it spends a dimension nobody declares -- because a meter that silently under-counts is worse than no meter. `phases` is the per-phase draw the timeline renders beside each phase. A plan over the caps is a 200, for the dry run reason: asking for 45 when 30 is allowed is an answer, not a failed request.",
+ "responses": {
+ "200": {
+ "description": "The draw per dimension, the draw per phase, and every step that could not be priced",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "steps": {
+ "type": "integer"
+ },
+ "priced": {
+ "type": "integer"
+ },
+ "cost": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "dimension": {
+ "type": "string"
+ },
+ "total": {
+ "type": "integer"
+ },
+ "cap": {
+ "type": "integer"
+ },
+ "from": {
+ "type": "string"
+ },
+ "over": {
+ "type": "boolean"
+ }
+ }
+ }
+ },
+ "phases": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ },
+ "unpriced": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "The body is over the spec size limits",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Not an admin or editor",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "phases": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "key": {
+ "type": "string"
+ },
+ "steps": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "actionId": {
+ "type": "string"
+ },
+ "params": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
"/api/v1/admin/events/runs": {
"get": {
"tags": [
diff --git a/server/test/eventPrice.test.js b/server/test/eventPrice.test.js
new file mode 100644
index 0000000..d9291d0
--- /dev/null
+++ b/server/test/eventPrice.test.js
@@ -0,0 +1,309 @@
+// ── The live cap meter (EVENTS_PLAN.md Phase 13) ───────────────────────────
+//
+// `events/price.js` answers what a plan would spend without dispatching a thing.
+// Three of the tests below protect a decision rather than a mechanism, and they
+// are the reason this file exists apart from `eventVerify.test.js`:
+//
+// • **A step core cannot price is reported, never counted as free.** All three
+// ways that happens — a dormant action, a `cost()` that broke its own
+// contract, and a dimension nobody declared — make the totals an UNDER-count,
+// and a meter an author trusts that reads lower than what will happen is
+// worse than no meter at all.
+// • **An undeclared dimension is still counted.** It is unenforceable, not
+// unknown: the action really will try to spend it, and the step is refused at
+// dispatch for that reason. Reporting it as costing nothing would hide both
+// facts at once.
+// • **The route dispatches nothing.** An action whose `perform()` would throw
+// prices perfectly well here, which is what makes the meter safe on a
+// debounce — `verify` puts a module and a sidecar behind every call and this
+// deliberately does not.
+//
+// The registry is the real one, staged and applied the way a module does it, for
+// `eventAuthorize.test.js`'s reason: an action that would not register is not one
+// this file has to survive.
+
+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 price = require('../src/events/price')
+const settingsDb = require('../src/model/events/eventActionSettings.db')
+const db = require('../src/utils/db')
+
+after(() => db.close())
+
+const originalSettings = { ...settingsDb }
+
+let settings
+
+beforeEach(() => {
+ registries._reset()
+ settings = new Map()
+ settingsDb.byIds = async (ids) =>
+ new Map([...new Set(ids || [])].filter((i) => settings.has(i)).map((i) => [i, settings.get(i)]))
+})
+
+afterEach(() => {
+ Object.assign(settingsDb, originalSettings)
+ registries._reset()
+})
+
+const action = (id, over = {}) => ({
+ id,
+ label: over.label || id,
+ risk: over.risk || 'notify',
+ reversible: over.reversible || 'none',
+ params: over.params || [],
+ ...(over.cost ? { cost: over.cost } : {}),
+ async perform() {
+ return over.perform ? over.perform() : { ok: true }
+ },
+})
+
+const register = (entries, { owner = 'test', budgets = [] } = {}) => {
+ const api = registries.stage(owner)
+ api.registerEventActions(entries)
+ if (budgets.length) api.registerEventBudgets(budgets.map((id) => ({ id, label: id, unit: 'count' })))
+ registries.apply(api.staged)
+}
+
+const setCaps = (id, caps) => settings.set(id, { action_id: id, enabled: 1, caps })
+
+/** `{ phases: [...] }` out of a compact `[[step, step], [step]]`. */
+const spec = (phases) => ({
+ phases: phases.map((steps, i) => ({
+ key: `phase${i + 1}`,
+ steps: steps.map(([actionId, params = {}]) => ({ actionId, params })),
+ })),
+})
+
+const dimension = (report, id) => report.cost.find((c) => c.dimension === id)
+
+// ── The whole-plan total, which is the number the meter exists to show ──────
+
+test('adds a dimension up across every phase and compares it to the tightest cap', async () => {
+ register(
+ [
+ action('test.spawn', { cost: (p) => ({ 'test.creatures': Number(p.count) || 0 }) }),
+ action('test.boss', { cost: () => ({ 'test.creatures': 1 }) }),
+ ],
+ { budgets: ['test.creatures'] },
+ )
+ setCaps('test.spawn', { 'test.creatures': 30 })
+ // The tightest cap wins: two actions spending one dimension have to agree on
+ // one number, and a safety limit settles on the smaller.
+ setCaps('test.boss', { 'test.creatures': 24 })
+
+ const report = await price.priceSpec(
+ spec([
+ [['test.spawn', { count: 15 }]],
+ [['test.spawn', { count: 15 }], ['test.boss', {}]],
+ ]),
+ )
+
+ assert.equal(report.ok, true)
+ assert.equal(report.steps, 3)
+ assert.equal(report.priced, 3)
+ assert.deepEqual(dimension(report, 'test.creatures'), {
+ dimension: 'test.creatures',
+ total: 31,
+ cap: 24,
+ from: 'test.boss',
+ over: true,
+ })
+})
+
+test('a plan inside its cap is not over', async () => {
+ register([action('test.spawn', { cost: (p) => ({ 'test.creatures': Number(p.count) || 0 }) })], {
+ budgets: ['test.creatures'],
+ })
+ setCaps('test.spawn', { 'test.creatures': 30 })
+
+ const report = await price.priceSpec(spec([[['test.spawn', { count: 12 }]]]))
+ assert.equal(dimension(report, 'test.creatures').over, false)
+})
+
+test('a dimension nobody caps comes back uncapped rather than missing', async () => {
+ register([action('test.say', { cost: () => ({ 'test.broadcasts': 1 }) })], { budgets: ['test.broadcasts'] })
+
+ const report = await price.priceSpec(spec([[['test.say', {}]]]))
+ assert.deepEqual(dimension(report, 'test.broadcasts'), {
+ dimension: 'test.broadcasts',
+ total: 1,
+ cap: null,
+ from: null,
+ over: false,
+ })
+})
+
+// ── The per-phase draw the timeline renders ────────────────────────────────
+
+test('reports the draw per phase, in timeline order, including a phase that spends nothing', async () => {
+ register(
+ [
+ action('test.spawn', { cost: (p) => ({ 'test.creatures': Number(p.count) || 0 }) }),
+ action('test.wait'),
+ ],
+ { budgets: ['test.creatures'] },
+ )
+
+ const report = await price.priceSpec(
+ spec([
+ [['test.spawn', { count: 8 }]],
+ [['test.wait', {}]],
+ [['test.spawn', { count: 4 }], ['test.spawn', { count: 2 }]],
+ ]),
+ )
+
+ assert.deepEqual(
+ report.phases.map((p) => ({ phase: p.phase, key: p.key, steps: p.steps, draw: p.draw })),
+ [
+ { phase: 0, key: 'phase1', steps: 1, draw: [{ dimension: 'test.creatures', total: 8 }] },
+ // A phase whose steps cost nothing still appears, so the rollup and the
+ // timeline have the same number of rows.
+ { phase: 1, key: 'phase2', steps: 1, draw: [] },
+ { phase: 2, key: 'phase3', steps: 2, draw: [{ dimension: 'test.creatures', total: 6 }] },
+ ],
+ )
+})
+
+test('a phase is addressed by its ordinal, so an unnamed one still meters', async () => {
+ register([action('test.spawn', { cost: () => ({ 'test.creatures': 3 }) })], { budgets: ['test.creatures'] })
+
+ // The key a half-typed phase carries may not validate yet. A meter that could
+ // only address a phase whose key is already legal would go blank exactly while
+ // somebody is naming it.
+ const report = await price.priceSpec({ phases: [{ steps: [{ actionId: 'test.spawn', params: {} }] }] })
+ assert.equal(report.phases[0].phase, 0)
+ assert.equal(report.phases[0].key, null)
+ assert.equal(dimension(report, 'test.creatures').total, 3)
+})
+
+// ── The three ways a step cannot be priced ─────────────────────────────────
+
+test('a step naming an action nothing registers is reported, not silently free', async () => {
+ register([action('test.spawn', { cost: () => ({ 'test.creatures': 5 }) })], { budgets: ['test.creatures'] })
+
+ const report = await price.priceSpec(spec([[['test.spawn', {}], ['uo.creature.spawn', {}]]]))
+
+ assert.equal(report.steps, 2)
+ assert.equal(report.priced, 1)
+ assert.deepEqual(report.unpriced, [
+ {
+ phase: 0,
+ seq: 1,
+ actionId: 'uo.creature.spawn',
+ code: 'dormant',
+ message: 'no module registers "uo.creature.spawn"',
+ },
+ ])
+})
+
+test('a step with no action chosen yet is not a problem', async () => {
+ register([action('test.spawn', { cost: () => ({ 'test.creatures': 5 }) })], { budgets: ['test.creatures'] })
+
+ // A form being filled in, not a plan with a hole in it. Reporting it would put
+ // a red line on the screen for every step the moment it is added.
+ const report = await price.priceSpec(spec([[['test.spawn', {}], ['', {}]]]))
+ assert.deepEqual(report.unpriced, [])
+ assert.equal(report.steps, 2)
+ assert.equal(report.priced, 1)
+})
+
+test('an action whose cost() breaks its own contract is unpriceable, not free', async () => {
+ register(
+ [
+ action('test.broken', {
+ label: 'Broken',
+ cost: () => {
+ throw new Error('nope')
+ },
+ }),
+ ],
+ { budgets: [] },
+ )
+
+ const report = await price.priceSpec(spec([[['test.broken', {}]]]))
+ assert.equal(report.priced, 0)
+ assert.equal(report.unpriced.length, 1)
+ assert.equal(report.unpriced[0].code, 'unpriceable')
+ assert.match(report.unpriced[0].message, /could not report what it costs/)
+})
+
+test('an undeclared dimension is COUNTED and reported as unenforceable', async () => {
+ // The split `authorize.undeclaredDimensions` makes, for the same reason: the
+ // action really will try to spend it — the step is refused at dispatch for
+ // exactly this — so the amount is true and the enforcement is what is missing.
+ register([action('test.spawn', { cost: () => ({ 'test.creatures': 9 }) })], { budgets: [] })
+
+ const report = await price.priceSpec(spec([[['test.spawn', {}]]]))
+ assert.equal(dimension(report, 'test.creatures').total, 9)
+ assert.equal(report.priced, 1)
+ assert.equal(report.unpriced[0].code, 'undeclared')
+ assert.match(report.unpriced[0].message, /refused at dispatch/)
+})
+
+// ── What makes it safe to call while somebody types ────────────────────────
+
+test('prices without dispatching: an action whose perform() throws still meters', async () => {
+ register(
+ [
+ action('test.spawn', {
+ cost: () => ({ 'test.creatures': 7 }),
+ perform: () => {
+ throw new Error('the shard is down')
+ },
+ }),
+ ],
+ { budgets: ['test.creatures'] },
+ )
+
+ const report = await price.priceSpec(spec([[['test.spawn', {}]]]))
+ assert.equal(dimension(report, 'test.creatures').total, 7)
+ assert.deepEqual(report.unpriced, [])
+})
+
+test('an empty plan prices to nothing rather than failing', async () => {
+ const report = await price.priceSpec({})
+ assert.deepEqual(report, { ok: true, steps: 0, priced: 0, cost: [], phases: [], unpriced: [] })
+})
+
+// ── The paste guard, which is the spec's own and not a number invented here ──
+
+test('refuses a body over the spec size limits', async () => {
+ const spawn = { actionId: 'test.spawn', params: {} }
+ const tooManyPhases = { phases: Array.from({ length: 41 }, (_, i) => ({ key: `p${i}`, steps: [] })) }
+ assert.deepEqual(await price.priceSpec(tooManyPhases), { ok: false, error: 'at most 40 phases' })
+
+ const tooManySteps = { phases: [{ key: 'p', steps: Array.from({ length: 101 }, () => spawn) }] }
+ assert.deepEqual(await price.priceSpec(tooManySteps), {
+ ok: false,
+ error: 'at most 100 steps in one phase',
+ })
+
+ // 40 x 100 is over MAX_STEPS while breaking neither of the two bounds above.
+ const tooManyOverall = {
+ phases: Array.from({ length: 40 }, (_, i) => ({
+ key: `p${i}`,
+ steps: Array.from({ length: 100 }, () => spawn),
+ })),
+ }
+ assert.deepEqual(await price.priceSpec(tooManyOverall), {
+ ok: false,
+ error: 'at most 500 steps in one definition',
+ })
+})
+
+test('a step whose params are not an object is priced as no params rather than throwing', async () => {
+ register([action('test.spawn', { cost: (p) => ({ 'test.creatures': Number(p.count) || 1 }) })], {
+ budgets: ['test.creatures'],
+ })
+
+ const report = await price.priceSpec({
+ phases: [{ key: 'p', steps: [{ actionId: 'test.spawn', params: ['not', 'an', 'object'] }] }],
+ })
+ assert.equal(dimension(report, 'test.creatures').total, 1)
+})
diff --git a/server/test/eventsRoles.test.js b/server/test/eventsRoles.test.js
index d0d64f5..c3c9411 100644
--- a/server/test/eventsRoles.test.js
+++ b/server/test/eventsRoles.test.js
@@ -24,6 +24,10 @@
// both buttons, would behave badly in exactly the case the moderator role
// exists for.
//
+// Phase 13 adds the meter beside `verify`, and it is the one route here that
+// neither dispatches nor records — which is what makes it safe to call on a
+// debounce while a form is edited.
+//
// Phase 6 adds the switchboard to the `admin` column — §K puts it in the same row
// as the world-changing actions it governs — and `verify` to the `admin, editor`
// one, because a dry run dispatches nothing and the author who wrote the
@@ -154,6 +158,9 @@ const SURFACE = [
['DELETE', '/events/series/1', ['admin', 'editor']],
// Phase 6. A dry run dispatches nothing and changes nothing.
['POST', '/events/1/verify', ['admin', 'editor']],
+ // Phase 13's meter, in the same column and for a stronger version of the
+ // same reason: it dispatches nothing AND records nothing.
+ ['POST', '/events/price', ['admin', 'editor']],
// Committing the deployment: admin only (§N2).
['POST', '/events/1/publish', ['admin']],
@@ -214,7 +221,9 @@ test('start and stop are NOT the same gate, and that is the point', async () =>
test('an editor may price an event but not publish or start it', async () => {
// Phase 6's addition to the same shape: the author who wrote the definition can
// find out what it would cost before asking an admin to commit the deployment.
+ // Phase 13 gave the same author the meter, on the same argument.
assert.equal(await forbidden('POST', '/events/1/verify', 'editor'), false)
+ assert.equal(await forbidden('POST', '/events/price', 'editor'), false)
assert.equal(await forbidden('POST', '/events/1/publish', 'editor'), true)
assert.equal(await forbidden('POST', '/events/1/runs', 'editor'), true)
})