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

View File

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