// Engine regression checks. Run from the repo root: node tools/engine-check.mjs import fs from "node:fs"; import { fileURLToPath } from "node:url"; import assert from "node:assert/strict"; import * as E from "../web/js/engine/index.js"; import { encodeSave, decodeSave, crc32, MIGRATIONS } from "../web/js/save/codec.js"; import { createStorage, writeSave, readSave, loadSettings, saveSettings, KEYS } from "../web/js/save/storage.js"; const root = fileURLToPath(new URL("../web/data/", import.meta.url)); const bal = JSON.parse(fs.readFileSync(root + "balance.json")); const scn = JSON.parse(fs.readFileSync(root + "scenarios/default.json")); const fresh = (seed = 7) => E.createGame({ scenario: scn, balance: bal, seed, pronouns: "they" }); const open = (s) => { E.confirmBudget(s); E.clearPending(s, "weeklyCard"); }; const run = (s, n) => { for (let i = 0; i < n; i++) { if (s.pending.review) E.confirmBudget(s); if (s.pending.weeklyCard) E.clearPending(s, "weeklyCard"); E.advanceDay(s, bal); } }; // 1. blocked while pending let s = fresh(); assert.equal(E.advanceDay(s, bal).blocked, "pending"); open(s); assert.equal(E.advanceDay(s, bal).advanced, true); assert.equal(s.day, 1); // 2. determinism + JSON round trip mid-game let a = fresh(), b = fresh(); run(a, 200); run(b, 100); b = JSON.parse(JSON.stringify(b)); run(b, 100); assert.deepEqual(a, b); // 3. event cadence s = fresh(); open(s); const evs = []; for (let i = 0; i < 200; i++) { const r = E.advanceDay(s, bal); if (r.events.weeklyCard) E.clearPending(s, "weeklyCard"); if (r.events.review) { evs.push(s.day); E.confirmBudget(s); } } assert.deepEqual(evs, [91, 182]); // 4. budget lock + validation s = fresh(); E.confirmBudget(s); assert.deepEqual(E.setBudget(s, bal, { alloc: { police: 2 } }).errors, ["budget_locked"]); s = fresh(); assert.equal(E.setBudget(s, bal, { alloc: { police: 999 } }).ok, false); assert.equal(E.setBudget(s, bal, { alloc: { nope: 1 } }).ok, false); assert.equal(E.setBudget(s, bal, { taxes: { vat: -1 } }).ok, false); assert.equal(s.sectors.police.alloc, 1.2); // nothing applied on failure assert.equal(E.setBudget(s, bal, { alloc: { police: 2 }, taxes: { vat: 22 } }).ok, true); // 5. effects s = fresh(); open(s); const op0 = s.indicators.opinion; E.applyEffect(s, bal, { stat: "opinion", delta: 5 }); assert.equal(s.indicators.opinion, op0 + 5); E.applyEffect(s, bal, { stat: "opinion", delta: 100 }); assert.equal(s.indicators.opinion, 100); // clamped E.applyEffect(s, bal, { stat: "growth", delta: -1, days: 10 }); assert.equal(s.modifiers.length, 1); run(s, 10); assert.equal(s.modifiers.length, 0); const d0 = s.economy.debt; E.applyEffect(s, bal, { oneOff: 2 }); assert.ok(Math.abs(s.economy.debt - d0 - 0.02 * s.economy.gdp) < 1e-9); E.applyEffect(s, bal, { revenue: "income", delta: 3 }); assert.equal(s.economy.taxes.income, 33); assert.equal(E.applyEffect(s, bal, { stat: "bogus", delta: 1 }), false); // 6. bias lasts exactly `days` and moves the target s = fresh(); open(s); E.applyEffect(s, bal, { stat: "opinion", delta: 15, bias: true, days: 60 }); run(s, 59); assert.equal(s.modifiers.length, 1); run(s, 1); assert.equal(s.modifiers.length, 0); // 7. term end s = fresh(); run(s, 2000); assert.equal(s.over.type, "term"); assert.equal(s.day, bal.time.termDays); assert.equal(E.advanceDay(s, bal).blocked, "over"); // 8. real-time clock: 1 day = msPerDay / speed, frame time is capped at 1 s { const tick = (speed, frames, dt = 1000) => { const c = E.createClock(); let d = 0; for (let i = 0; i < frames; i++) d += E.consumeClock(c, dt, bal.time.msPerDay, speed); return d; }; assert.equal(tick(1, 9), 0); assert.equal(tick(1, 10), 1); assert.equal(tick(4, 10), 4); // 10 s at 4x = 4 days assert.equal(tick(1, 1, 60000), 0); // a huge frame (tab was hidden) is capped, no fast-forward { const c = E.createClock(); assert.equal(E.consumeClock(c, -25, bal.time.msPerDay, 1), 0); assert.equal(c.acc, 0, "a negative first frame must not pre-load a day"); } assert.equal(E.setSpeed(fresh(), bal, 3), false); assert.equal(E.setSpeed(fresh(), bal, 4), true); } // 9. budget preview { s = fresh(); // day-0 review is open const before = JSON.stringify(s); const same = E.previewBudget(s, bal, {}); assert.equal(JSON.stringify(s), before, "preview must not touch the real state"); assert.equal(same.ok, true); assert.equal(same.points.length, 5); assert.equal(same.points[4].day, 364); // matches a real noise-free run of the same year const quiet = structuredClone(bal); quiet.noise.indicator = 0; for (const d of Object.values(quiet.vars)) d.noise = 0; const real = fresh(); E.confirmBudget(real); for (let i = 0; i < 364; i++) { E.clearPending(real, "weeklyCard"); if (real.pending.review) E.confirmBudget(real); E.advanceDay(real, quiet); } assert.ok(Math.abs(real.economy.debt / real.economy.gdp * 100 - same.points[4].debtRatio) < 1e-9); // a spending draft shows a bigger deficit and more debt than the status quo const big = E.previewBudget(s, bal, { alloc: { police: 3.0, army: 5.0 } }); assert.ok(big.fiscal.deficit > same.fiscal.deficit + 4); assert.ok(big.points[4].debtRatio > same.points[4].debtRatio); // invalid drafts are rejected, outside a review too assert.equal(E.previewBudget(s, bal, { alloc: { police: 999 } }).ok, false); E.confirmBudget(s); assert.equal(E.previewBudget(s, bal, { taxes: { vat: 25 } }).ok, true); assert.equal(s.pending.review, false); // fast enough to run while a slider is dragged const t0 = performance.now(); for (let i = 0; i < 20; i++) E.previewBudget(s, bal, { alloc: { police: 1 + i / 20 } }); const ms = (performance.now() - t0) / 20; console.log("preview ms:", ms.toFixed(1)); assert.ok(ms < 60); } // 10. cards { const mk = (id, extra = {}) => ({ id, category: "economy", doc: "memo", sender: "min_finance", title: { en: id, fr: id }, body: { en: "b", fr: "b" }, yes: { label: { en: "y", fr: "o" }, effects: [] }, no: { label: { en: "n", fr: "n" }, effects: [] }, ...extra, }); const week = (g, cards, choice) => { // advance to the next weekly pause, draw and answer for (let i = 0; i < 20 && !g.pending.weeklyCard; i++) { if (g.pending.review) E.confirmBudget(g); E.advanceDay(g, bal); } const card = E.ensureCard(g, bal, cards); if (card) E.answerCard(g, bal, cards, choice); return card; }; // once-only cards come up once; conditions gate eligibility let cards = [mk("a"), mk("b", { conditions: { min: { unrest: 99 } } })]; let g = fresh(1); E.confirmBudget(g); assert.equal(week(g, cards, "yes").id, "a"); assert.equal(week(g, cards, "yes"), null, "card b is gated, card a is used up"); assert.equal(g.pending.weeklyCard, false, "an empty week must not block the game"); // a repeatable card respects its cooldown cards = [mk("r", { once: false, cooldownDays: 21 })]; g = fresh(2); E.confirmBudget(g); const seen = []; for (let i = 0; i < 8; i++) { const c = week(g, cards, "no"); seen.push(c ? g.day : null); } const days = seen.filter((d) => d !== null); assert.ok(days.length >= 2 && days.every((d, i) => i === 0 || d - days[i - 1] >= 21)); // flags, effects and follow-ups (a due follow-up beats a random draw) cards = [ mk("start", { yes: { label: { en: "y", fr: "y" }, effects: [{ stat: "opinion", delta: 5 }, { revenue: "income", delta: 2 }], setFlags: ["f"], followUp: { id: "later", inDays: 10 } }, }), mk("later", { followUpOnly: true, conditions: { flags: ["f"] } }), mk("filler", { once: false, cooldownDays: 1 }), ]; g = fresh(3); E.confirmBudget(g); const op = g.indicators.opinion; for (let i = 0; i < 10 && g.inbox[0]?.id !== "start"; i++) { if (g.pending.weeklyCard) E.ensureCard(g, bal, [cards[0]]); if (!g.inbox.length) E.advanceDay(g, bal); } assert.equal(g.inbox[0].id, "start"); E.answerCard(g, bal, cards, "yes"); assert.equal(g.indicators.opinion, op + 5); assert.equal(g.economy.taxes.income, 32); assert.ok(g.flags.f !== undefined); assert.equal(g.followUps.length, 1); assert.equal(g.cardHistory[0].id, "start"); let order = []; for (let i = 0; i < 6; i++) { const c = week(g, cards, "no"); if (c) order.push(c.id); } assert.ok(order.includes("later"), "follow-up was drawn"); assert.equal(order.filter((x) => x === "later").length, 1); assert.equal(E.answerCard(g, bal, cards, "yes").ok, false, "nothing to answer"); // the real card files: every draw is valid, and a full term never throws const dir = fileURLToPath(new URL("../web/data/cards/", import.meta.url)); const files = JSON.parse(fs.readFileSync(dir + "index.json")).files; const real = files.flatMap((f) => JSON.parse(fs.readFileSync(dir + f))); const play = (seed) => { const gg = fresh(seed); const drawn = new Set(); for (let guard = 0; guard < 6000 && !gg.over; guard++) { if (gg.pending.review) E.confirmBudget(gg); while (gg.pending.alert) E.dismissAlert(gg); if (gg.pending.weeklyCard) { const card = E.ensureCard(gg, bal, real); if (card) { drawn.add(card.id); E.answerCard(gg, bal, real, E.nextFloat(gg) < 0.5 ? "yes" : "no"); } } E.advanceDay(gg, bal); } return { gg, drawn }; }; const a = play(11), b = play(11); assert.ok(a.gg.over, "random play ends one way or another (the term, or a fall: passive play is risky since M8)"); assert.deepEqual(a.gg, b.gg, "same seed and answers give the same game"); assert.ok(a.drawn.size >= 40, "most of the pool is seen in a run: " + a.drawn.size); assert.equal(a.gg.cardHistory.length, a.gg.cardHistory.filter((h) => real.some((c) => c.id === h.id)).length); console.log("cards in pool:", real.length, "| drawn in a run:", a.drawn.size, "| answered:", a.gg.cardHistory.length); } // 11. profile, game over, alerts and the end-of-term review { // the pronoun question pauses the game until answered let g = E.createGame({ scenario: scn, balance: bal, seed: 3 }); assert.equal(g.pending.profile, true); assert.equal(g.pronouns, "they", "generic default"); E.confirmBudget(g); E.clearPending(g, "weeklyCard"); assert.equal(E.advanceDay(g, bal).blocked, "pending", "profile still pending"); assert.equal(E.setPronouns(g, "xyz"), false); assert.equal(E.setPronouns(g, "she"), true); assert.equal(g.pronouns, "she"); assert.equal(E.advanceDay(g, bal).advanced, true); // helper: a game past all the day-0 pauses const ready = (seed) => { const x = fresh(seed); E.confirmBudget(x); E.clearPending(x, "weeklyCard"); return x; }; // helper: advance one day, clearing whatever pauses appear, optionally forcing indicators first const step = (x, force = {}) => { Object.assign(x.indicators, force); for (const k of ["weeklyCard", "review"]) x.pending[k] = false; return E.advanceDay(x, bal); }; // sovereign default: immediate, with the lenders' telegram queued g = ready(4); g.economy.debt = g.economy.gdp * 2.05; step(g); assert.equal(g.over.type, "default"); assert.ok(g.alerts.some((a) => a.kind === "default")); // impeachment: certain after 60 days in the zone, and the alert comes on day one g = ready(5); let days = 0; for (; days < 80 && !g.over; days++) { while (g.pending.alert) E.dismissAlert(g); step(g, { civilRights: 3, opinion: 10 }); } assert.equal(g.over?.type, "impeachment"); assert.equal(days, 60, "exactly the configured number of days"); // leaving the zone resets impeachment and fades the other two g = ready(6); for (let i = 0; i < 20; i++) { while (g.pending.alert) E.dismissAlert(g); step(g, { civilRights: 3, opinion: 10 }); } assert.equal(g.risk.impeachment, 20); step(g, { civilRights: 60, opinion: 60, unrest: 20 }); assert.equal(g.risk.impeachment, 0); // coup and revolution: a growing daily chance, so they happen, but not on day one for (const [name, zone] of [["coup", { armyLoyalty: 5, stability: 5 }], ["revolution", { unrest: 98, opinion: 3 }]]) { g = ready(7); let n = 0; for (; n < 300 && !g.over; n++) { while (g.pending.alert) E.dismissAlert(g); step(g, zone); } assert.equal(g.over?.type, name); assert.ok(n > 1 && n < 120, name + " came after " + n + " days"); const g2 = ready(7); let n2 = 0; for (; n2 < 300 && !g2.over; n2++) { while (g2.pending.alert) E.dismissAlert(g2); step(g2, zone); } assert.equal(n, n2, "same seed, same fall"); } // an alert blocks time until it is dismissed g = ready(8); step(g, { civilRights: 3, opinion: 10 }); assert.equal(g.pending.alert, true); assert.equal(g.alerts[0].kind, "impeachment"); assert.equal(E.advanceDay(g, bal).blocked, "pending"); E.dismissAlert(g); assert.equal(g.pending.alert, false); // decision impact ranking assert.equal(E.optionImpact({ effects: [{ stat: "opinion", delta: -5 }] }), 5); assert.equal(E.optionImpact({ effects: [{ stat: "opinion", delta: 2, bias: true, days: 180 }] }), 4); assert.equal(E.optionImpact({ effects: [{ oneOff: 1 }, { revenue: "income", delta: 3 }] }), 11); assert.equal(E.optionImpact({}), 0); // the review, with the real cards and titles const rdir = fileURLToPath(new URL("../web/data/", import.meta.url)); const endgame = JSON.parse(fs.readFileSync(rdir + "endgame.json")); const files = JSON.parse(fs.readFileSync(rdir + "cards/index.json")).files; const real = files.flatMap((f) => JSON.parse(fs.readFileSync(rdir + "cards/" + f))); const run = (seed, review) => { const x = fresh(seed); for (let guard = 0; guard < 6000 && !x.over; guard++) { if (x.pending.review) { if (review) E.setBudget(x, bal, review(x)); E.confirmBudget(x); } while (x.pending.alert) E.dismissAlert(x); if (x.pending.weeklyCard) { const c = E.ensureCard(x, bal, real); if (c) E.answerCard(x, bal, real, E.nextFloat(x) < 0.5 ? "yes" : "no"); } E.advanceDay(x, bal); } return x; }; const calm = run(21); assert.equal(calm.over.type, "term"); const rv = E.buildReview(calm, bal, real, endgame); assert.ok(rv.score > 20 && rv.score < 80, "a hands-off term scores in range (low since M8): " + rv.score); assert.ok(["S", "A", "B", "C", "D", "F"].includes(rv.grade)); assert.ok(endgame.titles.some((x) => x.id === rv.titleId && !x.over), "a term title"); assert.ok(rv.topDecisions.length > 0 && rv.topDecisions.length <= 5); assert.ok(rv.topDecisions.every((d, i, a) => i === 0 || a[i - 1].impact >= d.impact), "sorted by impact"); assert.deepEqual(E.buildReview(calm, bal, real, endgame), rv, "pure and repeatable"); const ruin = run(22, (x) => ({ alloc: Object.fromEntries(Object.entries(x.sectors).map(([id, s]) => [id, s.need * 2])), taxes: { income: 15, corporate: 12, vat: 10 } })); assert.equal(ruin.over.type, "default"); const rr = E.buildReview(ruin, bal, real, endgame); assert.equal(rr.grade, "F"); assert.equal(rr.titleId, "defaulted"); assert.ok(rr.score <= bal.score.overCap); console.log("review:", rv.grade, Math.round(rv.score), rv.titleId, "| ruin ends day", ruin.over.day); } // 12. saves: codec, validation, storage { const sdir = fileURLToPath(new URL("../web/data/", import.meta.url)); const cardsAll = JSON.parse(fs.readFileSync(sdir + "cards/index.json")).files.flatMap((f) => JSON.parse(fs.readFileSync(sdir + "cards/" + f))); const scenarios = { default: scn }; const content = { scenarios, balance: bal, cards: cardsAll }; const clone = (x) => JSON.parse(JSON.stringify(x)); // a game with some history: budget set, cards answered, an alert waiting const play = (g, days) => { for (let guard = 0; guard < days * 4 && g.day < days && !g.over; guard++) { if (g.pending.review) E.confirmBudget(g); while (g.pending.alert) E.dismissAlert(g); if (g.pending.weeklyCard) { const c = E.ensureCard(g, bal, cardsAll); if (c) E.answerCard(g, bal, cardsAll, E.nextFloat(g) < 0.5 ? "yes" : "no"); } E.advanceDay(g, bal); } return g; }; const g = play(fresh(31), 200); const text = encodeSave(g); // codec round trip, whitespace tolerance, the CRC reference value assert.ok(text.startsWith("CG1:")); assert.deepEqual(decodeSave(text).state, clone(g)); assert.deepEqual(decodeSave(" " + text.replace(/(.{76})/g, "$1\n ") + "\n").state, clone(g)); assert.equal(crc32(new TextEncoder().encode("123456789")), 0xcbf43926); assert.equal(encodeSave({ a: "é✓" }).length > 10, true); assert.deepEqual(decodeSave(encodeSave({ version: 1, note: "é✓ 日本" })).state.note, "é✓ 日本"); // damage is detected, never thrown const flip = (s, i) => s.slice(0, i) + (s[i] === "A" ? "B" : "A") + s.slice(i + 1); assert.equal(decodeSave(flip(text, 200)).error, "checksum"); assert.equal(decodeSave(text.slice(0, text.length - 40)).error, "checksum"); assert.equal(decodeSave("hello").error, "format"); assert.equal(decodeSave("CG1:zzzz:abcd").error, "format"); assert.equal(decodeSave("").error, "format"); assert.equal(decodeSave(text.replace("CG1:", "CG2:")).error, "format"); // versions: newer is refused, older is migrated step by step const tooNew = encodeSave({ ...clone(g), version: 99 }); assert.equal(decodeSave(tooNew).error, "tooNew"); const old = encodeSave({ ...clone(g), version: 0 }); assert.equal(decodeSave(old).error, "version", "no migration registered"); MIGRATIONS[0] = (s) => ({ ...s, version: 1, migrated: true }); assert.equal(decodeSave(old).state.migrated, true); delete MIGRATIONS[0]; // validation accepts a real save and resumes the exact same game const loaded = E.restoreState(decodeSave(text).state, content); assert.equal(loaded.ok, true); const a = play(clone(g), 330), b = play(loaded.state, 330); assert.deepEqual(a, b, "a restored game continues identically"); // validation rejects damage and tampering const bad = (mutate, code = "invalid") => { const s = clone(g); mutate(s); const r = E.restoreState(s, content); assert.equal(r.ok, false, "should have been rejected: " + mutate); assert.equal(r.error, code); }; bad((s) => { s.day = -1; }); bad((s) => { s.day = 99999; }); bad((s) => { s.day = 1.5; }); bad((s) => { s.indicators.order = 140; }); bad((s) => { s.indicators.order = null; }); bad((s) => { s.economy.gdp = NaN; }); bad((s) => { s.economy.debt = -5; }); bad((s) => { delete s.sectors.police; }); bad((s) => { s.sectors.police.alloc = 99; }); bad((s) => { s.sectors.mystery = { need: 1, alloc: 1, quality: 50 }; }); bad((s) => { s.economy.taxes.income = "high"; }); bad((s) => { s.pronouns = "xyz"; }); bad((s) => { s.countryName = ""; }); bad((s) => { s.countryName = "x".repeat(200); }); bad((s) => { s.speed = 7; }); bad((s) => { s.history.series.opinion.pop(); }); bad((s) => { delete s.history.series.debtRatio; }); bad((s) => { s.modifiers.push({ type: "shift", group: "ind", key: "bogus", perDay: 1, remaining: 3 }); }); bad((s) => { s.cardHistory.push({ id: "a", category: "x", day: 3, choice: "maybe" }); }); bad((s) => { s.over = { type: "exploded", day: 3 }; }); bad((s) => { s.ref.dev.growth = Infinity; }); bad((s) => { s.scenarioId = "nowhere"; }, "scenario"); bad((s) => { s.version = 7; }, "version"); assert.equal(E.restoreState(null, content).ok, false); assert.equal(E.restoreState([], content).ok, false); // content that changed since the save is repaired, not fatal const stale = clone(g); stale.inbox = [{ id: "deleted_card", day: stale.day }]; stale.pending.inboxFull = true; stale.followUps.push({ id: "deleted_card", due: 5 }, { id: cardsAll[0].id, due: 9 }); const fixed = E.restoreState(stale, content); assert.equal(fixed.ok, true); assert.deepEqual(fixed.state.inbox, []); assert.equal(fixed.state.pending.inboxFull, false); assert.deepEqual(fixed.state.followUps.map((f) => f.id).filter((id) => id === "deleted_card"), []); assert.ok(fixed.state.followUps.some((f) => f.id === cardsAll[0].id)); // an alert queue and its pause flag stay consistent const al = clone(g); al.alerts = [{ kind: "coup", day: 10 }]; al.pending.alert = false; assert.equal(E.restoreState(al, content).state.pending.alert, true); // a game saved with a proposal in the tray comes back with the same tray, and keeps running const waiting = fresh(32); E.confirmBudget(waiting); E.ensureCard(waiting, bal, cardsAll); const w = E.restoreState(decodeSave(encodeSave(waiting)).state, content).state; assert.deepEqual(w.inbox, waiting.inbox); assert.equal(w.inbox.length, 1); assert.equal(E.advanceDay(w, bal).advanced, true); // storage: slots, autosave, settings, and the fallbacks const mem = new Map(); const backend = { getItem: (k) => mem.get(k) ?? null, setItem: (k, v) => mem.set(k, v), removeItem: (k) => mem.delete(k) }; const store = createStorage(backend); assert.equal(store.persistent, true); assert.equal(readSave(store, KEYS.slot(1)), null); assert.equal(writeSave(store, KEYS.slot(1), g), true); const back = readSave(store, KEYS.slot(1)); assert.deepEqual(back.state, clone(g)); assert.equal(back.meta.day, g.day); assert.equal(back.meta.countryName, g.countryName); store.remove(KEYS.slot(1)); assert.equal(readSave(store, KEYS.slot(1)), null); mem.set(KEYS.slot(2), "{not json"); assert.equal(readSave(store, KEYS.slot(2)), null, "corrupt slot reads as empty"); mem.set(KEYS.slot(3), JSON.stringify({ nope: 1 })); assert.equal(readSave(store, KEYS.slot(3)), null); assert.deepEqual(loadSettings(store), { lang: null, slow: false, sound: true, volume: 0.6 }); saveSettings(store, { lang: "fr", slow: true, sound: false, volume: 0.25 }); assert.deepEqual(loadSettings(store), { lang: "fr", slow: true, sound: false, volume: 0.25 }); mem.set(KEYS.settings, JSON.stringify({ lang: "de", slow: "yes" })); assert.deepEqual(loadSettings(store), { lang: null, slow: false, sound: true, volume: 0.6 }, "unknown values are ignored"); mem.set(KEYS.settings, JSON.stringify({ volume: 7, sound: "no" })); assert.equal(loadSettings(store).volume, 0.6, "an out-of-range volume falls back to the default"); // no storage at all, a throwing storage, and a full storage: the game keeps going in memory const none = createStorage(null); assert.equal(none.persistent, false); assert.equal(none.set("k", "v"), false); assert.equal(none.get("k"), "v"); none.remove("k"); assert.equal(none.get("k"), null); const angry = createStorage({ getItem() { throw new Error("blocked"); }, setItem() { throw new Error("quota"); }, removeItem() { throw new Error("blocked"); } }); assert.equal(angry.set("k", "v"), false); assert.equal(angry.get("k"), "v"); assert.equal(angry.persistent, false); angry.remove("k"); assert.equal(writeSave(angry, KEYS.auto, g), false); assert.deepEqual(readSave(angry, KEYS.auto).state, clone(g)); } // 12. the in-tray: proposals pile up without stopping the clock; the game waits at the limit { const real = JSON.parse(fs.readFileSync(root + "cards/index.json")).files.flatMap((f) => JSON.parse(fs.readFileSync(root + "cards/" + f))); const max = bal.cards.inboxMax; const g = fresh(5); E.confirmBudget(g); let blockedDay = null; for (let i = 0; i < 200 && blockedDay === null; i++) { if (g.pending.review) E.confirmBudget(g); while (g.pending.alert) E.dismissAlert(g); if (g.pending.weeklyCard) E.ensureCard(g, bal, real); if (g.pending.inboxFull) { blockedDay = g.day; break; } E.advanceDay(g, bal); } assert.equal(g.inbox.length, max, "the game waits when the tray holds " + max); assert.equal(blockedDay, (max - 1) * bal.time.daysPerWeek, "the " + max + "th proposal arrives on week " + max); assert.equal(E.advanceDay(g, bal).blocked, "pending"); assert.equal(new Set(g.inbox.map((e) => e.id)).size, max, "no proposal twice in the tray"); assert.deepEqual(g.inbox.map((e) => e.day), [...g.inbox.map((e) => e.day)].sort((a, b) => a - b), "oldest first"); // answering the top one frees the clock; answering a chosen one works too const first = g.inbox[0].id, third = g.inbox[2].id; assert.equal(E.answerCard(g, bal, real, "yes").card.id, first); assert.equal(g.pending.inboxFull, false); assert.equal(E.advanceDay(g, bal).advanced, true); assert.equal(E.answerCard(g, bal, real, "no", third).card.id, third); assert.equal(g.inbox.length, max - 2); assert.equal(E.answerCard(g, bal, real, "no", "not_in_tray").ok, false); assert.equal(E.answerCard(g, bal, real, "maybe").ok, false); // a card waiting in the tray is not drawn again, and a saved tray survives a round trip const copy = E.restoreState(decodeSave(encodeSave(g)).state, { scenarios: { default: scn }, balance: bal, cards: real }); assert.equal(copy.ok, true); assert.deepEqual(copy.state.inbox, g.inbox); // a version-2 save with a pending card becomes a one-card tray const v2 = JSON.parse(JSON.stringify(fresh(6))); v2.version = 2; v2.pendingCard = { id: real[0].id, day: 0 }; v2.pending.weeklyCard = true; delete v2.inbox; delete v2.pending.inboxFull; const up = decodeSave(encodeSave(v2)); assert.equal(up.ok, true); assert.deepEqual(up.state.inbox, [{ id: real[0].id, day: 0 }]); assert.equal(up.state.pending.weeklyCard, false); assert.equal(up.state.pendingCard, undefined); } // 13. scenario-only cards and the clock's catch-up cap { const mk = (id, extra = {}) => ({ id, category: "economy", doc: "memo", sender: "min_finance", title: { en: id, fr: id }, body: { en: "b", fr: "b" }, yes: { label: { en: "y", fr: "o" } }, no: { label: { en: "n", fr: "n" } }, ...extra }); const g = fresh(9); open(g); assert.equal(E.drawCard(g, bal, [mk("elsewhere", { scenarios: ["other"] })]), null, "a card for another scenario is never drawn"); assert.equal(E.drawCard(g, bal, [mk("here", { scenarios: ["default"] })])?.id, "here"); assert.equal(E.drawCard(g, bal, [mk("everywhere")])?.id, "everywhere", "no scenarios field: every scenario"); const clock = E.createClock(); assert.equal(E.consumeClock(clock, 5000, 10000, 1), 0, "by default one step is capped at a second"); const wake = E.createClock(); assert.equal(E.consumeClock(wake, 45000, 10000, 1, 60000), 4, "a frozen tab catches up within the given cap"); const capped = E.createClock(); assert.equal(E.consumeClock(capped, 900000, 10000, 1, 60000), 6, "and no further than the cap"); assert.equal(E.consumeClock(E.createClock(), -50, 10000, 1, 60000), 0); } // M8: world cycle, growing needs, card conditions on choices, coup from neglect, v1 saves { // the world cycle moves, stays within bounds and is part of the saved state let w = fresh(); open(w); let lo = 0, hi = 0; for (let i = 0; i < 1500; i++) { run(w, 1); lo = Math.min(lo, w.world.cycle); hi = Math.max(hi, w.world.cycle); } assert.ok(hi - lo > 1, "the cycle moves: " + (hi - lo)); assert.ok(lo >= -bal.world.max && hi <= bal.world.max); // needs grow, so a frozen budget buys less quality const n0 = fresh().sectors.health.need; const g1 = fresh(); run(g1, 365); assert.ok(Math.abs(g1.sectors.health.need / n0 - (1 + bal.needGrowth.health)) < 1e-6, "health need grows by its yearly rate"); // conditions can read the player's choices: budget ratio and tax rate const c = fresh(); open(c); const cond = (conditions) => E.drawCard(c, bal, [{ id: "x", category: "crises", conditions, yes: {}, no: {} }]); assert.equal(cond({ min: { "ratio:police": 1.5 } }), null, "police at its starting ratio is below 1.5"); assert.equal(cond({ max: { "ratio:police": 1.5 } })?.id, "x"); c.sectors.police.alloc = c.sectors.police.need * 2; assert.equal(cond({ min: { "ratio:police": 1.5 } })?.id, "x"); assert.equal(cond({ min: { "tax:income": 31 } }), null); c.economy.taxes.income = 45; assert.equal(cond({ min: { "tax:income": 31 } })?.id, "x"); assert.equal(cond({ min: { "ratio:nope": 0 } }), null, "unknown sector is never eligible"); // severe army neglect alone is enough for a coup (stability fine) const k = fresh(); open(k); for (let i = 0; i < 400 && !k.over; i++) { k.indicators.armyLoyalty = bal.gameOver.coup.armyAlone - 1; k.indicators.stability = 90; while (k.pending.alert) E.dismissAlert(k); run(k, 1); } assert.equal(k.over?.type, "coup"); // a new danger warning opens an emergency budget session, at most once per cooldown const em = fresh(); open(em); em.indicators.unrest = 95; em.indicators.opinion = 2; E.advanceDay(em, bal); assert.equal(em.alerts.length, 1); assert.equal(em.pending.review, true, "the warning opens the budget"); assert.equal(em.risk.emergency, true); E.confirmBudget(em); assert.equal(em.risk.emergency, false, "signing closes the emergency session"); while (em.pending.alert) E.dismissAlert(em); // leave the zone, re-enter it inside the cooldown: a second alert but no second session em.indicators.unrest = 20; em.indicators.opinion = 60; for (let i = 0; i < 3; i++) { if (em.pending.weeklyCard) E.clearPending(em, "weeklyCard"); E.advanceDay(em, bal); } em.indicators.unrest = 95; em.indicators.opinion = 2; if (em.pending.weeklyCard) E.clearPending(em, "weeklyCard"); E.advanceDay(em, bal); assert.equal(em.alerts.length, 1, "a second warning"); assert.equal(em.pending.review, false, "no second session inside the cooldown"); // a version-1 save is upgraded with a neutral world and then resumes const clone = (x) => JSON.parse(JSON.stringify(x)); const content = { scenarios: { default: scn }, balance: bal, cards: [] }; const v1 = clone(fresh()); delete v1.world; v1.version = 1; const up = decodeSave(encodeSave(v1)); assert.equal(up.ok, true); assert.equal(up.state.version, 3); assert.deepEqual(up.state.world, { cycle: 0 }); assert.deepEqual(up.state.inbox, []); assert.equal(E.restoreState(up.state, content).ok, true); } console.log("all engine checks passed");