559 lines
29 KiB
JavaScript
559 lines
29 KiB
JavaScript
// 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");
|