Files
Integration-kit/book/05-events.md
wtclaude f89044b42e
Some checks failed
PR Checks / prose (pull_request) Successful in 12s
PR Checks / template (pull_request) Failing after 29s
feat(kit): the event contract, taught and built (chapter 5)
The fifth chapter, and the template code it teaches out of. Events is the first
thing in the book that goes the other way — chapters 1-4 move data out of the
game and onto a page; an event changes a live world on a schedule, unattended.

**Chapter 5** covers the four declarations (budgets, option sources, leases,
actions), leads with the lease because EVENTS.md §H is right that it is the
primitive that travels and the spawn is the special case, and gives one section
each to the four things that are invisible until an outage: the envelope's
failure default, the idempotency passthrough, recording a resource before
confirming it, and under-declaring `cost`.

**Chapters 3 and 4 gain one section each** for the command plane, because
without them chapter 5 teaches a module to send an idempotency key to a sidecar
the book never told anyone to build a command path in. Both say at the top that
they are skippable until you want chapter 5.

**The template ships one of each declaration**, with `server/sidecarClient.js`
as the near end — a real timeout, a real key passthrough, a simulated transport
in one function marked for replacement. That file is named for the filename
`noGameConnection.test.js` already anticipated, so the test stays green now and
fires correctly the moment `deliver()` becomes a request.

Two things writing it found, both now in the chapter and beside the code:

  * **An idempotency key belongs on a command, never on a question.** The first
    draft keyed every call including the reads; an at-most-once store then
    answers every future read with the first one's reply, forever. The lease
    applied correctly and the module could no longer see it. Hence `ask` and
    `send` as two functions.

  * **A refusal's reason goes in `error`; core reads no other name.** The first
    draft used `detail`, on the strength of the one place EVENTS.md §H mentions
    it, and every refusal it produced was anonymous on the run console.

Proved by running the template's real declarations through core's real registry
at `edge` (all four accepted) and its real envelopes through the real
`events/dispatch.js` classifier.

**CI is RED on `checkCoreApi` and that is the mechanism working.** The template
now declares `coreApi: ^1.10.0` and `ci/core-ref.json` pins the engagement
cutover, where `main` is still 1.9.0. Equality is the check, a bump is meant to
turn this repo red until someone re-reads the chapters, and the pin move rides
in the events cutover (EVENTS_PLAN.md P16) as its own commit. Do not "fix" it.

Refs EVENTS_PLAN.md Phase 15, EVENTS.md §F, MODULE_API.md 1.10.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-08 18:10:18 -05:00

365 lines
19 KiB
Markdown

# 5. Making your module event-capable
Chapters 1 to 4 got a game onto the platform: a module that reads, a sidecar that
stores, a plugin that tells it what happened. Everything in them moves one way —
out of the game and onto a page.
This chapter is about the other direction. The event system is core's engine for
**scheduled, bounded, audited changes to a live game world**: an operator writes an
event on the website — a phase that announces, a phase that spawns something, a
phase that waits for a condition, a phase that cleans up — publishes it, schedules
it, and it runs unattended at two in the morning. Your module is what lets any of
that touch your game.
It is also the first thing in this book that can do damage. A page that renders
wrong is embarrassing. An action that half-ran and was recorded as done is a
change to a live world with nothing coming back for it.
Nothing here is normative. [`EVENTS.md`][events] is the design of record and
[`MODULE_API.md`][api] is the contract; where this chapter and either of those
disagree, they are right and this chapter has a bug. What is here is the ordering,
the reasoning, and the four mistakes that are invisible until an outage.
---
## Everything in this chapter is optional
Stated first because it changes how you should read the rest.
A deployment with **no module at all** still has a working event engine. Core owns
verbs of its own — announce something, wait, cue a human to do the in-game part,
publish results — and an event composed only of those runs on bare core with zero
modules installed. That is not a degraded mode; it is a real product, and for many
games it is the whole of what you want.
So each of the four declarations below *adds* something an author can reach for.
Registering none of them costs your deployment a capability, never a boot — the
same posture as a module with no `onBoot`, which still reaches `started`.
Which means you can stop reading at any section boundary and ship what you have.
## The four declarations
```js
api.registerEventBudgets([...]) // dimensions core can COUNT and BOUND
api.registerEventOptionSources([...]) // what a dropdown on the form is FILLED from
api.registerEventLeases([...]) // values a run may BORROW, with a deadline
api.registerEventActions([...]) // verbs a run may PERFORM
```
Four separate id spaces, each namespaced under your module id. `examplegame.beacons`
as a budget and `examplegame.beacon.light` as an action are not a collision, and
reading them as one would forbid the most natural set of names you will ever
write. An action names a VERB, a budget a RESOURCE, a lease a VALUE, an option
source a CATALOG.
All four are in the template at
[`template/server/config/eventActions.js`](../template/server/config/eventActions.js),
one of each, with the four traps marked where they bite. Read that file beside
this chapter.
## Build the lease first
If you have time for one thing, build a lease, not an action. This is the kit
disagreeing with the obvious priority on purpose.
The obvious thing to build is spawning: an event that puts creatures at a landmark
is what a game event *looks* like. But spawning is a shape one genre happens to
have, and it is the harder half — something now exists that did not, and your
module owes core a way to take it away again on every terminal path, including
the ones where nobody is watching.
A lease is the other shape: **a value that already existed, changed for a while,
and put back.** "Double the gather rate for the weekend." "Turn the night length
down until Sunday." "Raise this spawner's population for the invasion." That is
the canonical community event in most games, and it is cheaper to make safe,
because the value you are replacing already exists and reading it first gives you
your baseline for nothing.
**The verb is core's, not yours.** You declare what can be held and how long; an
author puts `core.lease` in a step naming your lease, a value and a number of
minutes, and core reads the baseline, reserves the target, applies the value with
a deadline, and restores it at teardown through your own `restore()`. A lease verb
of your own would be that duration bound and that "two events cannot hold one
target" check re-implemented once per module — advisory everywhere, and wrong in
the first one that forgot it.
```js
api.registerEventLeases([{
id: 'examplegame.rate.gather',
label: 'Gather rate',
type: 'float', min: 0.5, max: 5,
maxDurationMs: 48 * 60 * 60 * 1000,
async read() { /* the live baseline */ },
async apply(value, until) { /* hold it, and send `until` down the wire */ },
async restore(baseline, { expected }) { /* put it back, or report drift */ },
async inForce() { /* optional — a FOURTH question, see below */ },
}])
```
Three things about that shape are worth more than their size.
**`until` goes down the wire and the far end honours it without being asked
again.** Core's copy of the deadline is for the console; the game's copy is the
fail-safe. A module that passes `until` and then relies on core coming back to
restore has built a lease that outlives an outage — which is the one thing a lease
exists to prevent. If the website is never heard from again, the value must still
come back.
**`restore()` reports drift rather than overwriting it.** `expected` is what core
believes is applied. If the live value differs, somebody moved it by hand during
your event, and answering `{ ok: true, drifted: true, value }` lands the row as
`drifted` with the current value beside it. Silently restoring over a human's edit
is the bug this exists to prevent.
**`inForce()` is a fourth question, not a fourth spelling of `read()`.** It asks
*"does the game side still have any record of this hold?"*, and none of the other
three answers it. A value that DIFFERS from what the run applied is drift, which
`restore()` reports; a reconcile that inferred absence from a changed value would
take the row out and tell an operator the lease vanished rather than that somebody
moved it. Optional — and `{ ok: true, held: false }` is the only thing that takes
a lease's ledger row out. A throw, a refusal, or no `inForce()` at all leaves the
row alone.
**Only advertise a lease you have verified takes effect.** A value your game reads
once at start-up and caches will apply cleanly, read back cleanly, and do nothing
at all. Core cannot catch that and neither can review — it is a capability that
lies. Apply it, observe it in the running game, restore it. Per key, as a test.
The UO module surveyed 156 config reads in its game and found roughly eight that
were live; the rest were cached at boot and would all have lied.
## Actions, and what "owning" something means
An action is a verb an author puts in a step. What it makes, the run OWNS until
teardown.
```js
api.registerEventActions([{
id: 'examplegame.beacon.light',
label: 'Light beacons',
risk: 'change', // notify | inspect | change | irreversible
reversible: 'ledger', // none | self | ledger | override
version: 1,
budgetMs: 15000,
cost: (p) => ({ 'examplegame.beacons': p.count }),
params: [ /* every one carries an `example` */ ],
async perform({ runId, stepId, idempotencyKey, scope, params, actor, verify }) {},
async revert({ runId, resources, idempotencyKey }) {}, // required iff 'ledger'
async reconcile({ runId, resources }) {}, // optional
}])
```
**`reversible: 'ledger'` is a promise.** It says core may record what you made and
come back later to have it undone, and it makes `revert` required. Core's cleanup
is **derived, not authored**: there is no `on_teardown` field and no cleanup phase
in a spec, because an operator cannot be relied on to write the undo and an
aborted run never reaches the phase they wrote it in. Cleanup is one sweep over
the ledger and it runs on every terminal path — completion, cancellation and abort
alike. Your only job is to answer `revert` correctly, however many times you are
asked.
**`verify: true` must change nothing and must answer honestly.** It is the dry
run, and it rides the same dispatcher a real run uses — because a dry run down a
second code path is a dry run of the second path. Validate everything you can
reach without writing, then stop. Answering `{ ok: true }` unconditionally makes
the dry run worthless in the one situation it exists for.
**`example` is required on every param, optional ones included.** It is the
authoring form's placeholder. It is one word at declaration time and it is
unreconstructable afterwards by anybody who did not write the action.
**A `source` on a param makes it a dropdown**, filled by an option source you (or
another module) registered. A source that refuses degrades its field to free text
with a warning and never blocks the form — so resolve from live data and return
`[]` on failure, rather than defending with a hardcoded list that will be wrong.
---
# The four things that are invisible until an outage
Everything above is ordinary. These four are the ones that look like they are
working, in every test you write and every demo you give, right up until the day
something is down.
## 1. The failure default is a retry, and `budgetMs` is what makes the other half reachable
**No shape a failure can take reads as success.** A rejected promise, a throw, a
budget timeout, a non-object and a missing `ok` are all `{ ok: false, retry: true }`.
`retry` is opted OUT of: a module that means "this will never work" must say
`retry: false`.
That direction is deliberate, and it is `registerTeamProvider`'s default
*inverted*. A Team provider that refuses leaves core showing what it had, because
staleness is cheap. An action that half-ran and was recorded as done is a world
change nothing will ever come back for.
Now the part that is easy to miss. Core's dispatcher enforces `budgetMs`, and when
the budget expires it classifies the failure as **retry, unconditionally, without
asking you** — it cannot ask, your action is still awaiting a socket.
**So if your transport's timeout is longer than `budgetMs`, your own `retry: false`
is unreachable code.** Core's default `budgetMs` is 10 seconds. If your sidecar
client waits 12, core's deadline fires first on every slow game and the step is
retried no matter what your envelope says. The first module this project shipped
had exactly that pairing, and its one deliberately un-retryable verb was retried
anyway for a whole phase.
The rule generalises past that one pairing: **an action is the near end of a call
with a far end, and the near end has to outlive it.** Derive one constant from the
other rather than typing both, and assert the inequality in a test — the template
does both, because a number typed twice drifts the first time somebody tunes the
client and does not think to look at the other file.
**The reason a refusal gives goes in `error`.** Core reads exactly `ok`, `retry`
and `error` off a failure envelope; a message under any other name is dropped in
silence and the operator sees `"<action id> refused"`. Writing this chapter's
template is how that was found — its first draft used `detail`, and every refusal
it produced was anonymous.
## 2. Pass the idempotency key through, and put it on a command rather than a question
Core hands `perform()` an `idempotencyKey` derived from the step's identity — never
from the attempt number — so **every retry carries the same one**. The far end,
which is the only end that can tell a retry from a repeat, executes a key at most
once and answers a repeat with the ORIGINAL reply rather than running it again.
Pass it through unchanged. A module that invents its own key here, or drops it,
has an action that cannot be retried safely, and the cost of that is not a failed
step: it is a second set of everything on a socket hiccup. It looks correct in
every test you will write, because in every test the first attempt succeeds.
It is also what a lost acknowledgement is recovered from. Without a key, a command
that arrived, ran, and whose reply was lost is indistinguishable from one that
never arrived — so the only safe policy is never to retry, and a game restarting
mid-run writes the step off. With one, the retry collects the answer the first
attempt never delivered.
**And it belongs on a command, never on a question.** This is the correction
writing the template produced, and it is quiet and total: an at-most-once store
answers a key it has already seen with the first reply, forever. So a *read* that
carries a key returns the first read's value on every subsequent call — the lease
applied correctly, the game changed correctly, and the module could no longer see
any of it. `read()` reported the pre-run baseline and `inForce()` said nothing was
held. The template splits its client into `ask()` and `send()` for exactly this
reason.
The rule for which commands need a key is narrower than "all of them", too. A key
is for a write whose repetition would be a second EFFECT — creating, granting,
announcing. A write that SETS a value to X is idempotent by its own nature: doing
it twice is doing it once, and a key would only pin its reply.
Build the store on the **far end**, and persist it. A store in your module answers
nothing, because the case that matters is the one where the command arrived and
ran. See [chapter 4](04-game-plugin.md) for the game-side half.
## 3. Core records a resource BEFORE it is confirmed
This is one line in [`EVENTS.md`][events] §D and it decides the whole shape of your
`revert`.
Core writes a placeholder into its ledger, keyed by the step's idempotency key,
**before** dispatching — so a dispatch whose answer never came back is still
something cleanup can act on. Your `resources` are the refs core did not know until
the answer arrived, filled in afterwards.
Two consequences, and both are about what `revert` must tolerate:
**Reverting something that does not exist is a SUCCESS.** Cleanup will ask you
about rows for things that may never have existed. You must never have to tell
"I removed it" from "it was not there" — and you could not, because your game
cannot either. Answer `{ ok: true }`. This is also what a game with a monthly wipe
needs, where every ledgered resource is invalidated at once and "gone, and that is
fine" is the only useful answer.
**You will be called with NO resources and only a key.** That is the lost-answer
case stated exactly: core knows a dispatch went out under this key and never
learned what it made. A module that can undo by key answers honestly. One that
cannot answers `{ ok: false }`, and the row stays visible to an operator — which is
the correct outcome, not a silent one. Answering `{ ok: true }` to a question you
cannot answer is how something burns in a live world forever with core's ledger
reporting it cleaned up.
`revert` must also be idempotent, because core may ask more than once.
**`reconcile` is 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 any of this existed.
One that created something and cannot undo it has made a promise core has no way
to keep.
And when you do answer: **anything that is not an explicit
`{ ok: true, inForce: [...] }` leaves the ledger alone.** "I do not know" is never
read as "it is gone". A resource you report missing becomes `orphaned` rather than
`reverted`, because nobody asked for it to go.
**You say WHEN to reconcile, because core cannot.** Core has no concept of the game
being up — it sees `{ ok: false, retry: true }` and cannot tell a wedged sidecar
from a game that rebooted and lost everything an event made. So it asks once, at
its own boot, and otherwise waits to be told. `ctx.events.reconcile()` is being
told, and the thing that triggers it is your own watch on a boot id changing — which
is also how you tell a game restart from a sidecar reconnect. They are not the
same event; the second loses nothing.
## 4. Under-declaring `cost` turns every cap into a lie
`cost(params)` says what one invocation consumes. An operator sets caps per
dimension, and core refuses a step that would exceed one.
**Core prices `cost` before dispatch and never reconciles it against the resources
that come back.** It cannot — it does not know what a beacon is. So an action that
returns `{ 'examplegame.beacons': 1 }` while lighting twelve turns an operator's cap
of 30 into a cap of 360, the meter on the run console agrees with the lie, and
nothing anywhere goes red. The first symptom is a world with an order of magnitude
more in it than anyone authorised.
Count what you will actually make, from the params you were given, every time. **If
you cannot know until the answer comes back, declare the maximum**: a spend that is
too high refuses an event that would have fit, which an author can see and argue
with; one that is too low cannot be seen at all.
Two smaller rules ride with it:
- **You cannot spend a dimension no module declared.** A `cost()` naming an
unregistered one is refused at save, at the dry run and at dispatch, with its own
refusal code — because the fix is a module's declaration and not a deployment's
cap.
- **Declaring a dimension is not the same as bounding it.** A declared dimension
with no operator cap is counted and unbounded, which is useful on its own: the
run console then shows an author what their event actually spent.
---
## What core owns that you might think is yours
Four things a second module's author reaches for and should not.
| You might build | Core already owns it | Because |
| --- | --- | --- |
| A `myGame.lease` verb | `core.lease` | the duration bound and the two-events-one-target check belong in one place, or they are advisory everywhere |
| A cleanup phase, or `on_teardown` | the ledger sweep | an aborted run never reaches the phase somebody wrote the undo in |
| Deciding who is told about your event | rules and audiences | you declare what CAN happen; core decides who is told ([chapter 2](02-website-module.md)) |
| A second write path for participants | the `participants` envelope member | a second door into a run core is mid-tick on is a second thing that can race the step claim |
## What to build, in order
1. **Nothing.** Confirm an event composed of core's own verbs runs on your
deployment. If it does, the engine is working and everything below is additive.
2. **One budget dimension**, declared and uncapped. Costs nothing and makes the
next step legible.
3. **One lease**, verified live — apply, observe in the running game, restore.
This is the primitive that travels, and for many games it is the whole feature.
4. **One option source**, so the authoring form stops asking operators to type
identifiers from memory.
5. **One action that ledgers**, with `revert` and the four traps above. This is
where the work is, and where the damage is.
6. **`reconcile`**, and the boot-id watch that calls `ctx.events.reconcile()`.
Last, because it is the only one whose absence is merely a lower standard
rather than a broken promise.
Then read your own `revert` again, and ask what it answers when the game is down.
[api]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md
[events]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/EVENTS.md