Files
Integration-kit/template/server/boot.js
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

214 lines
9.7 KiB
JavaScript

// ── The lifecycle hooks ───────────────────────────────────────────────────
//
// `register()` may not touch the database (MODULE_API.md §2.2). This file is
// where everything it could not do goes.
//
// core schema → your schema fragment → onBoot(ctx) → the listener binds
//
// So by the time `onBoot` runs your tables exist, core's settings are seeded, and
// nothing is serving traffic yet. That last part is a guarantee you can rely on:
// a module that must warm a cache before its first request gets to.
//
// **`onBoot` has no timeout.** Shutdown races the process being killed; boot does
// not. A slow `onBoot` delays the listener, which is the promise above rather
// than a problem to be timed out.
//
// **If `onBoot` throws, the module is `startup_failed` and the site still comes
// up.** Your routes stay mounted but answer 503, because a module that failed to
// warm up serving half-initialised data is worse than one that says it is down.
// You then get NO `onShutdown` — you are part-way through a warm-up you never
// finished, and being handed a half-built world to tear down is worse than not
// closing cleanly.
//
// This is where a real module opens its sidecar connection. **The website process
// never opens a connection to a game server** — that is §2.7, contract as of
// MODULE_API 1.4.0, not advice. What you connect to here is your sidecar: a
// service you write, which owns the socket to the game, persists what the game
// says before forwarding it, and answers reads from that store. See the kit's
// chapter 3 for why that shape and not a shorter one.
const core = require('./core')
const worldStatusDb = require('./model/worldStatus/worldStatus.db')
const clanDb = require('./model/clans/clanProvider.db')
const sidecar = require('./sidecarClient')
const log = core.logger('boot')
// Whatever a real module would keep open — a sidecar WebSocket, a poll timer —
// is held here so `onShutdown` can close it. This template has one timer, purely
// so that there is something for the shutdown hook to actually do.
let refreshTimer = null
// The last boot id the game reported. `null` means "never observed", which is not
// the same as "changed" — see `checkForRestart`.
let lastBootId = null
const REFRESH_MS = 30 * 1000
/**
* Ask the game (in a real module: your sidecar) how it is doing, and store it.
*
* Isolated from the hooks so it is the one place a failure is handled: an
* unreachable game is expected, is not this module's fault, and must not become
* an unhandled rejection in core's process.
*/
async function refresh() {
try {
// A real module calls its sidecar's REST API here. Two hardcoded values
// stand in, so that the page renders and the seam is visible.
const next = { online: true, players: 0, worldName: 'Example World' }
// ── Emitting a declared event ──────────────────────────────────────────
//
// **Emit on the TRANSITION, not on the poll.** This function runs every
// thirty seconds; a rule on an event fired every thirty seconds is a rule
// that mails somebody every thirty seconds. Core has a cooldown and an
// hourly cap and they would both hold, but leaning on them means the module
// is emitting "the world is still up" and calling it news. Read the previous
// state, compare, and emit only when the answer changed.
//
// The read is BEFORE the write for the same reason, and getting that
// backwards is the easy version of this bug: after `setStatus` the previous
// value is gone and every poll looks like no change at all — an emitter that
// never fires and never errors.
const previous = await worldStatusDb.getStatus()
await worldStatusDb.setStatus(next)
// Same poll, different question: did the thing we lit beacons in restart?
checkForRestart()
// `previous === null` is the first boot on a fresh install, not a change.
// Treating it as one would announce the world coming online to everyone the
// first time an operator started the site.
if (previous && Boolean(previous.online) !== next.online) {
// Fire-and-forget: no await, no return value, nothing to handle. Core
// validates the payload against what `index.js` declared, and what a
// mismatch does depends on where you are running. **In production it is
// dropped and logged** against this module, because a notification must
// never be able to break the thing it is about. **Anywhere else it throws**,
// at this line, so the stack points at your own call instead of at a
// warning nobody reads. Neither is a condition to catch: a payload that
// does not match the contract you declared is a bug to fix.
core.emit('examplegame.world.status_changed', {
data: {
worldName: next.worldName,
status: next.online ? 'online' : 'offline',
players: next.players,
url: '/world',
},
})
}
} catch (err) {
log.warn('could not refresh world status', { error: err.message })
}
}
/**
* Notice that the game restarted, and tell core.
*
* **Core has no concept of the game being up.** It sees `{ ok: false, retry: true }`
* from a dispatch and cannot tell a wedged sidecar from a game that rebooted and
* lost every beacon an event lit. Only this module knows, because only this
* module watches the feed the boot id arrives on — which is also how you tell a
* game restart from a sidecar reconnect, and they are not the same event: the
* second loses nothing.
*
* So core asks once, at its own boot — the one reconnect it can see — and
* otherwise waits to be told. `core.reconcileEvents()` is being told. It returns
* at once and core sweeps its resource ledger on its own time, putting the
* question back to this module as `reconcile({ runId, resources })` in
* `config/eventActions.js`.
*
* Called from the same poll as everything else here, because a boot id is just
* another thing the feed carries. In a real module this is a frame handler rather
* than a comparison against a remembered value.
*/
function checkForRestart() {
const bootId = sidecar.currentBootId()
if (lastBootId === null) {
// First observation is not a restart. Recording it as one would ask core to
// reconcile every ledgered resource on every website deploy, which is a sweep
// that costs a round trip per action for no news.
lastBootId = bootId
return
}
if (bootId === lastBootId) return
lastBootId = bootId
log.info('game restarted, asking core to reconcile', { bootId })
core.reconcileEvents()
}
/**
* Two clans, so that the Team provider has something to be authoritative about.
*
* A real module fills these tables from its sidecar — the roster arriving on its
* own frames, separately from the clan itself. That separation is why
* `member_count` is written from what the game SAYS the size is rather than from
* the rows: the provider needs both numbers to tell an empty clan from one whose
* roster has not landed, and a seeder that derives the count from its own array
* quietly removes the case the provider's most important guard exists for.
*
* **Core is not called here and does not have to be.** Registration is a claim;
* core reconciles on its own schedule, after `onBoot`, by calling the provider.
* A module that tried to push Teams into core would be a module racing core's
* reconciler for a table it does not own.
*/
async function seedClans() {
try {
await clanDb.replaceClan({
externalId: 'clan-1', name: 'The Gilded Company', abbr: 'GC', memberCount: 3,
members: [
{ memberKey: 'char-001', displayName: 'Aldric', rankLabel: 'Warlord', leader: true, online: true },
{ memberKey: 'char-002', displayName: 'Bryn', rankLabel: 'Member', online: false },
{ memberKey: 'char-003', displayName: 'Cass', rankLabel: 'Member', online: true },
],
})
await clanDb.replaceClan({
externalId: 'clan-2', name: 'Ash and Ember', abbr: 'A&E', memberCount: 1,
members: [
{ memberKey: 'char-101', displayName: 'Dael', rankLabel: 'Warlord', leader: true, online: false },
],
})
} catch (err) {
log.warn('could not seed clans', { error: err.message })
}
}
/**
* Runs once, after the schema and before the listener binds.
*
* Receives the same frozen `ctx` `register()` was given — not a second object
* built to look like it — so a module that only needs core at boot time can skip
* `core.init` entirely and use this argument.
*/
async function onBoot() {
await refresh()
await seedClans()
refreshTimer = setInterval(refresh, REFRESH_MS)
// Node keeps the process alive for a pending timer. Core's own intervals are
// unref'd for exactly this reason: a module that forgets turns `Ctrl-C` into a
// thirty-second wait, and on a host it turns a `systemctl stop` into a SIGKILL.
if (typeof refreshTimer.unref === 'function') refreshTimer.unref()
log.info('booted', { refreshMs: REFRESH_MS })
}
/**
* Runs on SIGINT/SIGTERM, before core closes anything of its own.
*
* The database pool, the push dispatcher and the SSE fan-out are all still open,
* because flushing through them is the only thing this hook is for. There is a
* five-second budget per module, after which the hook is abandoned — abandoned
* rather than cancelled, since nothing can stop a promise that is still running.
* Close what you opened, flush what is buffered, and return.
*/
async function onShutdown() {
if (refreshTimer) clearInterval(refreshTimer)
refreshTimer = null
lastBootId = null
log.info('shut down')
}
module.exports = { onBoot, onShutdown, refresh, seedClans, checkForRestart, REFRESH_MS }