feat(events): open the event contract to modules (Phase 7)
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:
@@ -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(), [])
|
||||
})
|
||||
|
||||
@@ -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'),
|
||||
|
||||
515
server/test/eventModuleContract.test.js
Normal file
515
server/test/eventModuleContract.test.js
Normal 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"/)
|
||||
})
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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']],
|
||||
|
||||
Reference in New Issue
Block a user