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
This commit is contained in:
@@ -89,6 +89,52 @@ A module cannot do any of this from inside the website process. There is nowhere
|
||||
put what arrives while the website is not running, because the website not running
|
||||
is exactly the case.
|
||||
|
||||
## 2a. The other direction, if you ever want events
|
||||
|
||||
Everything above is about data leaving the game. Skip this section until you want
|
||||
[chapter 5](05-events.md) — but read it *before* you build the sidecar rather than
|
||||
after, because retrofitting it is more work than allowing for it.
|
||||
|
||||
An event on the website is core telling your module *"do this to the world now"*,
|
||||
and your module telling your sidecar, and your sidecar telling the game. That is a
|
||||
**command** — a request with a reply, going the way nothing above goes. It needs
|
||||
three things the read path does not.
|
||||
|
||||
**Request/reply correlation.** A command is not a broadcast: the caller waits for
|
||||
an answer and has to know which answer is theirs. `uo-link` does this in
|
||||
`sidecar/src/rpc.rs` — an id on the way out, a map of pending calls, the reply
|
||||
matched back and the waiter woken. You need it for reads that ask the game a live
|
||||
question too, so it is often already there; commands are what make it load-bearing.
|
||||
|
||||
**An idempotency key, executed at most once, stored where the game is.** Core hands
|
||||
your module a key that is a function of the step's identity and never of the
|
||||
attempt, so every retry carries the same one. The far end must execute a given key
|
||||
once and answer a repeat with **the reply the first attempt produced** — not by
|
||||
running the command again.
|
||||
|
||||
That store belongs as close to the game as the state it protects. A store in the
|
||||
sidecar is right for a command whose effect is the sidecar's own; a command that
|
||||
changes the *world* needs the store where the world is, because the case it exists
|
||||
for is the game restarting mid-run. And a repeat arriving while the original is
|
||||
still in flight is its own answer — "busy", transient by construction, because the
|
||||
work is happening.
|
||||
|
||||
Without this, a command that arrived, ran, and whose acknowledgement was lost is
|
||||
indistinguishable from one that never arrived. The only safe policy is then never
|
||||
to retry, which means a game restarting mid-event writes the step off.
|
||||
|
||||
**A deadline the game enforces on its own.** A borrowed value — a doubled gather
|
||||
rate, a raised spawn cap — carries an expiry down the wire, and the game side must
|
||||
restore the baseline when it passes **without being asked again**. The website's
|
||||
copy of that deadline is for the console. The game's copy is the fail-safe: if the
|
||||
website is never heard from again, the value still comes back.
|
||||
|
||||
Two details that are easy to get wrong and expensive to change later. Send the
|
||||
deadline as a **duration**, not an absolute time — two machines' clocks are two
|
||||
clocks. And if the borrowed value lives in the game's own save file, the *hold*
|
||||
must be persisted and the timer re-armed at load; a restart preserves the change
|
||||
and destroys only the thing that would have undone it.
|
||||
|
||||
## 3. The wire is a versioned contract, not a build dependency
|
||||
|
||||
Your sidecar and your module ship separately, on different schedules, to hosts you
|
||||
|
||||
@@ -103,6 +103,53 @@ escape into a game code path. `BridgeLink` wraps the inbound handler and logs
|
||||
anything it throws, because the alternative is an exception unwinding somewhere in
|
||||
the engine's main loop.
|
||||
|
||||
## A command that changes the world runs at most once
|
||||
|
||||
Skip this until you want [chapter 5](05-events.md). Everything above assumes an
|
||||
inbound line either asks a question or is a one-off an operator typed. An **event**
|
||||
is neither: it is unattended, it is retried, and what it does is permanent.
|
||||
|
||||
Three obligations, and they all live on this side of the wire because this is the
|
||||
side that has the world.
|
||||
|
||||
**Keep a key store, and persist it.** Every command an event sends carries an
|
||||
idempotency key — a function of the step's identity, never of the attempt, so a
|
||||
retry carries the one the first attempt did. Before executing, look the key up:
|
||||
|
||||
- **not seen** — execute, then record the key *with the reply you are about to
|
||||
send*;
|
||||
- **seen and finished** — send that stored reply back, unchanged. Do not re-run;
|
||||
- **seen and still running** — answer "busy". It is transient by construction, and
|
||||
the caller will retry; running it concurrently with itself is the failure.
|
||||
|
||||
The stored reply matters as much as the guard. A repeat that re-ran and returned a
|
||||
*new* serial would be two things in the world and one in the website's ledger,
|
||||
which is the exact failure the key exists to prevent, arrived at by a longer route.
|
||||
|
||||
**Persist it in the world save, not in memory**, if what the command creates
|
||||
survives a restart. The case this whole mechanism exists for is a game restarting
|
||||
mid-event, and a key store that dies with the process is a store that is empty in
|
||||
precisely that case.
|
||||
|
||||
**Own what an event made, and expire what it borrowed.**
|
||||
|
||||
An event-created thing has to be findable again later, because the website will ask
|
||||
you to remove it after the event and may ask more than once. That means a registry
|
||||
— a persisted map from the website's reference to the object — and it means
|
||||
`remove` is idempotent: **removing something that is not there is a success.** The
|
||||
website records a resource *before* it is confirmed, so it will ask you about
|
||||
things that may never have existed, and neither end can tell the difference.
|
||||
|
||||
A borrowed value is the mirror. It arrives with a duration, and you arm a timer that
|
||||
puts the baseline back when it expires. If the borrowed value lives in the save
|
||||
file, persist the hold and **re-arm the timer at load** — a restart preserves the
|
||||
change and destroys only the thing that would have undone it. Restore with a
|
||||
compare-and-set against what you applied: if a staff member has moved it by hand
|
||||
since, report that rather than overwriting them.
|
||||
|
||||
The through-line: **the game enforces the expiry, not the website.** If the website
|
||||
is never heard from again, every borrowed value still comes back on its own.
|
||||
|
||||
## Reconnect, and what to send on connect
|
||||
|
||||
Your sidecar restarts independently of your game. It comes back with an empty
|
||||
@@ -173,12 +220,25 @@ Two practical notes from that file, both general:
|
||||
If all eight hold, the worst a broken sidecar can do to your game is nothing at
|
||||
all — which is the entire point of the arrangement.
|
||||
|
||||
Three more, and only if you took commands (chapter 5):
|
||||
|
||||
9. A command's idempotency key is looked up before it is executed, and a repeat is
|
||||
answered with the stored reply rather than re-run.
|
||||
10. What an event made is in a persisted registry, and removing something absent is
|
||||
a success.
|
||||
11. A borrowed value's expiry is armed by this side, re-armed at load, and restored
|
||||
with a compare-and-set.
|
||||
|
||||
---
|
||||
|
||||
That is the book. The three parts are a module core loads, a sidecar that owns the
|
||||
game connection and the durable copy of what it said, and a plugin that feeds the
|
||||
sidecar without ever waiting on it.
|
||||
|
||||
[Chapter 5](05-events.md) is the optional fifth part: what to declare if you want
|
||||
the website to be able to change your world on a schedule, and the four mistakes
|
||||
that make that unsafe.
|
||||
|
||||
If you got this far and built something, the places you got stuck are the most
|
||||
valuable thing this repo can receive — [tell us][issues], and please say where you
|
||||
left the kit and what you did next.
|
||||
|
||||
364
book/05-events.md
Normal file
364
book/05-events.md
Normal file
@@ -0,0 +1,364 @@
|
||||
# 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
|
||||
@@ -1,6 +1,7 @@
|
||||
# The book
|
||||
|
||||
Four chapters, in the order the work happens.
|
||||
Five chapters, in the order the work happens. The first four are the job; the
|
||||
fifth is optional and comes after you have one.
|
||||
|
||||
Read [the dry run][dryrun] before any of them — a complete module designed on
|
||||
paper for a second game, and the shortest honest picture of the whole job.
|
||||
@@ -11,12 +12,20 @@ paper for a second game, and the shortest honest picture of the whole job.
|
||||
| 2 | [The website module](02-website-module.md) | The bulk of the work: `module.json`, `register(ctx, api)`, the schema fragment, the client chunk, packaging, and what a module must never do. |
|
||||
| 3 | [The sidecar](03-sidecar.md) | Why the website never talks to a game server, what "persist before you forward" means, and what a *thin* sidecar is. |
|
||||
| 4 | [The game-side plugin](04-game-plugin.md) | The least code and the highest stakes: never block the game thread. |
|
||||
| 5 | [Making your module event-capable](05-events.md) | Optional, and the first thing here that can do damage: letting a scheduled event on the website change your live world, and get it back. |
|
||||
|
||||
Chapters 1 and 2 quote `template/`, which CI builds against a pinned core, so their
|
||||
code is a tree that is proved rather than prose that looks like one. Chapters 3 and
|
||||
4 cite `uo-link` and `servuo-plugins` by file and identifier rather than by line, on
|
||||
purpose: those repositories move for their own reasons and a line number in a book
|
||||
is wrong the moment they do.
|
||||
Chapters 1, 2 and 5 quote `template/`, which CI builds against a pinned core, so
|
||||
their code is a tree that is proved rather than prose that looks like one. Chapters
|
||||
3 and 4 cite `uo-link` and `servuo-plugins` by file and identifier rather than by
|
||||
line, on purpose: those repositories move for their own reasons and a line number in
|
||||
a book is wrong the moment they do.
|
||||
|
||||
**Chapter 5 is the one you can stop before.** Chapters 1 to 4 get a game onto the
|
||||
platform and everything in them moves one way — out of the game and onto a page.
|
||||
Chapter 5 is the other direction, and a deployment that never reads it still has a
|
||||
working event engine over core's own verbs. Chapters 3 and 4 each carry one section
|
||||
that only matters if you are going there (§2a and *"A command that changes the
|
||||
world runs at most once"*); both say so at the top.
|
||||
|
||||
## What is normative, and what is here
|
||||
|
||||
@@ -28,12 +37,14 @@ document is right and the chapter has a bug — [say so][issues]:
|
||||
| [`MODULE_API.md`][api] | Everything a module may do. |
|
||||
| [`MODULE_SYSTEM.md`][system] | Why the module system is shaped this way, and how a module is installed and removed. |
|
||||
| [`link/PLAN.md`][linkplan] + [`INTEGRATION.md`][linkint] | The game↔sidecar wire protocol, as one real sidecar implements it. |
|
||||
| [`EVENTS.md`][events] | The event system: what an event is, what a module declares, and what core owns. |
|
||||
|
||||
The chapters teach: the order to do things in, the reasoning, and the mistakes that
|
||||
cost this project time.
|
||||
|
||||
[api]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md
|
||||
[system]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_SYSTEM.md
|
||||
[events]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/EVENTS.md
|
||||
[dryrun]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/modules/rust-dryrun.md
|
||||
[linkplan]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md
|
||||
[linkint]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/INTEGRATION.md
|
||||
|
||||
Reference in New Issue
Block a user