feat(events): open the event contract to modules (Phase 7)
Some checks failed
PR Checks / bot-tests (pull_request) Successful in 29s
PR Checks / client-build (pull_request) Successful in 36s
PR Checks / server-tests (pull_request) Failing after 8m41s

MODULE_API 1.10.0. Four names forwarded on the module-facing `api` --
registerEventActions, registerEventBudgets, registerEventLeases and
registerEventOptionSources -- one new route, and one rule made real: a
`cost()` naming a dimension no module declared is refused.

Only one of the four is new machinery. The action registry has staged
core's three actions on every boot since Phase 1; what it never had was a
way in, because loader.js builds its own `api` facade and had no method
that delegated to it. So the registry a module now reaches is one that has
been exercised on every boot for six phases.

Four decisions, settled 2026-09-03, all as recommended:

- Option sources are their own registration, modelled on registerAudiences,
  because a catalog has more than one consumer.
- An undeclared dimension is refused -- at save, at the dry run and at
  dispatch -- with its own code, because the fix is a module's declaration
  and not a deployment's cap.
- A lease is declared here and acquired by nothing; the ledger is Phase 8.
- Core registers core.options.legs, so an announce leg is a dropdown rather
  than the free-text box whose typo Phase 6's walk caught mid-run.

Proved with a throwaway module through the real loader, not with module-uo:
eventModuleContract.test.js writes a module to a real directory and lets the
loader scan it, covering all five envelope failure shapes, verify: true, the
four id spaces and dormancy on uninstall.

The live walk found the one defect nothing else could: the option-source
loader wrote its "already asked?" guard inside a setState updater and read
it on the next line, so the request was never made and the field sat on
"Reading the list..." for ever. It is a useRef now.

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T6t8mrAWhZU5vnyYgZTMtL
This commit is contained in:
2026-09-03 14:15:04 -05:00
parent 429e657239
commit fd9fb50351
22 changed files with 1825 additions and 117 deletions

View File

@@ -247,3 +247,137 @@ test('_reset() hands the process back', () => {
assert.equal(registries.allEventActions().length, 0)
assert.equal(registries.isEventAction('core.wait'), false)
})
// ── Budgets, leases and option sources (§F, Phase 7) ───────────────────────
//
// The three declarations that arrive WITH the module-facing seam. What is worth
// a test here is the same thing that was worth one for actions: the closed sets
// are closed, the required members are required, and each id space is its own.
// The seam being reachable by a module at all is `eventModuleContract.test.js`,
// against a real loader; these are the shape rules, at the call.
const registerBudgets = (owner, entries) => {
const api = registries.stage(owner)
api.registerEventBudgets(entries)
registries.apply(api.staged)
}
const registerLeases = (owner, entries) => {
const api = registries.stage(owner)
api.registerEventLeases(entries)
registries.apply(api.staged)
}
const lease = (over = {}) => ({
id: 'demo.rate.gain',
label: 'Gain rate',
type: 'float',
min: 0.5,
max: 5,
maxDurationMs: 3_600_000,
read: async () => ({ ok: true, value: 1 }),
apply: async () => ({ ok: true }),
restore: async () => ({ ok: true }),
...over,
})
test('a budget needs a unit, because a number on a cap box is ambiguous without one', () => {
assert.throws(
() => registerBudgets('demo', [{ id: 'demo.wisps', label: 'Wisps' }]),
/has no unit/,
)
// Open vocabulary, deliberately: core never interprets a unit, it renders it.
// Closing the set would make "kilometres" a MODULE_API bump for a noun core
// does not read.
registerBudgets('demo', [{ id: 'demo.road', label: 'Road laid', unit: 'kilometres' }])
assert.equal(registries.eventBudget('demo.road').unit, 'kilometres')
})
test('a budget and an action are different id spaces, so one name may be both', () => {
// §F says it in one line — an action names a VERB and a budget names a
// RESOURCE — and this is the pair every module will actually write. Reading a
// collision here would forbid the most natural set of names there is.
const api = registries.stage('demo')
api.registerEventBudgets([{ id: 'demo.creatures', label: 'Creatures', unit: 'count' }])
api.registerEventActions([ok({ id: 'demo.creatures' })])
registries.apply(api.staged)
assert.equal(registries.eventBudget('demo.creatures').label, 'Creatures')
assert.equal(registries.eventAction('demo.creatures').label, 'Do the thing')
})
test('a budget is claimed once, and the second claim names who holds it', () => {
registerBudgets('demo', [{ id: 'demo.wisps', label: 'Wisps', unit: 'count' }])
assert.throws(
() => registerBudgets('other', [{ id: 'demo.wisps', label: 'Theirs', unit: 'count' }]),
/already registered by "demo"/,
)
})
test('a lease declares all three callables, and restore is not optional', () => {
// `read` could stand in for `restore`, and that is exactly why it may not:
// `read` answers "what is it now" and `restore` answers "put this back, and
// tell me if someone else has moved it". The drift check is the one thing a
// module must not be allowed to skip — a restore that writes blindly silently
// reverts an operator's manual fix.
for (const missing of ['read', 'apply', 'restore']) {
assert.throws(
() => registerLeases('demo', [lease({ [missing]: undefined })]),
new RegExp(`has no ${missing}\(\)`),
)
}
})
test('a numeric lease is bounded, and an unbounded one is refused', () => {
// A lease on a rate multiplier with no range is an operator one keystroke away
// from setting a shard's skill gain to 5000 — and unlike a cap, a bad lease
// value is in force the moment it is applied.
assert.throws(() => registerLeases('demo', [lease({ min: undefined })]), /numeric min and max/)
assert.throws(() => registerLeases('demo', [lease({ min: 9, max: 2 })]), /min 9 above max 2/)
assert.throws(
() => registerLeases('demo', [lease({ type: 'int', min: 0.5, max: 5 })]),
/whole-number min and max/,
)
// A bool holds no range and is not asked for one.
registerLeases('demo', [lease({ id: 'demo.seasonal', type: 'bool', min: undefined, max: undefined })])
assert.equal(registries.eventLease('demo.seasonal').min, null)
})
test('a lease may not be held longer than core is willing to promise', () => {
assert.throws(
() => registerLeases('demo', [lease({ maxDurationMs: registries.MAX_LEASE_MS + 1 })]),
/maxDurationMs must be 1\.\./,
)
assert.throws(() => registerLeases('demo', [lease({ maxDurationMs: 0 })]), /maxDurationMs must be/)
assert.throws(() => registerLeases('demo', [lease({ maxDurationMs: 1.5 })]), /maxDurationMs must be/)
})
test('an unknown lease type is refused, because core validates operator input against it', () => {
assert.throws(() => registerLeases('demo', [lease({ type: 'colour' })]), /needs a type, one of/)
assert.deepEqual(registries.LEASE_TYPES, ['int', 'float', 'bool', 'string'])
})
test('an option source needs a resolver, and core registers one of its own', () => {
const api = registries.stage('demo')
assert.throws(
() => api.registerEventOptionSources([{ id: 'demo.options.hues', label: 'Hues' }]),
/has no resolve\(\)/,
)
// Core through the same door (Phase 7): `core.announce`'s `leg` param names a
// source, and the announce legs are already a registry with labels in them.
registries.registerCore()
assert.deepEqual(registries.allEventOptionSources().map((s) => s.id), ['core.options.legs'])
const leg = registries.eventAction('core.announce').params.find((p) => p.name === 'leg')
assert.equal(leg.source, 'core.options.legs')
})
test('_reset() hands back the three new registries too', () => {
registries.registerCore()
registerBudgets('demo', [{ id: 'demo.wisps', label: 'Wisps', unit: 'count' }])
registerLeases('demo', [lease()])
assert.equal(registries.allEventBudgets().length, 1)
registries._reset()
assert.deepEqual(registries.allEventBudgets(), [])
assert.deepEqual(registries.allEventLeases(), [])
assert.deepEqual(registries.allEventOptionSources(), [])
})

View File

@@ -68,9 +68,43 @@ afterEach(() => {
registries._reset()
})
const register = (entries, owner = 'test') => {
/**
* Declare the budget dimensions a batch of actions prices.
*
* §F, fail closed (Phase 7): a `cost()` naming a dimension no module registered
* is refused BEFORE any cap arithmetic runs. Without this, every cap test below
* would pass for the WRONG reason — refused by the layer above the one under
* test. Discovered the way the switchboard discovers them, by pricing the
* action's own declared examples, so a test never has to keep a second list of
* its dimensions in step with its `cost()`.
*
* Written out here rather than borrowed from `authorize.dimensionsOf` so this
* file keeps stubbing exactly what it means to stub. The undeclared case is not
* an omission; it has its own test.
*/
const declaredBudgets = (entries, owner) => {
const dimensions = new Set()
for (const e of entries) {
if (typeof e.cost !== 'function') continue
const params = {}
for (const p of e.params || []) if (p.example !== undefined) params[p.name] = p.example
let priced
try {
priced = e.cost(params)
} catch {
continue
}
for (const d of Object.keys(priced || {})) dimensions.add(d)
}
return [...dimensions]
.filter((id) => id.startsWith(`${owner}.`))
.map((id) => ({ id, label: id, unit: 'count' }))
}
const register = (entries, { owner = 'test', budgets = true } = {}) => {
const api = registries.stage(owner)
api.registerEventActions(entries)
if (budgets) api.registerEventBudgets(declaredBudgets(entries, owner))
registries.apply(api.staged)
}
@@ -192,10 +226,10 @@ test('the refusal names the action by its LABEL, not its id', async () => {
// ── Pricing ────────────────────────────────────────────────────────────────
test('cost() is called with the step params and its answer is what core enforces', async () => {
register([action('test.spawn', { risk: 'change', cost: (p) => ({ 'x.creatures': p.count }) })])
register([action('test.spawn', { risk: 'change', cost: (p) => ({ 'test.creatures': p.count }) })])
setSetting('test.spawn', true)
const priced = authorize.priceOf(registries.eventAction('test.spawn'), { count: 12 })
assert.deepEqual(priced, { 'x.creatures': 12 })
assert.deepEqual(priced, { 'test.creatures': 12 })
})
test('a cost() that throws makes the action unpriceable, never free', async () => {
@@ -252,10 +286,10 @@ test('dimensions are discovered by pricing the declared examples', async () => {
action('test.spawn', {
risk: 'change',
params: [{ name: 'count', type: 'int', required: true, example: 4 }],
cost: (p) => ({ 'x.creatures': p.count, 'x.bosses': 1 }),
cost: (p) => ({ 'test.creatures': p.count, 'test.bosses': 1 }),
}),
])
assert.deepEqual(authorize.dimensionsOf(registries.eventAction('test.spawn')), ['x.bosses', 'x.creatures'])
assert.deepEqual(authorize.dimensionsOf(registries.eventAction('test.spawn')), ['test.bosses', 'test.creatures'])
})
test('an action whose cost() cannot survive its own examples reports no dimensions', async () => {
@@ -275,44 +309,44 @@ test('an action whose cost() cannot survive its own examples reports no dimensio
test('two actions spending one dimension resolve to the tightest cap, and it says whose', async () => {
register([
action('test.spawn', { risk: 'change', cost: () => ({ 'x.creatures': 1 }) }),
action('test.horde', { risk: 'change', cost: () => ({ 'x.creatures': 1 }) }),
action('test.spawn', { risk: 'change', cost: () => ({ 'test.creatures': 1 }) }),
action('test.horde', { risk: 'change', cost: () => ({ 'test.creatures': 1 }) }),
])
setSetting('test.spawn', true, { 'x.creatures': 30 })
setSetting('test.horde', true, { 'x.creatures': 10 })
setSetting('test.spawn', true, { 'test.creatures': 30 })
setSetting('test.horde', true, { 'test.creatures': 10 })
const caps = authorize.effectiveCaps(
[{ actionId: 'test.spawn', params: {} }, { actionId: 'test.horde', params: {} }],
await settingsDb.byIds(['test.spawn', 'test.horde']),
)
assert.deepEqual(caps, { 'x.creatures': { cap: 10, from: 'test.horde' } })
assert.deepEqual(caps, { 'test.creatures': { cap: 10, from: 'test.horde' } })
})
test('an action that declines to cap a dimension does not raise a ceiling another one set', async () => {
// `null` is uncapped and must never win a minimum. Otherwise adding a second
// verb to an event would silently remove the bound on the first.
register([
action('test.spawn', { risk: 'change', cost: () => ({ 'x.creatures': 1 }) }),
action('test.horde', { risk: 'change', cost: () => ({ 'x.creatures': 1 }) }),
action('test.spawn', { risk: 'change', cost: () => ({ 'test.creatures': 1 }) }),
action('test.horde', { risk: 'change', cost: () => ({ 'test.creatures': 1 }) }),
])
setSetting('test.spawn', true, { 'x.creatures': 30 })
setSetting('test.spawn', true, { 'test.creatures': 30 })
setSetting('test.horde', true, {})
const caps = authorize.effectiveCaps(
[{ actionId: 'test.horde', params: {} }, { actionId: 'test.spawn', params: {} }],
await settingsDb.byIds(['test.spawn', 'test.horde']),
)
assert.deepEqual(caps, { 'x.creatures': { cap: 30, from: 'test.spawn' } })
assert.deepEqual(caps, { 'test.creatures': { cap: 30, from: 'test.spawn' } })
})
test('a dimension nobody caps is still a row, uncapped', async () => {
// Seeded so the meter counts it. The distinction it preserves is that a MISSING
// row means something else entirely: a step spending a dimension its own run's
// version never priced.
register([action('test.spawn', { risk: 'change', cost: () => ({ 'x.creatures': 1 }) })])
register([action('test.spawn', { risk: 'change', cost: () => ({ 'test.creatures': 1 }) })])
setSetting('test.spawn', true, {})
const caps = authorize.effectiveCaps([{ actionId: 'test.spawn', params: {} }], await settingsDb.byIds(['test.spawn']))
assert.deepEqual(caps, { 'x.creatures': { cap: null, from: null } })
assert.deepEqual(caps, { 'test.creatures': { cap: null, from: null } })
})
test('a step naming an unregistered action contributes no dimension', async () => {
@@ -325,13 +359,13 @@ test('with no run, the question is whether the cost could EVER fit', async () =>
// The dry run's and the editor's question. A step asking for 40 under a cap of
// 30 is an authoring error answerable before anything is scheduled, which is
// the entire value of catching it here rather than at 2am.
register([action('test.spawn', { risk: 'change', cost: (p) => ({ 'x.creatures': p.count }) })])
setSetting('test.spawn', true, { 'x.creatures': 30 })
register([action('test.spawn', { risk: 'change', cost: (p) => ({ 'test.creatures': p.count }) })])
setSetting('test.spawn', true, { 'test.creatures': 30 })
const bad = await authorize.mayInvoke({ action: registries.eventAction('test.spawn'), params: { count: 40 } })
assert.equal(bad.ok, false)
assert.equal(bad.code, 'cap')
assert.equal(bad.dimension, 'x.creatures')
assert.equal(bad.dimension, 'test.creatures')
assert.equal(bad.requested, 40)
assert.equal(bad.cap, 30)
@@ -340,9 +374,9 @@ test('with no run, the question is whether the cost could EVER fit', async () =>
})
test('with a run and spend, the check IS the spend', async () => {
register([action('test.spawn', { risk: 'change', cost: (p) => ({ 'x.creatures': p.count }) })])
setSetting('test.spawn', true, { 'x.creatures': 30 })
setBudget(RUN.id, 'x.creatures', { consumed: 0, cap: 30 })
register([action('test.spawn', { risk: 'change', cost: (p) => ({ 'test.creatures': p.count }) })])
setSetting('test.spawn', true, { 'test.creatures': 30 })
setBudget(RUN.id, 'test.creatures', { consumed: 0, cap: 30 })
const v = await authorize.mayInvoke({
action: registries.eventAction('test.spawn'),
@@ -352,24 +386,24 @@ test('with a run and spend, the check IS the spend', async () => {
})
assert.equal(v.ok, true)
assert.equal(v.spent, true)
assert.equal(store.budget.get('1:x.creatures').consumed, 12)
assert.equal(store.budget.get('1:test.creatures').consumed, 12)
})
test('without spend the cap check is advisory and moves nothing', async () => {
register([action('test.spawn', { risk: 'change', cost: () => ({ 'x.creatures': 5 }) })])
setSetting('test.spawn', true, { 'x.creatures': 30 })
setBudget(RUN.id, 'x.creatures', { consumed: 0, cap: 30 })
register([action('test.spawn', { risk: 'change', cost: () => ({ 'test.creatures': 5 }) })])
setSetting('test.spawn', true, { 'test.creatures': 30 })
setBudget(RUN.id, 'test.creatures', { consumed: 0, cap: 30 })
const v = await authorize.mayInvoke({ action: registries.eventAction('test.spawn'), run: RUN })
assert.equal(v.ok, true)
assert.equal(v.spent, undefined)
assert.equal(store.budget.get('1:x.creatures').consumed, 0)
assert.equal(store.budget.get('1:test.creatures').consumed, 0)
})
test('a refusal names the numbers an operator needs, not just "refused"', async () => {
register([action('test.spawn', { risk: 'change', cost: () => ({ 'x.creatures': 5 }) })])
setSetting('test.spawn', true, { 'x.creatures': 30 })
setBudget(RUN.id, 'x.creatures', { consumed: 28, cap: 30 })
register([action('test.spawn', { risk: 'change', cost: () => ({ 'test.creatures': 5 }) })])
setSetting('test.spawn', true, { 'test.creatures': 30 })
setBudget(RUN.id, 'test.creatures', { consumed: 28, cap: 30 })
const v = await authorize.mayInvoke({ action: registries.eventAction('test.spawn'), run: RUN, spend: true })
assert.equal(v.ok, false)
@@ -386,24 +420,24 @@ test('a partial spend across dimensions is given back when a later one is refuse
// costing creatures AND bosses can take the creatures and be refused the
// bosses, and a step that did not run must not have spent anything.
register([
action('test.spawn', { risk: 'change', cost: () => ({ 'x.creatures': 5, 'x.bosses': 2 }) }),
action('test.spawn', { risk: 'change', cost: () => ({ 'test.creatures': 5, 'test.bosses': 2 }) }),
])
setSetting('test.spawn', true)
setBudget(RUN.id, 'x.creatures', { consumed: 0, cap: 30 })
setBudget(RUN.id, 'x.bosses', { consumed: 1, cap: 2 })
setBudget(RUN.id, 'test.creatures', { consumed: 0, cap: 30 })
setBudget(RUN.id, 'test.bosses', { consumed: 1, cap: 2 })
const v = await authorize.mayInvoke({ action: registries.eventAction('test.spawn'), run: RUN, spend: true })
assert.equal(v.ok, false)
assert.equal(v.dimension, 'x.bosses')
assert.equal(store.budget.get('1:x.creatures').consumed, 0, 'the creatures must have been given back')
assert.equal(store.budget.get('1:x.bosses').consumed, 1)
assert.equal(v.dimension, 'test.bosses')
assert.equal(store.budget.get('1:test.creatures').consumed, 0, 'the creatures must have been given back')
assert.equal(store.budget.get('1:test.bosses').consumed, 1)
})
test('spending a dimension the run has no row for is refused, and says so in its own words', async () => {
// Fail-closed, and a distinct code: "this run has no budget for that" is a
// different diagnosis from "the cap is spent", and an operator who raises the
// cap in answer to the wrong one has not fixed anything.
register([action('test.spawn', { risk: 'change', cost: () => ({ 'x.ghosts': 1 }) })])
register([action('test.spawn', { risk: 'change', cost: () => ({ 'test.ghosts': 1 }) })])
setSetting('test.spawn', true)
const v = await authorize.mayInvoke({ action: registries.eventAction('test.spawn'), run: RUN, spend: true })
assert.equal(v.ok, false)
@@ -411,13 +445,36 @@ test('spending a dimension the run has no row for is refused, and says so in its
assert.match(v.reason, /no budget for/)
})
test('an uncapped budget row never refuses', async () => {
register([action('test.spawn', { risk: 'change', cost: () => ({ 'x.creatures': 999 }) })])
test('a dimension no module declares is refused before any cap arithmetic', async () => {
// §F, fail closed (org lead, 2026-09-03). The refusal has to be its OWN code:
// told "the cap is spent", an operator goes and raises a cap, and nothing
// changes, because the problem is a module whose declaration is incomplete.
register([action('test.spawn', { risk: 'notify', cost: () => ({ 'test.wisps': 3 }) })], {
budgets: false,
})
setSetting('test.spawn', true)
setBudget(RUN.id, 'x.creatures', { consumed: 5, cap: null })
setBudget(1, 'test.wisps', { cap: 100 })
const verdict = await authorize.mayInvoke({
action: registries.eventAction('test.spawn'),
params: {},
run: RUN,
spend: true,
})
assert.equal(verdict.ok, false)
assert.equal(verdict.code, 'undeclared')
assert.match(verdict.reason, /no module declares/)
// And it did not spend: a step refused for this reason never ran.
assert.equal(store.budget.get('1:test.wisps').consumed, 0)
})
test('an uncapped budget row never refuses', async () => {
register([action('test.spawn', { risk: 'change', cost: () => ({ 'test.creatures': 999 }) })])
setSetting('test.spawn', true)
setBudget(RUN.id, 'test.creatures', { consumed: 5, cap: null })
const v = await authorize.mayInvoke({ action: registries.eventAction('test.spawn'), run: RUN, spend: true })
assert.equal(v.ok, true)
assert.equal(store.budget.get('1:x.creatures').consumed, 1004)
assert.equal(store.budget.get('1:test.creatures').consumed, 1004)
})
// ── The layers are ordered ─────────────────────────────────────────────────
@@ -426,7 +483,7 @@ test('the layers answer in order: role before enablement before cap', async () =
// Not cosmetic. An editor told "that action is disabled" would go and ask an
// admin to enable it, and still not be allowed to author the step — the honest
// first answer is the role.
register([action('test.spawn', { risk: 'change', cost: () => ({ 'x.creatures': 99 }) })])
register([action('test.spawn', { risk: 'change', cost: () => ({ 'test.creatures': 99 }) })])
// Disabled AND over cap AND world-changing, all at once.
const v = await authorize.mayInvoke({
user: EDITOR,
@@ -435,7 +492,7 @@ test('the layers answer in order: role before enablement before cap', async () =
})
assert.equal(v.code, 'role')
setSetting('test.spawn', false, { 'x.creatures': 1 })
setSetting('test.spawn', false, { 'test.creatures': 1 })
const asAdmin = await authorize.mayInvoke({
user: ADMIN,
action: registries.eventAction('test.spawn'),

View File

@@ -0,0 +1,515 @@
// ── The module contract, proved with a module (EVENTS_PLAN.md Phase 7) ─────
//
// §F is the seam this phase opens: `registerEventActions`, `registerEventBudgets`,
// `registerEventLeases` and `registerEventOptionSources`, at MODULE_API 1.10.0.
// Every one of them was reachable only by `registerCore()` before this phase, and
// the plan is explicit about how to prove they are reachable now:
//
// > **Prove it with a throwaway module, not with module-uo.** A contract
// > validated only against the module it was carved out of has not been
// > validated, and P9 should be the *second* consumer of this seam.
//
// So every test below writes a real module to a real directory, points
// MODULES_DIR at it and lets the real loader scan, validate, `register()` and
// commit it. Nothing here stubs the loader or calls `registries.stage()` by hand:
// a test that staged directly would pass just as happily against the code before
// this phase, when `buildApi` forwarded none of these four names.
//
// **The half this file cares most about is the failure half.** §F's load-bearing
// envelope rule is that *no shape a failure can take may read as success* — a
// rejected promise, a throw, a timeout, a non-object and a missing `ok` are all
// `{ ok: false, retry: true }`, which is `registerTeamProvider`'s default
// inverted, because the expensive mistake here is recording a world change that
// did not happen. Those five shapes are dispatched from a module below, not
// constructed as literals, so what is under test is the path a module actually
// takes.
//
// The pool points at a closed port before anything is required: the loader's
// `buildCtx` pulls in the models, which build a mariadb pool at require time. No
// query is ever run.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const fs = require('fs')
const os = require('os')
const path = require('path')
const { test, beforeEach, after } = require('node:test')
const assert = require('node:assert/strict')
const express = require('express')
const db = require('../src/utils/db')
const registries = require('../src/modules/registries')
const dispatch = require('../src/events/dispatch')
const authorize = require('../src/events/authorize')
const { MODULE_API_VERSION } = require('../src/modules/version')
after(() => db.close())
let tmpRoot
const emptyTiers = () => ({
public: express.Router(),
admin: express.Router(),
player: express.Router(),
})
/**
* Write a module and let the real loader scan it.
*
* MODULES_DIR is read into a const at require time — it has to be, the scan is
* synchronous and happens while app.js is being required — so busting the cache
* is the only honest way to point the loader somewhere else.
*/
function loadModule(id, source, manifest = {}) {
const dir = path.join(tmpRoot, id)
fs.mkdirSync(dir, { recursive: true })
fs.writeFileSync(
path.join(dir, 'module.json'),
JSON.stringify({ id, name: id, version: '1.0.0', coreApi: '^1.10.0', server: 'index.js', ...manifest }),
)
fs.writeFileSync(path.join(dir, 'index.js'), source)
process.env.MODULES_DIR = tmpRoot
registries._reset()
delete require.cache[require.resolve('../src/modules/loader')]
// eslint-disable-next-line global-require
const loader = require('../src/modules/loader')
loader.load(emptyTiers())
return loader.list().find((m) => m.id === id)
}
/** The state a module that loaded cleanly is in, with its reason if it did not. */
const assertRegistered = (record) => {
assert.equal(record.state, 'registered', record.reason || 'expected the module to register')
}
const RUN = { id: 1, scope: '' }
const step = (actionId, params = {}) => ({
id: 1,
action_id: actionId,
params,
idempotency_key: 'k-1',
})
beforeEach(() => {
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-events-module-'))
})
// ── The seam is open ───────────────────────────────────────────────────────
test('the version a module declares against is 1.10.0', () => {
// Not decoration. `coreApi: "^1.10.0"` on every module below is what makes
// these tests fail loudly rather than quietly if the bump is ever reverted —
// the loader would refuse the manifest and every assertion would become "the
// module did not register", which is the same failure the seam closing would
// produce. Asserting the number here says which of the two it was.
assert.equal(MODULE_API_VERSION, '1.10.0')
})
test('a module registers actions, budgets, leases and option sources', () => {
const record = loadModule('demo', `module.exports = (ctx, api) => {
api.registerEventBudgets([
{ id: 'demo.wisps', label: 'Wisps summoned', unit: 'count' },
])
api.registerEventActions([{
id: 'demo.wisp.summon',
label: 'Summon wisps',
risk: 'change',
reversible: 'ledger',
cost: (p) => ({ 'demo.wisps': p.count }),
params: [{ name: 'count', type: 'int', required: true, example: 3 }],
async perform() { return { ok: true, resources: [{ kind: 'wisp', ref: '0x1' }] } },
async revert() { return { ok: true } },
}])
api.registerEventLeases([{
id: 'demo.rate.gain',
label: 'Gain rate',
type: 'float',
min: 0.5,
max: 5,
maxDurationMs: 3600000,
async read() { return { ok: true, value: 1 } },
async apply() { return { ok: true } },
async restore() { return { ok: true } },
}])
api.registerEventOptionSources([{
id: 'demo.options.hues',
label: 'Hues',
async resolve() { return [{ value: '1157', label: 'Blood', group: 'Reds' }] },
}])
}`)
assertRegistered(record)
assert.equal(registries.eventAction('demo.wisp.summon').owner, 'demo')
assert.deepEqual(registries.eventBudget('demo.wisps'), {
owner: 'demo', id: 'demo.wisps', label: 'Wisps summoned', unit: 'count', description: '',
})
assert.equal(registries.eventLease('demo.rate.gain').maxDurationMs, 3600000)
assert.deepEqual(registries.allEventOptionSources().map((s) => s.id), ['demo.options.hues'])
})
test('the catalog never carries a callable, whichever registration it came from', async () => {
// The rule every registry in this file already keeps, restated for four new
// shapes at once: these objects LEAVE THE PROCESS, and the browser's whole
// relationship with any of them is naming one by id. A `perform` or a
// `resolve` riding out would make §F's "a module registers actions server-side
// and adds no routes for them" false in the one direction nobody would notice.
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
api.registerEventActions([{
id: 'demo.a', label: 'A', risk: 'notify', reversible: 'none',
async perform() { return { ok: true } },
}])
api.registerEventLeases([{
id: 'demo.l', label: 'L', type: 'bool', maxDurationMs: 1000,
async read() { return { ok: true } },
async apply() { return { ok: true } },
async restore() { return { ok: true } },
}])
api.registerEventOptionSources([
{ id: 'demo.o', label: 'O', async resolve() { return [] } },
])
}`))
for (const a of registries.allEventActions()) {
assert.equal(a.perform, undefined)
assert.equal(a.revert, undefined)
assert.equal(a.cost, undefined)
}
for (const l of registries.allEventLeases()) {
assert.equal(l.read, undefined)
assert.equal(l.apply, undefined)
assert.equal(l.restore, undefined)
}
for (const s of registries.allEventOptionSources()) assert.equal(s.resolve, undefined)
})
test('a second call is a module changing its mind, and it fails the module alone', () => {
// `once`, on all four, for the reason every batch registration takes it: a
// batch is a module's complete statement about what it declares. And the
// failure is the loader's §4.4 guarantee — recorded against the module, the
// site still up, nothing committed.
const record = loadModule('demo', `module.exports = (ctx, api) => {
api.registerEventBudgets([{ id: 'demo.a', label: 'A', unit: 'count' }])
api.registerEventBudgets([{ id: 'demo.b', label: 'B', unit: 'count' }])
}`)
assert.equal(record.state, 'startup_failed')
assert.match(record.reason, /registerEventBudgets\(\) called twice/)
// Validate-then-commit, per registrant: the FIRST batch is gone too.
assert.equal(registries.eventBudget('demo.a'), null)
})
test('a module may not name a budget outside its own prefix', () => {
const record = loadModule('demo', `module.exports = (ctx, api) => {
api.registerEventBudgets([{ id: 'uo.creatures', label: 'Creatures', unit: 'count' }])
}`)
assert.equal(record.state, 'startup_failed')
assert.match(record.reason, /not namespaced "demo\."/)
})
test('a module that registers nothing at all is a module, not a failure', () => {
// §F states it once because it governs every member: everything a module
// registers is optional, and core with none of it is still an event engine
// that can announce, wait, cue a human and publish results.
const record = loadModule('quiet', 'module.exports = () => {}')
assertRegistered(record)
assert.deepEqual(registries.allEventBudgets(), [])
assert.deepEqual(registries.allEventActions().map((a) => a.id), [])
})
// ── The envelope: no failure shape reads as success ────────────────────────
test('every shape a module failure can take is dispatched as a retry, not a success', async () => {
// The five §F names, answered by a real module through the real dispatcher.
// Written as one module with five verbs rather than five modules, because what
// is under test is the CLASSIFIER and a module per shape would be four extra
// loader scans saying nothing.
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
const verb = (id, perform) => ({
id, label: id, risk: 'notify', reversible: 'none', budgetMs: 200, perform,
})
api.registerEventActions([
verb('demo.rejects', async () => { return Promise.reject(new Error('the socket went away')) }),
verb('demo.throws', async () => { throw new Error('a typo in the module') }),
verb('demo.hangs', () => new Promise(() => {})),
verb('demo.lies', async () => 'fine'),
verb('demo.forgets', async () => ({ resources: [] })),
])
}`))
for (const id of ['demo.rejects', 'demo.throws', 'demo.hangs', 'demo.lies', 'demo.forgets']) {
const result = await dispatch.dispatchStep(step(id), { run: RUN })
assert.equal(result.outcome, 'retry', `${id} must not read as success`)
assert.ok(result.error, `${id} must say what went wrong`)
}
})
test('a module that means "never" says so, and only then is it terminal', async () => {
// The inverse of the rule above, and the reason `retry` is opted OUT of rather
// than into: an envelope that forgot to say anything gets the benefit of the
// doubt on the transient question, so only a module that states `retry: false`
// gets a step marked as never going to work.
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
api.registerEventActions([
{ id: 'demo.never', label: 'Never', risk: 'notify', reversible: 'none',
async perform() { return { ok: false, retry: false, error: 'there is no such gate' } } },
{ id: 'demo.later', label: 'Later', risk: 'notify', reversible: 'none',
async perform() { return { ok: false, error: 'the shard is restarting' } } },
])
}`))
const never = await dispatch.dispatchStep(step('demo.never'), { run: RUN })
assert.equal(never.outcome, 'terminal')
assert.equal(never.error, 'there is no such gate')
const later = await dispatch.dispatchStep(step('demo.later'), { run: RUN })
assert.equal(later.outcome, 'retry')
})
test('budgetMs is the contract’s deadline, and a wedged module does not hold the tick', async () => {
// Declared by the module, bounded by the registry, enforced by the dispatcher.
// Without it a `perform()` awaiting a socket that never answers holds the
// step's claim until the lease expires and the reclaim re-dispatches it, which
// is how one wedged sidecar becomes an infinite loop rather than a failed step.
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
api.registerEventActions([{
id: 'demo.hangs', label: 'Hangs', risk: 'notify', reversible: 'none',
budgetMs: 120,
perform: () => new Promise(() => {}),
}])
}`))
const started = Date.now()
const result = await dispatch.dispatchStep(step('demo.hangs'), { run: RUN })
assert.equal(result.outcome, 'retry')
assert.match(result.error, /exceeded its 120ms budget/)
assert.ok(Date.now() - started < 2000, 'the runner stopped waiting long before any lease would expire')
})
test('the two success shapes that mean "not finished" reach a module through the same door', async () => {
// §F, and the org lead's 2026-09-02 decision: both are ordinary envelope
// members rather than special cases keyed on an action id, so the runner never
// names a verb — and a module's own long-running action gets `await: 'human'`
// and `holdFor` for free, exactly as `core.cue` and `core.wait` do.
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
api.registerEventActions([
{ id: 'demo.parks', label: 'Parks', risk: 'notify', reversible: 'none',
async perform() { return { ok: true, await: 'human' } } },
{ id: 'demo.holds', label: 'Holds', risk: 'notify', reversible: 'none',
async perform() { return { ok: true, holdFor: 300 } } },
])
}`))
const parked = await dispatch.dispatchStep(step('demo.parks'), { run: RUN })
assert.equal(parked.outcome, 'parked')
const held = await dispatch.dispatchStep(step('demo.holds'), { run: RUN })
assert.equal(held.outcome, 'done')
assert.equal(held.holdSeconds, 300)
})
// ── verify: true is a parameter a module must honour ───────────────────────
test('verify rides through to the module unchanged, and a dry run changes nothing', async () => {
// §F: *"`verify: true` must change nothing and must answer honestly"*. Core
// cannot enforce the first half — only the module knows what its own writes
// are — so what IS testable is that the flag arrives, and that it arrives down
// the same dispatcher the real run uses. A dry run down a second code path is
// a dry run of the second path.
const written = []
global.__rgDemoWrites = written
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
api.registerEventActions([{
id: 'demo.writes', label: 'Writes', risk: 'change', reversible: 'none',
async perform({ verify, params }) {
if (verify) return { ok: true, wouldWrite: params.what }
global.__rgDemoWrites.push(params.what)
return { ok: true }
},
}])
}`))
const dry = await dispatch.dispatchStep(step('demo.writes', { what: 'a gate' }), {
run: RUN,
verify: true,
})
assert.equal(dry.outcome, 'done')
assert.deepEqual(written, [], 'a dry run wrote something')
await dispatch.dispatchStep(step('demo.writes', { what: 'a gate' }), { run: RUN })
assert.deepEqual(written, ['a gate'])
delete global.__rgDemoWrites
})
test('the whole envelope reaches the module, idempotency key included', async () => {
// The key is the module's half of a retry that is safe on the game side, and a
// module cannot pass it down its own wire if core does not hand it over. It is
// asserted here rather than in the runner's tests because THIS is the surface
// a module author reads.
global.__rgDemoEnvelope = null
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
api.registerEventActions([{
id: 'demo.echo', label: 'Echo', risk: 'notify', reversible: 'none',
async perform(envelope) { global.__rgDemoEnvelope = envelope; return { ok: true } },
}])
}`))
await dispatch.dispatchStep(step('demo.echo', { a: 1 }), { run: { id: 7, scope: 'atlantic' } })
const envelope = global.__rgDemoEnvelope
assert.deepEqual(envelope, {
runId: 7,
stepId: 1,
idempotencyKey: 'k-1',
scope: 'atlantic',
params: { a: 1 },
actor: null,
verify: false,
})
delete global.__rgDemoEnvelope
})
// ── Budgets: a module cannot spend what it did not declare ─────────────────
test('a cost naming a dimension the module never declared is refused', async () => {
// §F, fail closed (org lead, 2026-09-03). The module here is not malicious and
// not exotic — it is one that declared two budgets and priced a third, which is
// what a rename looks like. The refusal has its own code because the fix is a
// module's declaration, not a deployment's cap.
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
api.registerEventBudgets([{ id: 'demo.wisps', label: 'Wisps', unit: 'count' }])
api.registerEventActions([{
id: 'demo.summon', label: 'Summon', risk: 'notify', reversible: 'none',
cost: () => ({ 'demo.wraiths': 2 }),
async perform() { return { ok: true } },
}])
}`))
// `settings: null` because this file has no database: passing it explicitly is
// what tells `mayInvoke` not to go and read the switchboard row, and the
// enablement layer answers before the cap layer either way.
const verdict = await authorize.mayInvoke({
action: registries.eventAction('demo.summon'),
params: {},
settings: null,
})
assert.equal(verdict.ok, false)
assert.equal(verdict.code, 'undeclared')
assert.match(verdict.reason, /demo\.wraiths/)
})
test('a declared dimension is priced, named and unbounded until an operator says otherwise', async () => {
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
api.registerEventBudgets([{ id: 'demo.wisps', label: 'Wisps summoned', unit: 'count' }])
api.registerEventActions([{
id: 'demo.summon', label: 'Summon', risk: 'notify', reversible: 'none',
cost: (p) => ({ 'demo.wisps': p.count }),
params: [{ name: 'count', type: 'int', required: true, example: 4 }],
async perform() { return { ok: true } },
}])
}`))
// What the switchboard renders: the id the action prices, dressed with what the
// module called it. Discovered by pricing the declared example, which is why
// §F makes `example` required on every param.
assert.deepEqual(authorize.budgetsOf(registries.eventAction('demo.summon')), [
{ id: 'demo.wisps', label: 'Wisps summoned', unit: 'count', registered: true },
])
// And with no settings row and no run, the answer is yes: a declared budget is
// a dimension that can be counted, not a bound that has been set.
const verdict = await authorize.mayInvoke({
action: registries.eventAction('demo.summon'),
params: { count: 4 },
settings: null,
})
assert.deepEqual(verdict, { ok: true, cost: { 'demo.wisps': 4 } })
})
// ── Option sources ─────────────────────────────────────────────────────────
test('a module answers its own option source, and core normalises what comes back', async () => {
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
api.registerEventOptionSources([{
id: 'demo.options.hues', label: 'Hues',
async resolve() {
return [
{ value: 1157, label: 'Blood', group: 'Reds' },
{ value: '2213', label: 'Ice' },
{ value: '', label: 'a blank nobody can pick' },
'not an option at all',
]
},
}])
}`))
const answer = await registries.resolveOptionSource('demo.options.hues')
assert.equal(answer.ok, true)
assert.equal(answer.owner, 'demo')
// Coerced to strings, because this array is rendered into a `<select>` and
// submitted back as text; and the two entries that could not become an option
// are dropped rather than becoming one that submits "undefined".
assert.deepEqual(answer.options, [
{ value: '1157', label: 'Blood', group: 'Reds' },
{ value: '2213', label: 'Ice' },
])
})
test('every way an option source can fail degrades the field rather than breaking it', async () => {
// §F: a refusal degrades the field to free text with a visible warning. So none
// of these throws, and each says something an operator can act on — an
// authoring form a shard outage can make unusable is a worse failure than the
// typo the dropdown exists to prevent.
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
api.registerEventOptionSources([
{ id: 'demo.options.throws', label: 'Throws', async resolve() { throw new Error('sidecar down') } },
{ id: 'demo.options.lies', label: 'Lies', async resolve() { return { nope: true } } },
{ id: 'demo.options.empty', label: 'Empty', async resolve() { return [] } },
])
}`))
const threw = await registries.resolveOptionSource('demo.options.throws')
assert.equal(threw.ok, false)
assert.match(threw.reason, /could not be read/)
const lied = await registries.resolveOptionSource('demo.options.lies')
assert.equal(lied.ok, false)
assert.match(lied.reason, /no option list/)
// An EMPTY list is not a failure. A source that legitimately has nothing to
// offer today — no landmarks configured yet — must not be reported as broken,
// because the two have different fixes.
const empty = await registries.resolveOptionSource('demo.options.empty')
assert.equal(empty.ok, true)
assert.deepEqual(empty.options, [])
const missing = await registries.resolveOptionSource('demo.options.gone')
assert.equal(missing.ok, false)
assert.match(missing.reason, /no module registers/)
})
// ── Uninstall is dormancy, never an error ──────────────────────────────────
test('an action whose module is gone goes dormant, and a step naming it fails terminal with the module named', async () => {
// §F and §L, verbatim: *"a step naming one fails terminal with the module named
// and the run degrades — never a silent skip"*. The scenario is real and
// ordinary: a module was uninstalled between the publish that pinned the
// version and the run that dispatches it.
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
api.registerEventActions([{
id: 'demo.summon', label: 'Summon', risk: 'notify', reversible: 'none',
async perform() { return { ok: true } },
}])
}`))
assert.equal(registries.isEventAction('demo.summon'), true)
// The uninstall: a fresh scan of a directory the module is no longer in.
fs.rmSync(path.join(tmpRoot, 'demo'), { recursive: true, force: true })
loadModule('other', 'module.exports = () => {}')
assert.equal(registries.isEventAction('demo.summon'), false)
const result = await dispatch.dispatchStep(step('demo.summon'), { run: RUN })
assert.equal(result.outcome, 'terminal')
assert.equal(result.dormant, true)
assert.match(result.error, /no module registers "demo\.summon"/)
})

View File

@@ -483,9 +483,43 @@ afterEach(() => {
})
/** Register actions the way a module does, through the real staging area. */
/**
* Declare the budget dimensions a batch of actions prices.
*
* §F, fail closed (Phase 7): a `cost()` naming a dimension no module registered
* is refused BEFORE any cap arithmetic runs. Without this, every cap test below
* would pass for the WRONG reason — refused by the layer above the one under
* test. Discovered the way the switchboard discovers them, by pricing the
* action's own declared examples, so a test never has to keep a second list of
* its dimensions in step with its `cost()`.
*
* Written out here rather than borrowed from `authorize.dimensionsOf` so this
* file keeps stubbing exactly what it means to stub. The undeclared case is not
* an omission; it has its own test.
*/
const declaredBudgets = (entries, owner) => {
const dimensions = new Set()
for (const e of entries) {
if (typeof e.cost !== 'function') continue
const params = {}
for (const p of e.params || []) if (p.example !== undefined) params[p.name] = p.example
let priced
try {
priced = e.cost(params)
} catch {
continue
}
for (const d of Object.keys(priced || {})) dimensions.add(d)
}
return [...dimensions]
.filter((id) => id.startsWith(`${owner}.`))
.map((id) => ({ id, label: id, unit: 'count' }))
}
const register = (entries, owner = 'test') => {
const api = registries.stage(owner)
api.registerEventActions(entries)
api.registerEventBudgets(declaredBudgets(entries, owner))
registries.apply(api.staged)
}
@@ -1161,22 +1195,22 @@ test('a step over its cap is refused with the dimension and the numbers on the l
// "You asked for 40 and this deployment allows 30" is an authoring error, and
// it has to arrive as those words rather than as a stack trace.
register([
scriptedAction('test.spawn', { risk: 'change', cost: (p) => ({ 'x.creatures': p.count }) }),
scriptedAction('test.spawn', { risk: 'change', cost: (p) => ({ 'test.creatures': p.count }) }),
])
setSwitch('test.spawn', true, { 'x.creatures': 30 })
setSwitch('test.spawn', true, { 'test.creatures': 30 })
const id = seedRun([{ key: 'main', steps: [step('test.spawn', { count: 12 }, 'skip')] }])
seedBudget(id, 'x.creatures', { consumed: 28, cap: 30 })
seedBudget(id, 'test.creatures', { consumed: 28, cap: 30 })
await runner.tick(T0)
assert.equal(stepsOf(id)[0].status, 'refused')
const line = store.log.find((l) => l.runId === id && l.kind === 'step.refused')
assert.equal(line.detail.code, 'cap')
assert.equal(line.detail.dimension, 'x.creatures')
assert.equal(line.detail.dimension, 'test.creatures')
assert.equal(line.detail.requested, 12)
assert.equal(line.detail.cap, 30)
assert.equal(line.detail.consumed, 28)
assert.equal(budgetOf(id, 'x.creatures').consumed, 28, 'a refused step spends nothing')
assert.equal(budgetOf(id, 'test.creatures').consumed, 28, 'a refused step spends nothing')
})
test('two steps drawing on one cap spend it once each, and the second is refused when it will not fit', async () => {
@@ -1184,8 +1218,8 @@ test('two steps drawing on one cap spend it once each, and the second is refused
// spends arriving genuinely at once — is in `eventRunnerSql.test.js`, because
// the guard lives in a WHERE and a stub reproduces the reading rather than the
// server.
register([scriptedAction('test.spawn', { risk: 'change', cost: (p) => ({ 'x.creatures': p.count }) })])
setSwitch('test.spawn', true, { 'x.creatures': 30 })
register([scriptedAction('test.spawn', { risk: 'change', cost: (p) => ({ 'test.creatures': p.count }) })])
setSwitch('test.spawn', true, { 'test.creatures': 30 })
const id = seedRun([
{
key: 'main',
@@ -1196,7 +1230,7 @@ test('two steps drawing on one cap spend it once each, and the second is refused
],
},
])
seedBudget(id, 'x.creatures', { consumed: 0, cap: 30 })
seedBudget(id, 'test.creatures', { consumed: 0, cap: 30 })
await runner.tick(T0)
@@ -1204,7 +1238,7 @@ test('two steps drawing on one cap spend it once each, and the second is refused
assert.equal(s[0].status, 'done')
assert.equal(s[1].status, 'refused', 'the second 20 does not fit under 30')
assert.equal(s[2].status, 'done', 'and a later step that DOES fit still runs')
assert.equal(budgetOf(id, 'x.creatures').consumed, 30)
assert.equal(budgetOf(id, 'test.creatures').consumed, 30)
})
test('a retry does not pay the cap twice', async () => {
@@ -1212,36 +1246,36 @@ test('a retry does not pay the cap twice', async () => {
// idempotent operation against the same key, and charging a cap for a flaky
// socket would exhaust a deployment's allowance through unreliability rather
// than through effect.
register([scriptedAction('test.spawn', { risk: 'change', cost: () => ({ 'x.creatures': 5 }) })])
setSwitch('test.spawn', true, { 'x.creatures': 30 })
register([scriptedAction('test.spawn', { risk: 'change', cost: () => ({ 'test.creatures': 5 }) })])
setSwitch('test.spawn', true, { 'test.creatures': 30 })
scripted['test.spawn'] = { calls: [], answers: [{ ok: false, retry: true, error: 'shard busy' }] }
const id = seedRun([{ key: 'main', steps: [step('test.spawn', {}, 'skip')] }])
seedBudget(id, 'x.creatures', { consumed: 0, cap: 30 })
seedBudget(id, 'test.creatures', { consumed: 0, cap: 30 })
await runner.tick(T0)
assert.equal(stepsOf(id)[0].status, 'pending')
assert.equal(budgetOf(id, 'x.creatures').consumed, 5, 'the first attempt spends')
assert.equal(budgetOf(id, 'test.creatures').consumed, 5, 'the first attempt spends')
// Past the retry backoff: the second attempt succeeds and must not spend again.
await runner.tick(later(61_000))
assert.equal(stepsOf(id)[0].status, 'done')
assert.equal(budgetOf(id, 'x.creatures').consumed, 5, 'the retry must not pay twice')
assert.equal(budgetOf(id, 'test.creatures').consumed, 5, 'the retry must not pay twice')
})
test('a step that spent and then failed for good keeps its spend', async () => {
// The corollary, and it is deliberate: the attempt may have half-run, and a
// refund would be core asserting that it did not.
register([scriptedAction('test.spawn', { risk: 'change', cost: () => ({ 'x.creatures': 5 }) })])
setSwitch('test.spawn', true, { 'x.creatures': 30 })
register([scriptedAction('test.spawn', { risk: 'change', cost: () => ({ 'test.creatures': 5 }) })])
setSwitch('test.spawn', true, { 'test.creatures': 30 })
scripted['test.spawn'] = { calls: [], answer: { ok: false, retry: false, error: 'no' } }
const id = seedRun([{ key: 'main', steps: [step('test.spawn', {}, 'skip')] }])
seedBudget(id, 'x.creatures', { consumed: 0, cap: 30 })
seedBudget(id, 'test.creatures', { consumed: 0, cap: 30 })
await runner.tick(T0)
assert.equal(stepsOf(id)[0].status, 'failed')
assert.equal(budgetOf(id, 'x.creatures').consumed, 5)
assert.equal(budgetOf(id, 'test.creatures').consumed, 5)
})
test('a step spending a dimension its run has no budget row for is refused', async () => {
@@ -1249,7 +1283,7 @@ test('a step spending a dimension its run has no budget row for is refused', asy
// dimension seeded — uncapped ones included, as a row with a NULL cap — so a
// missing row means the step is spending something its own version never
// declared.
register([scriptedAction('test.spawn', { risk: 'change', cost: () => ({ 'x.ghosts': 1 }) })])
register([scriptedAction('test.spawn', { risk: 'change', cost: () => ({ 'test.ghosts': 1 }) })])
setSwitch('test.spawn', true)
const id = seedRun([{ key: 'main', steps: [step('test.spawn', {}, 'skip')] }])
@@ -1261,15 +1295,15 @@ test('a step spending a dimension its run has no budget row for is refused', asy
})
test('an uncapped dimension counts without ever refusing', async () => {
register([scriptedAction('test.spawn', { risk: 'change', cost: () => ({ 'x.creatures': 99 }) })])
register([scriptedAction('test.spawn', { risk: 'change', cost: () => ({ 'test.creatures': 99 }) })])
setSwitch('test.spawn', true)
const id = seedRun([{ key: 'main', steps: [step('test.spawn'), step('test.spawn')] }])
seedBudget(id, 'x.creatures', { consumed: 0, cap: null })
seedBudget(id, 'test.creatures', { consumed: 0, cap: null })
await runner.tick(T0)
assert.deepEqual(stepsOf(id).map((s) => s.status), ['done', 'done'])
assert.equal(budgetOf(id, 'x.creatures').consumed, 198)
assert.equal(budgetOf(id, 'test.creatures').consumed, 198)
})
test('the runner never re-checks the role of whoever started the run', async () => {

View File

@@ -48,9 +48,43 @@ afterEach(() => {
registries._reset()
})
/**
* Declare the budget dimensions a batch of actions prices.
*
* §F, fail closed (Phase 7): a `cost()` naming a dimension no module registered
* is refused BEFORE any cap arithmetic runs. Without this, every cap test below
* would pass for the WRONG reason — refused by the layer above the one under
* test. Discovered the way the switchboard discovers them, by pricing the
* action's own declared examples, so a test never has to keep a second list of
* its dimensions in step with its `cost()`.
*
* Written out here rather than borrowed from `authorize.dimensionsOf` so this
* file keeps stubbing exactly what it means to stub. The undeclared case is not
* an omission; it has its own test.
*/
const declaredBudgets = (entries, owner) => {
const dimensions = new Set()
for (const e of entries) {
if (typeof e.cost !== 'function') continue
const params = {}
for (const p of e.params || []) if (p.example !== undefined) params[p.name] = p.example
let priced
try {
priced = e.cost(params)
} catch {
continue
}
for (const d of Object.keys(priced || {})) dimensions.add(d)
}
return [...dimensions]
.filter((id) => id.startsWith(`${owner}.`))
.map((id) => ({ id, label: id, unit: 'count' }))
}
const register = (entries, owner = 'test') => {
const api = registries.stage(owner)
api.registerEventActions(entries)
api.registerEventBudgets(declaredBudgets(entries, owner))
registries.apply(api.staged)
}
@@ -94,8 +128,8 @@ test('every step is dispatched with verify true, and nothing is asked to act', a
test('the cost of the whole plan is added up across steps, and a total over the cap is a finding', async () => {
// The one check that only exists here. Each of the three steps fits under 30 on
// its own; together they do not.
register([recorder('test.spawn', { risk: 'change', cost: (p) => ({ 'x.creatures': p.count }) })])
setSetting('test.spawn', true, { 'x.creatures': 30 })
register([recorder('test.spawn', { risk: 'change', cost: (p) => ({ 'test.creatures': p.count }) })])
setSetting('test.spawn', true, { 'test.creatures': 30 })
const report = await verifySpec(
spec([
@@ -109,31 +143,31 @@ test('the cost of the whole plan is added up across steps, and a total over the
assert.equal(report.ok, false)
const total = report.findings.find((f) => f.code === 'cap-total')
assert.ok(total, 'the whole-plan total must be its own finding')
assert.match(total.message, /asks for 45 of "x.creatures" across all its steps, and this deployment allows 30/)
assert.match(total.message, /asks for 45 of "test.creatures" across all its steps, and this deployment allows 30/)
assert.deepEqual(report.cost, [
{ dimension: 'x.creatures', total: 45, cap: 30, from: 'test.spawn', over: true },
{ dimension: 'test.creatures', total: 45, cap: 30, from: 'test.spawn', over: true },
])
})
test('a plan that fits reports its cost without a finding', async () => {
register([recorder('test.spawn', { risk: 'change', cost: (p) => ({ 'x.creatures': p.count }) })])
setSetting('test.spawn', true, { 'x.creatures': 30 })
register([recorder('test.spawn', { risk: 'change', cost: (p) => ({ 'test.creatures': p.count }) })])
setSetting('test.spawn', true, { 'test.creatures': 30 })
const report = await verifySpec(spec([step('test.spawn', { count: 12 }), step('test.spawn', { count: 8 })]), {
user: ADMIN,
})
assert.equal(report.ok, true)
assert.deepEqual(report.cost, [{ dimension: 'x.creatures', total: 20, cap: 30, from: 'test.spawn', over: false }])
assert.deepEqual(report.cost, [{ dimension: 'test.creatures', total: 20, cap: 30, from: 'test.spawn', over: false }])
})
test('an uncapped dimension is reported with its total and no cap', async () => {
// Worth showing rather than hiding: "this event will spawn 40 creatures and
// nothing bounds that" is exactly what an operator opening the switchboard
// wants to have seen first.
register([recorder('test.spawn', { risk: 'change', cost: () => ({ 'x.creatures': 40 }) })])
register([recorder('test.spawn', { risk: 'change', cost: () => ({ 'test.creatures': 40 }) })])
setSetting('test.spawn', true, {})
const report = await verifySpec(spec([step('test.spawn')]), { user: ADMIN })
assert.equal(report.ok, true)
assert.deepEqual(report.cost, [{ dimension: 'x.creatures', total: 40, cap: null, from: null, over: false }])
assert.deepEqual(report.cost, [{ dimension: 'test.creatures', total: 40, cap: null, from: null, over: false }])
})
test('a step naming an action nobody registers is a dormant finding, and the rest are still checked', async () => {

View File

@@ -420,9 +420,41 @@ test('the catalog serves the registry, callables stripped, with its vocabularies
for (const action of res.body.actions) assert.equal(action.perform, undefined)
assert.deepEqual(res.body.risks, ['notify', 'inspect', 'change', 'irreversible'])
assert.deepEqual(res.body.onFailure, ['skip', 'pause', 'abort_run'])
// Phase 1 is honest about what it does not have: budget dimensions arrive with
// the module contract, so the catalog does not pretend to carry any.
assert.equal(res.body.budgets, undefined)
// The other three registrations of the module contract arrived in Phase 7, and
// they are served BESIDE the actions because the editor needs all four to draw
// one step. Core declares no budgets and no leases of its own — its three
// actions cost nothing and hold nothing — so those are empty here, and that is
// the fact worth asserting: present and empty, not absent.
assert.deepEqual(res.body.budgets, [])
assert.deepEqual(res.body.leases, [])
// One option source, and it is core's: `core.announce`'s leg param. It is here
// WITHOUT its resolver — the values are a request of their own.
assert.deepEqual(
res.body.optionSources.map((s) => s.id),
['core.options.legs'],
)
for (const s of res.body.optionSources) assert.equal(s.resolve, undefined)
})
test('an option source resolves its values, and a refusal is a 200 the form can render', async () => {
// §F: a source that cannot answer degrades its field to free text with a
// visible warning rather than blocking the form, so BOTH answers are 200s and
// the difference is `ok`. A 4xx here would make an authoring screen something a
// module's outage can take away.
const ok = await call(ctrl.options, { params: { sourceId: 'core.options.legs' }, user: ADMIN })
assert.equal(ok.statusCode, 200)
assert.equal(ok.body.ok, true)
// Whatever legs this boot registered, each is a { value, label } pair — core
// renders no game word, so a leg's own label is the only text on the option.
for (const option of ok.body.options) {
assert.equal(typeof option.value, 'string')
assert.equal(typeof option.label, 'string')
}
const missing = await call(ctrl.options, { params: { sourceId: 'nobody.at.all' }, user: ADMIN })
assert.equal(missing.statusCode, 200)
assert.equal(missing.body.ok, false)
assert.match(missing.body.reason, /no module registers/)
})
// ── Create, edit, slug ─────────────────────────────────────────────────────
@@ -763,6 +795,10 @@ function registerCosting() {
// that is not prefixed with the module registering it, which is what keeps an
// action's id space its own (§F).
const api = registries.stage('test')
// The dimension is DECLARED as well as priced (Phase 7): a `cost()` naming a
// dimension no module registers is refused at save and at dispatch, so an
// action that priced one without declaring it could never be put in a step.
api.registerEventBudgets([{ id: 'test.creatures', label: 'Creatures', unit: 'count' }])
api.registerEventActions([
{
id: 'test.spawn',
@@ -770,7 +806,7 @@ function registerCosting() {
risk: 'change',
reversible: 'none',
params: [{ name: 'count', type: 'int', required: true, example: 4 }],
cost: (p) => ({ 'x.creatures': p.count }),
cost: (p) => ({ 'test.creatures': p.count }),
perform: async () => ({ ok: true }),
},
])
@@ -862,7 +898,7 @@ test('a cap that is not a whole number of 0 or more is refused', async () => {
for (const bad of [-1, 2.5, 'lots']) {
const res = await call(ctrl.saveAction, {
user: ADMIN,
body: { actionId: 'test.spawn', enabled: true, caps: { 'x.creatures': bad } },
body: { actionId: 'test.spawn', enabled: true, caps: { 'test.creatures': bad } },
})
assert.equal(res.statusCode, 400, String(bad))
}
@@ -875,19 +911,24 @@ test('a cap of zero is legal, and it means zero', async () => {
registerCosting()
const res = await call(ctrl.saveAction, {
user: ADMIN,
body: { actionId: 'test.spawn', enabled: true, caps: { 'x.creatures': 0 } },
body: { actionId: 'test.spawn', enabled: true, caps: { 'test.creatures': 0 } },
})
assert.equal(res.statusCode, 200)
assert.deepEqual(res.body.action.caps, { 'x.creatures': 0 })
assert.deepEqual(res.body.action.caps, { 'test.creatures': 0 })
})
test('the board offers a cap box per dimension, discovered from the declared examples', async () => {
// The Phase 6 stand-in for §F's `registerEventBudgets`, which arrives in Phase
// 7 — until then a param's required `example` is what tells core the names.
test('the board offers a cap box per dimension, named by its own declaration', async () => {
// WHICH dimensions an action spends is still discovered by pricing its declared
// examples — `cost` is a function of params, so calling it is the only honest
// way to ask. What Phase 7 added is what they are CALLED: the label and unit
// come from `registerEventBudgets`, because "30" on an unlabelled box is
// ambiguous in exactly the case that matters.
registerCosting()
const res = await call(ctrl.actions, { user: ADMIN })
const spawn = res.body.actions.find((a) => a.id === 'test.spawn')
assert.deepEqual(spawn.dimensions, ['x.creatures'])
assert.deepEqual(spawn.dimensions, [
{ id: 'test.creatures', label: 'Creatures', unit: 'count', registered: true },
])
assert.equal(spawn.enabled, false, 'a change action arrives disabled')
assert.equal(spawn.changesWorld, true)
})

View File

@@ -134,6 +134,9 @@ const forbidden = async (method, path, role) => (await dispatch(method, path, ro
const SURFACE = [
// Reads: staff-wide, the tier gate and nothing added.
['GET', '/events/catalog', ['admin', 'editor', 'moderator']],
// Phase 7. Authoring data, so staff-wide like the catalog it belongs to: an
// editor who may write the step must be able to see which values it accepts.
['GET', '/events/catalog/options/core.options.legs', ['admin', 'editor', 'moderator']],
['GET', '/events/series', ['admin', 'editor', 'moderator']],
['GET', '/events/calendar', ['admin', 'editor', 'moderator']],
['GET', '/events/runs', ['admin', 'editor', 'moderator']],