First commit, done on M6 from the PLAN.MD

This commit is contained in:
gauvainboiche
2026-10-25 17:34:44 +01:00
parent d5dc2a3901
commit 04193e2013
86 changed files with 8396 additions and 0 deletions
+61
View File
@@ -0,0 +1,61 @@
// Appends a batch of new cards to a category file.
// node tools/add-cards.mjs <category> <batch.json>
// The batch is a JSON array of cards. Ids must be new across ALL card files, and every card's
// `category` must match. Existing cards are kept as they are. The file is rewritten in a compact
// layout (small objects on one line). Run `go test ./server` afterwards for the full validation.
import fs from "node:fs";
import { fileURLToPath } from "node:url";
import { format } from "./cardfmt.mjs";
const dir = fileURLToPath(new URL("../web/data/cards/", import.meta.url));
const [category, batchPath] = process.argv.slice(2);
if (!category || !batchPath) {
console.error("usage: node tools/add-cards.mjs <category> <batch.json>");
process.exit(2);
}
const manifest = JSON.parse(fs.readFileSync(dir + "index.json", "utf8")).files;
const file = `${category}.json`;
if (!manifest.includes(file)) {
console.error(`unknown category "${category}" (known: ${manifest.map((f) => f.replace(".json", "")).join(", ")})`);
process.exit(2);
}
const existing = {};
const used = new Set();
for (const f of manifest) {
existing[f] = JSON.parse(fs.readFileSync(dir + f, "utf8"));
for (const c of existing[f]) used.add(c.id);
}
let batch;
try {
batch = JSON.parse(fs.readFileSync(batchPath, "utf8"));
} catch (e) {
console.error(`cannot read the batch: ${e.message}`);
process.exit(1);
}
if (!Array.isArray(batch)) {
console.error("the batch must be a JSON array of cards");
process.exit(1);
}
const problems = [];
const seen = new Set();
for (const [i, c] of batch.entries()) {
const who = c?.id ?? `#${i}`;
if (!c?.id) problems.push(`${who}: missing id`);
else if (used.has(c.id) || seen.has(c.id)) problems.push(`${who}: id already exists`);
seen.add(c?.id);
if (c?.category !== category) problems.push(`${who}: category must be "${category}"`);
if (!c?.title || !c?.body || !c?.yes || !c?.no) problems.push(`${who}: needs title, body, yes and no`);
}
if (problems.length) {
console.error("batch rejected, nothing written:\n " + problems.join("\n "));
process.exit(1);
}
const all = [...existing[file], ...batch];
fs.writeFileSync(dir + file, `${format(all)}\n`);
console.log(`${file}: +${batch.length} cards, ${all.length} in total`);
+76
View File
@@ -0,0 +1,76 @@
// Overview of the card pool, for reviewing content and balance.
// node tools/card-stats.mjs whole pool
// node tools/card-stats.mjs economy one category in detail (adds the longest texts)
import fs from "node:fs";
import { fileURLToPath } from "node:url";
import { optionImpact } from "../web/js/engine/index.js";
const dir = fileURLToPath(new URL("../web/data/cards/", import.meta.url));
const files = JSON.parse(fs.readFileSync(dir + "index.json", "utf8")).files;
const only = process.argv[2];
const pool = files.flatMap((f) => JSON.parse(fs.readFileSync(dir + f, "utf8")));
const cards = only ? pool.filter((c) => c.category === only) : pool;
if (!cards.length) {
console.error("no cards for", only);
process.exit(1);
}
const count = (xs, key) => xs.reduce((m, x) => ((m[key(x)] = (m[key(x)] ?? 0) + 1), m), {});
const mean = (xs) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : 0);
const fmt = (n) => n.toFixed(2);
const table = (o) => Object.entries(o).map(([k, v]) => `${k} ${v}`).join(", ");
console.log(`cards: ${cards.length}${only ? ` in ${only}` : ` in ${files.length} categories`}`);
if (!only) console.log("per category:", table(count(cards, (c) => c.category)));
console.log("doc types:", table(count(cards, (c) => c.doc)));
console.log("senders:", table(count(cards, (c) => c.sender)));
console.log("follow-up only:", cards.filter((c) => c.followUpOnly).length, "| repeatable:", cards.filter((c) => c.once === false).length);
const bucket = (c) => {
const d = c.conditions?.dayMin ?? 0;
if (d < 90) return "start";
if (d < 365) return "y1";
if (d < 730) return "y2";
if (d < 1095) return "y3";
return "y4+";
};
console.log("available from:", table(count(cards, bucket)));
console.log("with conditions:", cards.filter((c) => c.conditions).length, "| with stat conditions:",
cards.filter((c) => c.conditions?.min || c.conditions?.max).length);
const set = new Set();
const used = new Set();
let links = 0;
for (const c of cards) {
for (const side of ["yes", "no"]) {
for (const f of c[side].setFlags ?? []) set.add(f);
if (c[side].followUp) links++;
}
for (const f of [...(c.conditions?.flags ?? []), ...(c.conditions?.notFlags ?? [])]) used.add(f);
}
console.log(`flags set: ${set.size} | flags used: ${used.size} | follow-up links: ${links}`);
// Balance: what an average card does to the country, for each answer
const stat = (c, side, pick) => (c[side].effects ?? []).filter(pick).reduce((s, e) => s + (e.delta ?? e.oneOff ?? e.extraSpending ?? 0), 0);
const row = (label, f) => console.log(label.padEnd(34), `yes ${fmt(mean(cards.map((c) => f(c, "yes"))))}`.padEnd(12), `no ${fmt(mean(cards.map((c) => f(c, "no"))))}`.padEnd(12),
`coin flip ${fmt(mean(cards.map((c) => (f(c, "yes") + f(c, "no")) / 2)))}`);
console.log("\naverage per card (instant, spread and bias deltas added up):");
row("impact (size of the effects)", (c, s) => optionImpact(c[s]));
for (const id of ["opinion", "stability", "economy", "order", "civilRights", "unrest"]) row(`${id}`, (c, s) => stat(c, s, (e) => e.stat === id));
row("one-off cost, % of GDP", (c, s) => stat(c, s, (e) => e.oneOff !== undefined));
row("permanent extra spending, % GDP", (c, s) => stat(c, s, (e) => e.extraSpending !== undefined));
row("tax rate points (income+corp+vat)", (c, s) => stat(c, s, (e) => ["income", "corporate", "vat"].includes(e.revenue)));
row("other revenue, % of GDP", (c, s) => stat(c, s, (e) => e.revenue === "other"));
const effects = cards.flatMap((c) => ["yes", "no"].flatMap((s) => c[s].effects ?? []));
console.log("\neffects per option:", fmt(effects.length / (cards.length * 2)),
"| with bias:", effects.filter((e) => e.bias).length, "| spread over time:", effects.filter((e) => e.days && !e.bias).length,
"| instant:", effects.filter((e) => (e.stat || e.sector) && !e.days && !e.bias).length);
const long = cards.map((c) => ({ id: c.id, en: c.body.en.length, fr: c.body.fr.length })).filter((x) => x.en > 360 || x.fr > 400);
if (long.length) console.log("\nlong bodies (en > 360 or fr > 400 chars):", long.map((x) => `${x.id} (${x.en}/${x.fr})`).join(", "));
const dupTitles = Object.entries(count(cards, (c) => c.title.en.toLowerCase())).filter(([, n]) => n > 1);
if (dupTitles.length) console.log("duplicate English titles:", dupTitles.map(([t]) => t).join("; "));
const labelDup = cards.filter((c) => c.yes.label.en === c.no.label.en);
if (labelDup.length) console.log("same label on both options:", labelDup.map((c) => c.id).join(", "));
+15
View File
@@ -0,0 +1,15 @@
// Compact JSON layout for the card files: anything that fits on one line stays on one line.
const inline = (v) => {
if (Array.isArray(v)) return "[" + v.map(inline).join(", ") + "]";
if (v && typeof v === "object") return "{ " + Object.entries(v).map(([k, x]) => JSON.stringify(k) + ": " + inline(x)).join(", ") + " }";
return JSON.stringify(v);
};
export function format(v, indent = "") {
const flat = inline(v);
if (flat.length + indent.length <= 118 || typeof v !== "object" || v === null) return flat;
const next = indent + " ";
if (Array.isArray(v)) return "[\n" + v.map((x) => next + format(x, next)).join(",\n") + "\n" + indent + "]";
return "{\n" + Object.entries(v).map(([k, x]) => next + JSON.stringify(k) + ": " + format(x, next)).join(",\n") + "\n" + indent + "}";
}
+435
View File
@@ -0,0 +1,435 @@
// 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");