feat(events): the authoring UI proper (Phase 13)
Some checks failed
PR Checks / client-build (pull_request) Successful in 31s
PR Checks / bot-tests (pull_request) Successful in 31s
PR Checks / server-tests (pull_request) Failing after 9m1s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
This commit is contained in:
2026-09-07 16:34:03 -05:00
parent db8e01e868
commit 8453762e3b
13 changed files with 1913 additions and 149 deletions

185
server/src/events/price.js Normal file
View File

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