feat: Doing more polishing (M8b)
This commit is contained in:
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user