The pin moves from 655fbf3f (1.10.0) to 0a37a44 (1.12.0, website#210), past 1.11.0 (website#209), which it had skipped. Chapter 5 was read again for both versions: - 1.11.0: a thing the game ends on its own schedule is reported with ctx.events.expired and filed `expired`, not `orphaned`. - 1.12.0: an action's optional progress(), the line on a public event page. It answers for one reader, cheaply, and costs a line rather than the page when it fails. "What to build, in order" gains it as step 7. The template declares neither, so nothing it does changed meaning. template/module.json's coreApi becomes ^1.12.0 for the equality check. Checks run at the new pin: - checkCoreApi, links, rename sites and chapter paths all pass. - The template's import, swagger, build and externals checks pass. - Tests pass: server 82, client 20, scripts 10 and 11. - The template was loaded as `examplegame` into a real core of this code through core's own loader, and it registered. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
387 lines
21 KiB
Markdown
387 lines
21 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
|
|
async progress({ runId, stepId, idempotencyKey, params, resources, viewer }) {}, // optional, 1.12.0
|
|
}])
|
|
```
|
|
|
|
**`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.
|
|
|
|
**`progress` is read by strangers, so it answers for one reader** (MODULE_API 1.12.0).
|
|
While a run is live, core asks it how its step stands and puts the answer on the
|
|
event's public page and in the app.
|
|
|
|
- **The answer.** `{ label, left, of }` ("Beacons: 3 of 8 lit") or `{ label, percent }`
|
|
("The warden: 62%"), or `null`. Core renders the text and reads nothing out of it.
|
|
- **Who is reading.** `viewer.userId` is the account reading the page, or null for a
|
|
stranger. Answer `null` to anybody your own pages would hide that information from:
|
|
a public page must never say what your map does not.
|
|
- **The cost.** It is a read, it may be asked every few seconds for as long as the
|
|
run lasts, and core gives it 2 s. Answer from something you already hold or
|
|
already cache, not from a fresh round trip to the game per reader.
|
|
- **Failure.** A throw or a late answer costs a line, never the page.
|
|
|
|
**`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.
|
|
|
|
**A thing your game ends on its own schedule is not orphaned** (MODULE_API 1.11.0).
|
|
If the game removes something at its own deadline (a zone that closes when its
|
|
time is up), call `ctx.events.expired({ kind, ref })` when it does. Core then marks the
|
|
row `expired`: finished as planned, never taken back at teardown. Left to `reconcile`,
|
|
the same thing would turn up `orphaned` and read as something that went wrong.
|
|
|
|
**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()`.
|
|
Its absence is merely a lower standard rather than a broken promise.
|
|
7. **`progress`**, if a visitor would want to watch the step happen. It is the only
|
|
one an outsider reads, so build it once the ledger it counts from is right.
|
|
|
|
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
|