// 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.pendingCard?.id !== "start"; i++) { if (g.pending.weeklyCard && !g.pendingCard) E.ensureCard(g, bal, [cards[0]]); if (!g.pendingCard) E.advanceDay(g, bal); } assert.equal(g.pendingCard.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); 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 && a.gg.over.type === "term", "random play reaches the end of the term"); 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 > 40 && rv.score < 80, "a hands-off term scores mid-range: " + 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.pendingCard = { id: "deleted_card", day: stale.day }; stale.pending.weeklyCard = 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.equal(fixed.state.pendingCard, null); 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 while waiting on a decision comes back waiting on the same decision const waiting = fresh(32); E.confirmBudget(waiting); E.ensureCard(waiting, bal, cardsAll); const w = E.restoreState(decodeSave(encodeSave(waiting)).state, content).state; assert.equal(w.pendingCard.id, waiting.pendingCard.id); assert.equal(E.advanceDay(w, bal).blocked, "pending"); // 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 }); saveSettings(store, { lang: "fr", slow: true }); assert.deepEqual(loadSettings(store), { lang: "fr", slow: true }); mem.set(KEYS.settings, JSON.stringify({ lang: "de", slow: "yes" })); assert.deepEqual(loadSettings(store), { lang: null, slow: false }, "unknown values are ignored"); // 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)); } console.log("all engine checks passed");