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