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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
This commit is contained in:
185
server/src/events/price.js
Normal file
185
server/src/events/price.js
Normal 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 }
|
||||
Reference in New Issue
Block a user