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
310 lines
12 KiB
JavaScript
310 lines
12 KiB
JavaScript
// ── 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)
|
|
})
|