Files
website/server/test/eventActionRegistry.test.js
wtclaude 7d3d6d5abd
Some checks failed
PR Checks / client-build (pull_request) Successful in 45s
PR Checks / server-tests (pull_request) Failing after 5m47s
PR Checks / bot-tests (pull_request) Successful in 8m27s
feat(events): the integrations — lifecycle triggers, participants, results (Phase 10)
`EVENTS_PLAN.md` Phase 10. Core registers its own `event.` triggers, records who
took part, publishes a results table, and announces a post through the legs the
news pipeline already uses. Events owns none of the delivery: a run says what
happened and an operator's rule decides who is told, so email, the in-app inbox,
push tickles, Discord and the town crier all arrive without anything in
`events/` growing a second delivery path.

**No route was added and nothing moved.** The whole surface is two more derived
fields on a run — `participants` and `resultsPublishedAt` — and a zero-line
`routes.manifest.json` diff proves it.

Seven triggers: six at ceiling `authenticated` / audience `subscribers`, exactly
where `news.post` sits, and `run.failed` at `admin` on both halves. Every one
keys its cooldown on the RUN. Two rules seeded, both off, under a third one-shot
key so a deployment that has already stamped the Team and news keys still gets
them.

**The phase's own defect was a promise nothing kept.** `EVENTS.md` §I says a
rehearsal runs for real "with announcements ceilinged to `staff`" — but a
ceiling is declared on the TRIGGER, and a rehearsal fires the same trigger as
the real thing, so the moment this phase gave a run something to announce,
rehearsing a published event would have mailed every subscriber. The emit
envelope now takes an optional `ceiling` and the send-time G24 gate applies
`meet(declared, emitted)`. It only narrows; two incomparable ceilings refuse
every rule rather than resolving to either.

`MODULE_API_VERSION` stays 1.10.0, amended in place — `main` declares 1.9.0, so
1.10.0 has not shipped and the org lead's 2026-09-03 rule applies for the third
time.

Three defects the live walk found, none visible to a unit test:

1. **A channel that reported success while reaching nobody.** The seeded
   `run.started` rule named `push`, because §8.5 and the plan both do. Push
   delivery joins `notification_subscriptions`, only ever written for an id the
   preferences screen offered push for — and it offers push only for a
   registered STREAM. So the tickle went nowhere every time while
   `pushChannel.deliver` answered "tickle published". `event.run.started` is now
   a stream as well as a trigger; the other six are not.
2. **A trigger's `description` reaches a recipient.** It is the structural
   projection's `intro` fallback, so `run.failed`'s line ending "Staff-facing."
   put those words in an administrator's own inbox item.
3. **`affectedRows` cannot tell an insert from an unchanged upsert.** The
   connector sends `CLIENT_FOUND_ROWS`, so a "was this new" flag would have
   counted every idempotent retried collect as a fresh participant.

And one caught before it shipped: ranking with a session variable is wrong here,
because `query()` takes a pool connection per call — the variable would be set
on one connection and read on another. A window function needs no session state.

## Verification

- `npm test --prefix server` — **1981 pass, 1 fail**, and that one
  (`botScore.test.js`) passes standalone at 18/18: a file-level flake under
  parallel load. Run with an empty `MODULES_DIR`, as CI does.
- `npm test --prefix client` — 362 pass, 0 fail. `npm run build` green.
- Zero-line `routes.manifest.json` / `routes.guards.json` diff.
- A live walk on a real rig: MariaDB, the site with no module, mailpit. The mail
  arrived, headed with the event's title and its start time in the shard's own
  zone; the rehearsal fired the same trigger and produced zero outbox rows where
  the real run produced three; `run.failed` reached the administrator's inbox
  and no player's; `core.announce.post` queued a second job without touching the
  news pipeline's back-pointer or `announced_at`; and `rankRun` and the upsert
  were run against real MariaDB 11.

## One thing for a reviewer, out of scope and not fixed

**Every `#swagger.description` in this repo is truncated in the generated spec.**
swagger-autogen does not honour a backslash-escaped apostrophe, so a description
is cut at the first `\'` — 175 of the 177 in `server/src/router/**`. It is
pre-existing and repo-wide. Only the one annotation this phase edits is fixed
here (a typographic apostrophe), because otherwise this phase's own addition to
it would be dead text. The rest wants its own change.

- [x] AI-assisted: Claude Code (Opus 5).

Docs: RunicGateway/docs#TBD.

Co-Authored-By: Claude <noreply@anthropic.com>

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-09-04 13:06:46 -05:00

478 lines
20 KiB
JavaScript

// ── 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 six actions on every boot', () => {
registries.registerCore()
const ids = registries.allEventActions().map((a) => a.id)
// `core.lease` joined the three in Phase 8, and it is the only one of the four
// that genuinely changes the world — which is why it is core's rather than each
// module's: §F puts the duration bound and the two-events-one-target conflict
// check on core's side of the seam, and a lease verb per module would be that
// bound re-implemented once per module and advisory everywhere.
//
// Phase 10 added the last two, and they are the integrations (§J):
// `core.announce.post` sends an ARTICLE through the announce legs rather than
// a line, and `core.results.publish` is what makes §F's "publish results"
// literal. Six is now the whole of what an event can do on a deployment with
// no game module installed at all.
assert.deepEqual(ids, [
'core.announce',
'core.wait',
'core.cue',
'core.lease',
'core.announce.post',
'core.results.publish',
])
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.reconcile, undefined, `${action.id} leaked reconcile`)
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, 6)
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',
// Phase 8's, and it is the same argument one phase on: `core.lease`'s `lease`
// param would otherwise be a free-text box whose typo is caught at dispatch,
// mid-run — and the leases are already a registry with labels in them.
'core.options.leases',
// Phase 10's, and the first core source that reaches a table. It is allowed
// to because of WHEN a source resolves: on its own request, on a booted
// server, rather than at `register()` time under a dead pool.
'core.options.posts',
])
const leg = registries.eventAction('core.announce').params.find((p) => p.name === 'leg')
assert.equal(leg.source, 'core.options.legs')
const which = registries.eventAction('core.lease').params.find((p) => p.name === 'lease')
assert.equal(which.source, 'core.options.leases')
})
// ── `reconcile`, the one member Phase 8 added to the action shape ──────────
//
// Optional where `revert` is required, and the asymmetry is the design: a module
// that cannot say what the game still has is not broken — core keeps believing
// its own ledger, which is the behaviour before this phase — whereas a module
// that created something and cannot undo it has made a promise core has no way
// to keep.
test('reconcile is optional, must be a function, and only on an action that ledgers', () => {
const ledgering = {
id: 'demo.spawn',
label: 'Spawn',
risk: 'change',
reversible: 'ledger',
perform: async () => ({ ok: true }),
revert: async () => ({ ok: true }),
}
// Absent is legal, and it lands as an explicit null rather than as a missing
// key — the same shape `revert` and `cost` take, so the catalog's strip list
// and the sweep's `typeof` check both have something to look at.
register('demo', [ledgering])
assert.equal(registries.eventAction('demo.spawn').reconcile, null)
registries._reset()
assert.throws(
() => register('demo', [{ ...ledgering, reconcile: 'yes please' }]),
/reconcile must be a function/,
)
// The mirror check `revert` already has. An action that ledgers nothing has no
// rows for core to ask about, so a `reconcile` on one is an author who believes
// something is being tracked and a sweep that will never call it.
assert.throws(
() =>
register('demo', [
{
id: 'demo.shout',
label: 'Shout',
risk: 'notify',
reversible: 'none',
perform: async () => ({ ok: true }),
reconcile: async () => ({ ok: true, inForce: [] }),
},
]),
/declares reconcile\(\) but is reversible: 'none' and ledgers nothing/,
)
})
test('an override action may reconcile, because a lease is ledgered too', () => {
register('demo', [
{
id: 'demo.borrow',
label: 'Borrow',
risk: 'change',
reversible: 'override',
perform: async () => ({ ok: true }),
reconcile: async () => ({ ok: true, inForce: [] }),
},
])
assert.equal(typeof registries.eventAction('demo.borrow').reconcile, 'function')
})
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(), [])
})