feat: Doing more polishing (M8b)

This commit is contained in:
gauvainboiche
2026-10-25 18:50:17 +01:00
parent c2103c32e7
commit c55e5a2484
34 changed files with 1605 additions and 106 deletions
+7 -2
View File
@@ -11,7 +11,10 @@ import { STRATEGIES } from "../web/dev/sim-core.js";
const root = new URL("../web/data/", import.meta.url);
const read = (p) => JSON.parse(fs.readFileSync(new URL(p, root), "utf8"));
export const bal = read("balance.json");
export const scn = read("scenarios/default.json");
// --scenario <id> plays another scenario (default: "default")
const scenarioAt = process.argv.indexOf("--scenario");
export const scenarioId = scenarioAt > 0 ? process.argv[scenarioAt + 1] : "default";
export const scn = read(`scenarios/${scenarioId}.json`);
export const endgame = read("endgame.json");
// --set path=value (repeatable) overrides balance.json in memory, e.g. --set gain=2 --set indicators.opinion.drift=-1
@@ -323,6 +326,8 @@ const TARGETS = {
function check(seeds) {
let failures = 0;
// a scenario may ship its own bands (same shape as TARGETS), merged over the defaults
try { Object.assign(TARGETS, read(`scenarios/${scenarioId}.targets.json`).targets ?? {}); } catch { /* none */ }
for (const [name, want] of Object.entries(TARGETS)) {
const [b, p] = PLAYERS[name];
const runs = [];
@@ -343,7 +348,7 @@ function check(seeds) {
if (process.argv[1] && import.meta.url.endsWith(process.argv[1].replaceAll("\\", "/").split("/").pop())) {
const raw = process.argv.slice(2);
const args = raw.filter((a, i) => !a.startsWith("--") && raw[i - 1] !== "--set");
const args = raw.filter((a, i) => !a.startsWith("--") && raw[i - 1] !== "--set" && raw[i - 1] !== "--scenario");
const seeds = Number(args[0] ?? 12);
const names = args[1] ? args[1].split(",") : Object.keys(PLAYERS);
const t0 = Date.now();
+58 -12
View File
@@ -147,8 +147,8 @@ assert.equal(E.advanceDay(s, bal).blocked, "over");
];
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");
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);
@@ -379,22 +379,24 @@ assert.equal(E.advanceDay(s, bal).blocked, "over");
// 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.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.equal(fixed.state.pendingCard, null);
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 while waiting on a decision comes back waiting on the same decision
// 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.equal(w.pendingCard.id, waiting.pendingCard.id);
assert.equal(E.advanceDay(w, bal).blocked, "pending");
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();
@@ -412,11 +414,13 @@ assert.equal(E.advanceDay(s, bal).blocked, "over");
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 });
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 }, "unknown values are ignored");
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);
@@ -433,6 +437,48 @@ assert.equal(E.advanceDay(s, bal).blocked, "over");
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);
}
// 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
@@ -488,7 +534,7 @@ assert.equal(E.advanceDay(s, bal).blocked, "over");
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, 2); assert.deepEqual(up.state.world, { cycle: 0 });
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);
}
+49
View File
@@ -0,0 +1,49 @@
// Start-state report for a scenario (M8b): is the start close to balance, and what does a year of
// doing nothing do? Usage: node tools/scenario-check.mjs [scenarioId]
import fs from "node:fs";
import * as E from "../web/js/engine/index.js";
const root = new URL("../web/data/", import.meta.url);
const read = (p) => JSON.parse(fs.readFileSync(new URL(p, root), "utf8"));
const id = process.argv[2] ?? "default";
const bal = read("balance.json");
const scn = read(`scenarios/${id}.json`);
const state = E.createGame({ scenario: scn, balance: bal, seed: 1, pronouns: "they" });
const f = E.computeFiscal(state, bal);
const warn = [];
const line = (k, v) => console.log(k.padEnd(28), v);
console.log(`Scenario ${id}: ${scn.name.en} (${scn.currency}), starts ${scn.startDate}`);
line("GDP", `${scn.economy.gdp} bn ${scn.currency}`);
line("Debt / GDP", `${f.debtRatio.toFixed(1)} % (interest rate ${f.rate.toFixed(2)} %)`);
line("Revenue", `${f.revenue.toFixed(2)} % of GDP`);
line("Sector spending", `${f.allocTotal.toFixed(2)} % of GDP`);
line("Debt interest", `${f.interest.toFixed(2)} % of GDP`);
line("Deficit (+) / surplus (-)", `${f.deficit.toFixed(2)} % of GDP`);
if (Math.abs(f.deficit) > 2) warn.push(`the start deficit is ${f.deficit.toFixed(1)} % of GDP: the start should be close to balance (within 2 points)`);
if (f.debtRatio > bal.gameOver.default.warnAbove - 25) warn.push("the start debt is close to the default warning");
for (const [k, s] of Object.entries(state.sectors)) {
const ratio = s.alloc / s.need;
if (ratio < 0.5 || ratio > 1.5) warn.push(`sector ${k} starts at ${(ratio * 100).toFixed(0)} % of its need`);
}
for (const [k, v] of Object.entries(state.indicators)) if (v < 5 || v > 95) warn.push(`indicator ${k} starts at ${v}`);
for (const k of E.IND_IDS) if (!(k in state.indicators)) warn.push(`indicator ${k} is missing`);
// A year of doing nothing: no card answered, the budget untouched, the same noise as the game.
const x = structuredClone(state);
const start = { ...x.indicators, debt: f.debtRatio, growth: x.economy.growth };
for (let d = 0; d < 365 && !x.over; d++) {
for (const k of Object.keys(x.pending)) x.pending[k] = false;
x.alerts.length = 0;
E.advanceDay(x, bal);
}
const end = E.summarize(x, bal);
console.log("\nOne year with no decisions:");
for (const k of E.IND_IDS) line(` ${k}`, `${start[k].toFixed(0)} -> ${x.indicators[k].toFixed(0)}`);
line(" debt / GDP", `${start.debt.toFixed(0)} -> ${end.debtRatio.toFixed(0)}`);
line(" growth", `${start.growth.toFixed(1)} -> ${end.growth.toFixed(1)}`);
if (x.over) warn.push(`the game ends within a year of doing nothing (${x.over.type})`);
if (end.indicators.opinion < start.opinion - 15) warn.push("opinion falls by more than 15 points in a year of inaction");
console.log(warn.length ? `\nWarnings:\n- ${warn.join("\n- ")}` : "\nNo warnings.");
process.exitCode = warn.length ? 1 : 0;