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
+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");