// ── The event action registry (EVENTS.md §F, Phase 1) ────────────────────── // // Phase 1's acceptance criteria for the registry half, one test apiece: // // • core's three actions register on every boot and appear in the catalog // • the catalog never carries a callable — no `perform`, `revert` or `cost` // • a module registering an un-namespaced action fails, with the holder named // • the closed sets are closed: an invented risk or reversibility is refused // • `reversible: 'ledger'` without a `revert()` is refused AT REGISTRATION, // not discovered at teardown when something has already been created // • an action id and a trigger id are DIFFERENT namespaces, so one id may // legitimately be both — the property the audience registry established and // this one inherits // // Point the DB at a closed port BEFORE requiring anything: registries.js reaches // utils/discordAnnounce, which reaches the pool at require time. process.env.DB_HOST = '127.0.0.1' process.env.DB_PORT = '59999' const { test, beforeEach, afterEach, after } = require('node:test') const assert = require('node:assert/strict') const registries = require('../src/modules/registries') const coreEventActions = require('../src/config/coreEventActions') const db = require('../src/utils/db') after(() => db.close()) beforeEach(() => registries._reset()) afterEach(() => registries._reset()) const ok = (over = {}) => ({ id: 'demo.thing.do', label: 'Do the thing', risk: 'change', reversible: 'none', perform: async () => ({ ok: true }), ...over, }) const register = (owner, entries) => { const api = registries.stage(owner) api.registerEventActions(entries) registries.apply(api.staged) } test('core registers its three actions on every boot', () => { registries.registerCore() const ids = registries.allEventActions().map((a) => a.id) assert.deepEqual(ids, ['core.announce', 'core.wait', 'core.cue']) assert.equal(ids.length, coreEventActions.ACTIONS.length) }) test('the catalog carries no callable', () => { registries.registerCore() for (const action of registries.allEventActions()) { assert.equal(action.perform, undefined, `${action.id} leaked perform`) assert.equal(action.revert, undefined, `${action.id} leaked revert`) assert.equal(action.cost, undefined, `${action.id} leaked cost`) } // …and the runner's own lookup still has it, which is the half that makes the // stripping a boundary rather than a deletion. assert.equal(typeof registries.eventAction('core.wait').perform, 'function') }) test('core.announce refuses an unregistered leg terminally, and never claims success', async () => { // Phase 1's version of this test asserted that all three core actions REFUSED, // because none of them was wired yet. Phase 2 gave them real bodies, so what // survives is the half that was never about the placeholder: `ok: true` on an // action that did nothing is a recorded world change that did not occur. // // `core.announce` is the one that can still legitimately refuse. A leg nobody // registers will not appear between two attempts a minute apart, so the answer // is terminal rather than transient — a human has to fix it. registries.registerCore() const answer = await registries.eventAction('core.announce').perform({ params: { leg: 'nowhere', body: 'hello' }, }) assert.equal(answer.ok, false) assert.equal(answer.retry, false) assert.match(answer.error, /nowhere/) }) test('a dry run validates and reports, but dispatches nothing', async () => { // §I's dry run: `verify === true` means validate and report, change nothing. // `core.announce` is the only core action with an outside effect to suppress. // // **The leg is resolved BEFORE `verify` is honoured, and that ordering is the // point rather than an oversight.** A dry run exists to report what would // happen, and "this step names a leg nobody registers" is the most useful thing // it can find. Answering `ok: true` first would make the dry run pass on // exactly the definition that cannot work. registries.registerCore() const announce = registries.eventAction('core.announce') const bad = await announce.perform({ params: { leg: 'nowhere', body: 'hello' }, verify: true }) assert.equal(bad.ok, false, 'a dry run must surface a leg that does not exist') // A registered leg: reported good, and its transport never touched. const leg = registries.announceLeg('discord') const dispatch = leg.dispatch let dispatched = 0 leg.dispatch = async () => { dispatched += 1 return { ok: true } } try { const good = await announce.perform({ params: { leg: 'discord', body: 'hello' }, verify: true }) assert.equal(good.ok, true) assert.equal(dispatched, 0, 'a dry run sends nothing') } finally { leg.dispatch = dispatch } }) test('core.wait defers the next step rather than sleeping, and core.cue parks', async () => { // Both answer through ordinary envelope members, which is what lets the runner // honour them without knowing what either action is. A `perform` that slept // would hold its claim for the duration and turn a five-minute pause into a // five-minute lease. registries.registerCore() assert.deepEqual(await registries.eventAction('core.wait').perform({ params: { seconds: 300 } }), { ok: true, holdFor: 300, }) assert.deepEqual(await registries.eventAction('core.cue').perform({ params: {} }), { ok: true, await: 'human', }) }) test('an action must be namespaced to its owner, and the holder is named', () => { assert.throws(() => register('demo', [ok({ id: 'other.thing.do' })]), /not namespaced "demo\."/) register('demo', [ok()]) assert.throws( () => register('rival', [ok({ id: 'demo.thing.do' })]), /already registered by "demo"/, ) }) test('the same id twice in one batch is refused', () => { assert.throws(() => register('demo', [ok(), ok()]), /registered twice/) }) test('risk and reversibility are closed sets with no default', () => { assert.throws(() => register('demo', [ok({ risk: undefined })]), /needs a risk class/) assert.throws(() => register('demo', [ok({ risk: 'world-write' })]), /needs a risk class/) assert.throws( () => register('demo', [ok({ reversible: undefined })]), /needs a reversible class/, ) assert.throws(() => register('demo', [ok({ reversible: 'maybe' })]), /needs a reversible class/) }) test("reversible: 'ledger' without revert() is refused at registration", () => { assert.throws( () => register('demo', [ok({ reversible: 'ledger' })]), /is reversible: 'ledger' but has no revert\(\)/, ) // And the mirror: a revert() nothing will ever call is a promise core does not // keep, so it is refused just as loudly. assert.throws( () => register('demo', [ok({ reversible: 'none', revert: async () => ({ ok: true }) })]), /declares revert\(\) but is reversible: 'none'/, ) register('demo', [ok({ reversible: 'ledger', revert: async () => ({ ok: true }) })]) assert.equal(typeof registries.eventAction('demo.thing.do').revert, 'function') }) test('perform() is required and cost must be a function', () => { assert.throws(() => register('demo', [ok({ perform: undefined })]), /has no perform\(\)/) assert.throws(() => register('demo', [ok({ cost: { 'demo.things': 1 } })]), /cost must be a function/) }) test('every param needs a type and an example', () => { const withParams = (params) => ok({ params }) assert.throws(() => register('demo', [withParams([{ name: 'x' }])]), /unsupported type/) assert.throws( () => register('demo', [withParams([{ name: 'x', type: 'int' }])]), /needs an example/, ) assert.throws( () => register('demo', [withParams([{ name: '9bad', type: 'int', example: 1 }])]), /bad param name/, ) assert.throws( () => register('demo', [ withParams([ { name: 'x', type: 'int', example: 1 }, { name: 'x', type: 'int', example: 2 }, ]), ]), /declared twice/, ) register('demo', [withParams([{ name: 'x', type: 'int', example: 12, source: 'demo.options.x' }])]) const [param] = registries.eventAction('demo.thing.do').params assert.deepEqual(param, { name: 'x', type: 'int', required: false, example: 12, source: 'demo.options.x', description: '', }) }) test('budgetMs defaults, and is bounded', () => { register('demo', [ok()]) assert.equal(registries.eventAction('demo.thing.do').budgetMs, registries.DEFAULT_BUDGET_MS) registries._reset() assert.throws(() => register('demo', [ok({ budgetMs: 0 })]), /budgetMs must be/) assert.throws(() => register('demo', [ok({ budgetMs: 3_600_001 })]), /budgetMs must be/) }) test('actions and triggers are different namespaces, so one id may be both', () => { // The property §F states and the audience registry established first. A verb // called `demo.raid.start` and an event called `demo.raid.start` are two // unrelated declarations, and forbidding the pair would forbid the most // natural names a module will ever want. const api = registries.stage('demo') api.registerEventTriggers([ { id: 'demo.raid.start', label: 'A raid started', ceiling: 'everyone' }, ]) api.registerEventActions([ok({ id: 'demo.raid.start', label: 'Start a raid' })]) registries.apply(api.staged) assert.equal(registries.eventTrigger('demo.raid.start').label, 'A raid started') assert.equal(registries.eventAction('demo.raid.start').label, 'Start a raid') }) test('a whole batch is refused or taken, never half', () => { assert.throws( () => register('demo', [ok(), ok({ id: 'demo.other.do', risk: 'nope' })]), /needs a risk class/, ) // The shape check throws at the CALL, before anything is staged, so nothing // from the batch is visible. assert.equal(registries.eventAction('demo.thing.do'), null) }) test('_reset() hands the process back', () => { registries.registerCore() assert.equal(registries.allEventActions().length, 3) registries._reset() 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(), []) })