// ── What an event author can reach for ──────────────────────────────────── // // MODULE_API.md 1.10.0 and `website/EVENTS.md` §F. Four declarations, all // optional, and together they are how a scheduled event on the website reaches // into your game and comes back out again. // // **All of it is optional, and that is the contract's own posture, not a hedge.** // A deployment with none of this installed still has a working event engine: it // can announce, wait, cue a human and publish results, over core's own verbs. // What these four add is the ability for an event to reach the GAME. A module // that registers none of them costs its deployment a capability, never a boot. // // ── The order to read this file in ──────────────────────────────────────── // // A BUDGET names a resource dimension core can bound. An OPTION SOURCE answers a // dropdown on the authoring form. A LEASE is a value a run may BORROW, with a // deadline. An ACTION is a verb a run may perform, and what it makes it OWNS // until teardown. // // Four separate id spaces, each namespaced with your module id. `examplegame.beacons` // as a budget and `examplegame.beacon.light` as an action are not a collision; // reading them as one would forbid the most natural set of names you will ever write. // // ── Own versus borrow, and which one to build first ─────────────────────── // // This file declares one of each on purpose, and if you only have time for one, // **build the lease.** `EVENTS.md` §H is blunt about it: the lease is the // primitive that travels and object creation is the special case. "Double the // gather rate for the weekend" is the canonical community event in almost every // game — set a value, hold it, put it back — while spawning creatures at a // landmark is a shape one genre happens to have. A lease is also the cheaper // thing to make safe, because the value you are replacing already existed and // reading it first gives you a baseline for free. // // ── The four things that are invisible until an outage ──────────────────── // // Everything below is ordinary except four rules, and all four are the kind that // look like they are working right up until the day something is down. They are // marked TRAP 1..4 where they bite. In short: // // 1. **No shape a failure can take reads as success**, and `retry: true` is the // default — so `budgetMs` must EXCEED your transport's own timeout or your // own `retry: false` is unreachable code. The reason a refusal gives goes in // `error`; core reads no other name. // 2. **Pass `idempotencyKey` through, unchanged, on every attempt** — and put // it on a COMMAND, never on a question. It is the only thing standing // between a retry and a second world change, and the only thing that can // make a read permanently stale. // 3. **Core records a resource BEFORE it is confirmed**, so `revert` will be // called about things that may never have existed — and about nothing at // all, with only a key. // 4. **`cost` is priced before dispatch and never reconciled against what came // back**, so an action that under-declares turns every cap into a lie. const core = require('../core') const sidecar = require('../sidecarClient') const clanDb = require('../model/clans/clanProvider.db') const log = core.logger('events') // ── TRAP 1 ──────────────────────────────────────────────────────────────── // // Core's dispatcher enforces `budgetMs`. When it expires the dispatcher stops // waiting and classifies the failure as **retry**, unconditionally, without // asking the action — it cannot ask, the action is still awaiting a socket. // // So an action whose own client gives up AFTER core's deadline never gets to // classify its own failure, and every `retry: false` it might return is // unreachable code. Core's default `budgetMs` is 10s; this module's client waits // 12s; on the default the deadline would fire first on every slow game and the // step would be retried by core no matter what this file says. // // Hence: strictly greater than `sidecar.TIMEOUT_MS`, derived from it rather than // typed beside it, and asserted in `test/eventActions.test.js`. Deriving it is // the part worth copying — a constant typed twice drifts the first time somebody // tunes the client and does not think to look here. const BUDGET_MS = sidecar.TIMEOUT_MS + 3000 // How many beacons one step may ask for. A bound in the module, in front of the // operator's cap rather than instead of it: this one is what the GAME can stand, // and the cap is what this deployment allows. Pre-checking here is what lets a // dry run show an author the refusal rather than a run meeting it at 3am. const MAX_BEACONS = 25 // Statuses the far end uses to mean "this will never work". Everything else — // including a timeout, a transport error and anything unrecognised — is left to // the default, which is a retry. That direction is deliberate: see the envelope // note on `classify` below. const PERMANENT = new Set(['unknown-command', 'no-idempotency-key']) /** * One place that turns a client reply into an envelope core understands. * * Worth having as a function even with two callers. The rule it encodes — * "unrecognised means retry" — is the one you want stated once, because the * failure mode of getting it wrong per-action is a verb that quietly stops * retrying and nobody notices until a shard reboots mid-event. */ function classify(answer) { // **The field is `error`, not `detail`.** Core's dispatcher reads exactly two // things off a failure envelope — `ok` and `retry` — and passes `error` // through as the message an operator sees on the run console and an author // sees on a dry run. Anything under another name is dropped in silence, so an // action that puts its reason in `detail` produces a refusal that reads // " refused" and tells nobody why. Writing this template is how // that was found: `EVENTS.md` §H names a `detail` member in passing and core // has never read one. return { ok: false, retry: !PERMANENT.has(answer.status), error: answer.status } } // ══ BUDGETS ═══════════════════════════════════════════════════════════════ // // A dimension core can count and bound. Core never learns what a beacon is: it // holds `{ dimension, consumed, cap }` and the vocabulary stays here. That is the // whole of what makes the engine game-agnostic at this seam. // // **Declaring a dimension is not the same as bounding it.** A declared dimension // with no operator cap is counted and unbounded — which is useful on its own, // because the run console then shows an author what their event actually spent. const BUDGETS = [ { id: 'examplegame.beacons', label: 'Beacons lit', unit: 'count' }, ] // ══ OPTION SOURCES ════════════════════════════════════════════════════════ // // What a dropdown on the authoring form is filled from. A fourth registration // rather than a field on the action, because a catalog usually has more than one // consumer — this one answers both the action's `clanId` param and the lease's // target would, if the lease were targeted — and two actions declaring it // separately would be two allowlists that can disagree. // // **A source that refuses degrades its field to free text with a warning.** It // never blocks the form and it never raises, so this resolver may read the // database and may fail. Do not defend against that by returning a hardcoded // list; an empty answer with a log line is more honest than a stale one. const OPTION_SOURCES = [ { id: 'examplegame.options.clans', label: 'Clans', // Core passes `q` to EVERY source and requires it of none, so a resolver // written before search existed behaves identically. Declare // `searchable: true` when the term actually narrows the answer — the form // reads it to choose between a typeahead and a select. Do not infer it from // the length of the list: that reads correctly right up until a small // deployment's list happens to fit in a dropdown. async resolve() { try { const clans = await clanDb.listClans() return clans.map((c) => ({ value: c.externalId, label: c.name })) } catch (err) { log.warn('option source failed', { source: 'examplegame.options.clans', error: err.message }) return [] } }, }, ] // ══ LEASES ════════════════════════════════════════════════════════════════ // // A value a run BORROWS and gives back. The module declares what can be held and // how long; **the verb is core's** — an author puts `core.lease` in a step, and // core reads the baseline, reserves the target in its resource ledger, applies // the value with a deadline, and restores it at teardown through `restore()` // below. A lease verb of your own would be that duration bound and that // two-events-one-target check re-implemented once per module, advisory // everywhere, and wrong in the first one that forgot it. // // **Only advertise a lease you have verified takes effect.** A value your game // reads once at start-up and caches will apply cleanly, read back cleanly and do // nothing — a capability that lies, which no amount of core-side checking can // catch. Apply it, observe it, restore it, as a test, per key. const LEASES = [ { id: 'examplegame.rate.gather', label: 'Gather rate', type: 'float', min: 0.5, max: 5, // The longest core will let a run hold it. A weekend, here. The bound is // core's to enforce and yours to choose, and it should be the longest you // would be comfortable finding still applied after everything else broke. maxDurationMs: 48 * 60 * 60 * 1000, /** The baseline, read live. Core stores what this answers and restores to it. */ async read() { // `ask`, not `send`. A read carrying an idempotency key would be answered // with the FIRST read's value forever — see `sidecarClient.js`'s header. const answer = await sidecar.ask('rate.gather.read') return answer.ok ? { ok: true, value: answer.data.value } : classify(answer) }, /** * Hold the value until `until`. * * **`until` goes down the wire and the far end honours it without being asked * again.** Core's copy of the deadline is for the console; the game's copy is * the fail-safe. A module that passes it and then relies on core to come back * and restore has built a lease that outlives an outage — which is the one * thing a lease exists to prevent. */ async apply(value, until) { // No idempotency key, and that is deliberate rather than an omission: // setting a value to X twice is setting it to X. A key here would buy // nothing and cost the reply's freshness. const answer = await sidecar.send('rate.gather.apply', { value, until: until instanceof Date ? until.toISOString() : until, }) return answer.ok ? { ok: true } : classify(answer) }, /** * Put it back. * * `expected` is what core believes is currently applied. Answering that the * live value differs is how a lease lands `drifted` with the current value * beside it, rather than core silently overwriting whatever a human changed * mid-event. Restoring must be idempotent for the same reason `revert` must: * core may ask more than once. */ async restore(baseline, { expected } = {}) { const live = await sidecar.ask('rate.gather.read') if (!live.ok) return classify(live) if (expected !== undefined && Number(live.data.value) !== Number(expected)) { return { ok: true, drifted: true, value: live.data.value } } const answer = await sidecar.send('rate.gather.restore', { value: baseline }) return answer.ok ? { ok: true } : classify(answer) }, /** * A FOURTH question, not a fourth spelling of `read()`. * * "Does the game side still have any record of this hold?" A value that * DIFFERS from what the run applied is drift, which `restore()` reports; a * reconcile that inferred absence from a changed value would take the row out * and tell an operator the lease vanished rather than that somebody moved it. * * Optional, and answering `{ ok: true, held: false }` is the only thing that * takes a lease's ledger row out. Everything else — a throw, a refusal, no * `inForce` at all — leaves the row alone, which is the same * "I do not know is never it is gone" rule the actions below follow. */ async inForce() { const live = await sidecar.ask('rate.gather.read') if (!live.ok) return classify(live) return { ok: true, held: Number(live.data.value) !== 1.0 } }, }, ] // ══ ACTIONS ═══════════════════════════════════════════════════════════════ const ACTIONS = [ { id: 'examplegame.beacon.light', label: 'Light beacons', description: "Lights signal beacons at a clan's hall for the length of this event.", // Both are closed sets core interprets, and neither is decoration: `risk` // decides which role may put this in a step and whether it is off by default, // and `reversible` decides whether core will ever call `revert`. risk: 'change', // notify | inspect | change | irreversible reversible: 'ledger', // none | self | ledger | override version: 1, budgetMs: BUDGET_MS, // TRAP 1 — see the constant // ── TRAP 4 ────────────────────────────────────────────────────────── // // What ONE invocation consumes. A function, because it depends on the params. // // **Core prices this BEFORE dispatch and never reconciles it against what // came back.** There is no check that the `resources` you return match what // you said you would spend — there cannot be, since core does not know what a // beacon is. So an action that returns `{ 'examplegame.beacons': 1 }` while // lighting twelve turns an operator's cap of 30 into a cap of 360, and the // meter on the run console agrees with the lie. Nothing goes red. The first // symptom is a world with an order of magnitude more in it than anyone // authorised. // // Count what you will actually make, from the params you were given, every // time. If you cannot know until the answer comes back, declare the maximum: // a spend that is too high refuses an event that would have fit, which an // author can see and argue with, and one that is too low cannot be seen at all. // // A `cost()` naming a dimension no module declared is REFUSED — at save, at // the dry run and at dispatch — because the fix is a module's declaration and // not a deployment's cap. cost: (p) => ({ 'examplegame.beacons': Number(p.count) || 0 }), params: [ { name: 'clanId', type: 'string', required: true, // `example` is required on every param, optional ones included. It is the // authoring form's placeholder, it is one word at declaration time, and // it is unreconstructable afterwards by anybody who did not write the action. example: 'clan-1', source: 'examplegame.options.clans', }, { name: 'count', type: 'int', required: true, example: 6 }, ], /** * Do it. * * @param {object} env * @param {string} env.runId * @param {string} env.stepId * @param {string} env.idempotencyKey a function of identity, never of attempt * @param {*} env.scope opaque to core; may be null * @param {object} env.params * @param {object} env.actor * @param {boolean} env.verify dry run: validate, change NOTHING */ async perform({ idempotencyKey, params, verify }) { const count = Number(params.count) if (!Number.isInteger(count) || count < 1 || count > MAX_BEACONS) { // A refusal the second attempt would repeat verbatim, so `retry: false`. // This is the arm TRAP 1 exists to keep reachable. return { ok: false, retry: false, error: `count must be 1..${MAX_BEACONS}` } } // **`verify` must change nothing and must answer honestly.** It rides this // same dispatcher a real run uses, because a dry run down a second code // path is a dry run of the second path. Validate everything you can reach // without writing — the params above, and a lookup below — then stop. if (verify) { const clan = await clanDb.findClan(params.clanId) return clan ? { ok: true } : { ok: false, retry: false, error: `no such clan: ${params.clanId}` } } // ── TRAP 2 ──────────────────────────────────────────────────────── // // The key goes through, unchanged. Core derives it from the step's identity // and never from the attempt number, so every retry carries the same one — // and the far end, which is the only end that can tell a retry from a // repeat, answers a key it already executed with the ORIGINAL reply rather // than running it again. // // A module that generates its own key here, or drops it, has an action that // cannot be retried safely, and the cost of that is not a failed step: it // is a second world change on a socket hiccup. It looks like it works in // every test, because in every test the first attempt succeeds. const answer = await sidecar.send( 'beacon.light', { clanId: params.clanId, count }, { idempotencyKey }, ) if (!answer.ok) return classify(answer) // What core writes into its ledger. `kind` is yours; `ref` is whatever you // will need to undo it. The boot stamp rides along because `reconcile` // below is the only thing that reads it — see its note. return { ok: true, resources: answer.data.refs.map((ref) => ({ kind: 'beacon', ref, meta: { bootId: answer.data.bootId }, })), } }, /** * Undo it. **Required, because `reversible` is `'ledger'`.** * * Called by core's cleanup sweep at teardown, over the rows this action's * `resources` produced — a LIST, so twelve beacons are one round trip rather * than twelve. Cleanup is derived rather than authored: there is no * `on_teardown` and no cleanup phase in a spec, because an operator cannot be * relied on to write the undo and an aborted run never reaches the phase they * wrote it in. It runs on every terminal path — completion, cancellation and * abort alike. * * ── TRAP 3 ────────────────────────────────────────────────────────── * * Two things follow from `EVENTS.md` §D rule 1, *core records a resource * BEFORE it is confirmed*: * * • **Reverting something that does not exist is a SUCCESS.** A dispatch * whose answer was lost leaves a ledger row for something that may never * have existed, and cleanup will ask about it. You must never have to * tell "I removed it" from "it was not there" — and you could not, because * the far end cannot either. Answer `{ ok: true }`. * * • **You will be called with NO resources and only a key.** That is the * lost-answer case stated exactly: core knows a dispatch went out under * this key and never learned what it made. A module that can undo by key * answers honestly. One that cannot answers `{ ok: false }`, and the row * stays visible to an operator — which is the correct outcome, not a * silent one. Answering `{ ok: true }` to a question you cannot answer is * how a beacon burns forever with core's ledger reporting it cleaned up. * * And it must be idempotent, because core may ask more than once. */ async revert({ resources, idempotencyKey }) { const refs = (resources || []).map((r) => r.ref).filter(Boolean) if (refs.length === 0) { // The lost-answer case. This module CAN answer it, because the far end // stores what each key produced — so asking it to undo the key is a real // question with a real answer. If yours cannot, say `{ ok: false }` here // and let a human see the row. const byKey = await sidecar.send('beacon.douse', { refs: [] }, { idempotencyKey }) return byKey.ok ? { ok: true } : classify(byKey) } const answer = await sidecar.send('beacon.douse', { refs }, { idempotencyKey }) if (!answer.ok) return classify(answer) // `{ ok: true }` reverts the whole group. Name the ones that did not come // back in `failed: [...]` and core keeps exactly those rows. return { ok: true } }, /** * Which of these does the game still have? **Optional.** * * Asked after something outside core restarted — core's own boot, or this * module calling `core.reconcileEvents()` because it saw the boot id change. * * The asymmetry with `revert` is the design: a module that cannot say what the * game still has is not broken, and core keeps believing its own ledger. One * that created something and cannot undo it has made a promise core has no way * to keep. So `revert` is required and this is not. * * **Anything that is not an explicit `{ ok: true, inForce: [...] }` leaves the * ledger alone.** "I do not know" is never read as "it is gone", and a * resource reported missing becomes `orphaned` rather than `reverted` — * because nobody asked for it to go. */ async reconcile({ resources }) { const refs = (resources || []).map((r) => r.ref).filter(Boolean) // A question, so `ask`. Keying this one would have pinned the answer to // whatever was in force the first time core ever swept — which is the exact // opposite of what a reconcile is for. const answer = await sidecar.ask('beacon.inForce', { refs }) if (!answer.ok) return classify(answer) return { ok: true, inForce: answer.data.refs } }, }, ] module.exports = { BUDGETS, OPTION_SOURCES, LEASES, ACTIONS, BUDGET_MS, MAX_BEACONS, classify }