// ── 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 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 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) // `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 }) } } /** * 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 log.info('shut down') } module.exports = { onBoot, onShutdown, refresh, seedClans, REFRESH_MS }