// ── 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 }