feat(kit): the event contract, taught and built (chapter 5)
Some checks failed
PR Checks / prose (pull_request) Successful in 12s
PR Checks / template (pull_request) Failing after 29s

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:
2026-09-08 18:10:18 -05:00
parent e9ca759227
commit f89044b42e
15 changed files with 1804 additions and 11 deletions

View File

@@ -0,0 +1,285 @@
// ── The near end of a call whose far end is your game ─────────────────────
//
// Every other file in this module reads its own tables. This one is different in
// kind: it is the only place that *asks the game to do something* and waits for
// an answer. That makes it the file chapter 5 is mostly about, and the file a
// reviewer should read hardest.
//
// **The transport is simulated and everything around it is not.** `deliver()` at
// the bottom is the one function you replace, and until you do, this module talks
// to a fake game that lives in this process. What is real is the shape: a
// declared timeout, an idempotency key that goes down the wire, a far end that
// executes a key at most once, a reply that says which of those two happened, and
// a call that answers rather than throwing. Those are the parts the event
// contract depends on, and simulating them is how the kit's CI can prove them at
// all — there is no game server on a runner.
//
// ── Why this file is not called `gameClient` ──────────────────────────────
//
// The website process never opens a connection to a game server (MODULE_API.md
// §2.7). It opens one to YOUR SIDECAR, which owns the socket to the game — see
// chapter 3. `test/noGameConnection.test.js` enforces the narrow, decidable half
// of that rule and its header names this exact filename as the one you allow when
// you replace `deliver()`:
//
// const MAY_OPEN_SOCKETS = new Set(['sidecarClient.js'])
//
// So the moment this file grows a real transport, that test fails correctly, and
// the fix is one line in a file whose whole job is to name what may reach the
// network. Do not delete the check to make it pass.
//
// ── TIMEOUT_MS is not a tuning knob. It is half of a rule. ────────────────
//
// An event action declares `budgetMs`, and core's dispatcher enforces it: when
// the budget expires it stops waiting and classifies the failure as **retry**,
// unconditionally, without asking the action — it cannot ask, the action is still
// awaiting a socket.
//
// So if core's deadline is shorter than this one, your action never gets to
// classify its own failure, and `{ ok: false, retry: false }` in your envelope is
// unreachable code. `budgetMs` must EXCEED the timeout of whatever the action
// talks to. This constant is exported so `config/eventActions.js` can be written
// against it rather than beside it, and so a test can assert the ordering — which
// it does, because the first module this project shipped got it the wrong way
// round and retried a verb it had explicitly refused.
//
// ── The at-most-once store belongs to the FAR end ─────────────────────────
//
// The simulation below keeps a map of keys it has already executed, and that map
// stands in for state on the game side, not for state here. A store on this side
// would be a module remembering what it sent, which answers nothing: the case
// that matters is the one where the command arrived, ran, and the acknowledgement
// was lost. Only the end that ran it can tell a retry from a repeat.
//
// Your sidecar and your plugin are where that store goes; chapter 4 is about
// building it. What this file owes the contract is narrower and is the thing
// modules get wrong: **pass the key through, unchanged, on every attempt.**
//
// ── `ask` and `send` are two functions because a key is not for a question ─
//
// This file offers `ask()` for a read and `send()` for a write, and the split is
// not tidiness — it is the correction that writing this template produced.
//
// The first draft had one function and every call carried a key, including the
// reads. That is wrong in a way that is quiet and total: the far end answers a
// key it has already executed with the ORIGINAL reply, so the second read of a
// value returns the first read's answer, and the third, and every one after it
// forever. The lease applied correctly, the game changed correctly, and this
// module could no longer see any of it — `read()` reported the baseline it had
// found before the run started and `inForce()` said nothing was held.
//
// **An idempotency key makes a COMMAND safe to repeat. It makes a QUESTION
// permanently stale.** Anything that only asks must go through `ask`.
//
// The rule for which commands need one is narrower than "all of them", too. A key
// is for a write whose repetition would be a second EFFECT — creating something,
// granting something, announcing something. 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. So the lease's `apply` and `restore` send no key, and the
// beacon verbs send core's.
const core = require('./core')
const log = core.logger('sidecar')
/**
* How long this client waits before giving up on the far end.
*
* Read the header. Every action in `config/eventActions.js` declares a `budgetMs`
* strictly greater than this, and `test/eventActions.test.js` asserts it.
*/
const TIMEOUT_MS = 12000
/** What a caller gets back. Shaped once so every call site reads the same. */
function reply(ok, status, data = null) {
return { ok, status, data }
}
/**
* Ask the game a question.
*
* **Never carries an idempotency key**, and the reason is the header's last
* section: a key would make the far end answer every future call with the first
* one's answer. A read is cheap to repeat and there is nothing to make safe.
*/
async function ask(command, payload = {}) {
return roundTrip(command, payload, null)
}
/**
* Tell the game to do something and wait for its answer.
*
* @param {string} command
* @param {object} payload
* @param {object} [options]
* @param {string} [options.idempotencyKey] core's key for this step. Pass it
* through unchanged on every attempt. Omit it only for a write that is
* idempotent by its own nature — setting a value to X.
*/
async function send(command, payload = {}, { idempotencyKey = null } = {}) {
return roundTrip(command, payload, idempotencyKey)
}
/**
* One round trip, with this client's own deadline on it.
*
* **Never throws.** A module that let a socket failure escape into core's dispatch
* would be handing core an exception where the contract asked for a verdict — and
* core would classify it as a retry, which is the safe default but not always the
* right one. Answer, and let the action decide.
*/
async function roundTrip(command, payload, idempotencyKey) {
let timer = null
try {
return await Promise.race([
deliver(command, payload, idempotencyKey),
new Promise((resolve) => {
timer = setTimeout(() => resolve(reply(false, 'timeout')), TIMEOUT_MS)
}),
])
} catch (err) {
// Everything the far end can do to us, reduced to one verdict. The status is
// the thing an action's `classify` reads; the stack goes to the log, where a
// human can find it, and never into a reply core would store.
log.error('command failed', { command, error: err.message })
return reply(false, 'transport-error')
} finally {
if (timer) clearTimeout(timer)
}
}
// ══════════════════════════════════════════════════════════════════════════
// Everything below this line is the FAKE GAME. Delete it, and make `deliver()`
// one request to your sidecar carrying `command`, `payload` and the key.
// ══════════════════════════════════════════════════════════════════════════
/**
* The far end's at-most-once store: key → the reply the first attempt produced.
*
* On the game side this is persisted, because the case it exists for is a restart
* mid-run. Here it is a Map, and losing it on restart is exactly what makes
* `bootId` below meaningful.
*/
const executed = new Map()
/** What the fake game currently holds. A restart resets both. */
let bootId = `boot-${Date.now()}`
let gatherRate = 1.0
const lit = new Set()
/**
* Stand-in for one round trip to your sidecar.
*
* **REPLACE THIS FUNCTION AND NOTHING ELSE.** Its contract is the whole of what
* the rest of this module assumes:
*
* • it resolves rather than rejecting, with `{ ok, status, data }`;
* • it is given the idempotency key and sends it unchanged;
* • a key it has already executed answers with the ORIGINAL reply, restamped —
* never by running the command again;
* • a key it is still working on answers `busy`, which is transient by
* construction: the work is happening.
*/
async function deliver(command, payload, idempotencyKey) {
// **The far end refuses an unkeyed command it cannot safely repeat.** This is
// the game side protecting itself rather than trusting every caller to have
// read the contract, and it is worth building: the module that forgets to pass
// the key is not punished on the first attempt, which succeeds, but on the
// retry six weeks later that makes a second set of everything.
if (CREATES.has(command) && !idempotencyKey) return reply(false, 'no-idempotency-key')
if (idempotencyKey && executed.has(idempotencyKey)) {
// The whole point. A retry of a command whose acknowledgement was lost
// collects the answer the first attempt never delivered, and the world is
// changed once. Note it is the same `data`, not a fresh execution: a repeat
// that re-ran and returned a NEW serial would be two creatures in the world
// and one in core's ledger.
return { ...executed.get(idempotencyKey), repeat: true }
}
const answer = execute(command, payload)
if (answer.ok && idempotencyKey) executed.set(idempotencyKey, answer)
return answer
}
/**
* The commands whose repetition would be a second effect.
*
* Everything else here either asks a question or sets a value, and both are
* idempotent without help. Your game's list is the verbs that CREATE, GRANT or
* ANNOUNCE — the ones where doing it twice is visible in the world.
*/
const CREATES = new Set(['beacon.light'])
/** The fake game's verbs. Yours are your game's, and none of them are these. */
function execute(command, payload) {
switch (command) {
case 'beacon.light': {
const refs = []
for (let i = 0; i < payload.count; i += 1) {
const ref = `beacon:${payload.clanId}:${lit.size + 1}`
lit.add(ref)
refs.push(ref)
}
return reply(true, 'ok', { refs, bootId })
}
case 'beacon.douse': {
// Dousing something that is not lit is a SUCCESS. See the revert rule in
// `config/eventActions.js`: core records a resource before it is confirmed,
// so cleanup will ask about things that may never have existed, and a
// module must never have to tell "I removed it" from "it was not there".
for (const ref of payload.refs || []) lit.delete(ref)
return reply(true, 'ok', {})
}
case 'beacon.inForce':
// Which of these does the game still have? Answered from live state, which
// is why a restart (`lit` empty again) reports honestly rather than
// repeating what the caller already believed.
return reply(true, 'ok', { refs: (payload.refs || []).filter((r) => lit.has(r)) })
case 'rate.gather.read':
return reply(true, 'ok', { value: gatherRate })
case 'rate.gather.apply':
// `until` arrives and the far end is responsible for it WITHOUT being asked
// again. A real plugin arms a timer that restores the baseline when the
// deadline passes, and re-arms it at load if the value is in the world save.
// A far end that treats `until` as advisory has produced a lease that
// outlives an outage, which is the one thing a lease exists to prevent.
gatherRate = payload.value
return reply(true, 'ok', { value: gatherRate, until: payload.until })
case 'rate.gather.restore':
gatherRate = payload.value
return reply(true, 'ok', { value: gatherRate })
default:
// An unknown command is the far end's judgement that this will never work,
// and it is the one status an action turns into `retry: false`.
return reply(false, 'unknown-command')
}
}
/**
* Pretend the game restarted. Test seam, and the only reason it is exported.
*
* A real module learns this from its sidecar — a boot id on the feed that changed,
* which is how you tell a game restart from a sidecar reconnect. `boot.js` is
* where that watch lives, and `ctx.events.reconcile()` is what it calls.
*/
function simulateRestart() {
bootId = `boot-${Date.now()}-${Math.random().toString(16).slice(2)}`
executed.clear()
lit.clear()
gatherRate = 1.0
return bootId
}
/** The boot id the far end is currently reporting. */
function currentBootId() {
return bootId
}
module.exports = { TIMEOUT_MS, ask, send, simulateRestart, currentBootId }