// Balance harness (M8): plays full terms with different "players" and reports how hard the game is. // node tools/balance-check.mjs [seeds] [players] [--json] // A player = a budget policy (decided at each quarterly review) + a card policy (yes/no each week). // Card policies: random (coin flip), oracle (reads the real effects), intuitive (oracle that gets // 30 % of the answers wrong, closer to a human reading the text). import fs from "node:fs"; import * as E from "../web/js/engine/index.js"; 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"); 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 for (let i = 2; i < process.argv.length; i++) { if (process.argv[i] !== "--set") continue; const [path, val] = process.argv[i + 1].split("="); const keys = path.split("."); let o = bal; for (const k of keys.slice(0, -1)) o = o[k]; o[keys.at(-1)] = JSON.parse(val); } export const cards = read("cards/index.json").files.flatMap((f) => read("cards/" + f)); // ---------- card policies ---------- // Rough worth of an option in "points". Stats the player cares about weigh more when they are low. function optionValue(state, opt, fiscal) { const ind = state.indicators; const need = (v) => 1 + Math.max(0, (50 - v) / 25); // low stat => more valuable let v = 0; for (const e of opt.effects ?? []) { const d = e.delta ?? 0; const horizon = e.bias ? Math.min(6, (e.days ?? 540) / 90) : 1; // permanent offsets count more if (e.stat !== undefined) { const w = { opinion: 1.0, stability: 1.0, economy: 1.0, order: 0.6, civilRights: 0.6, hardPower: 0.5, softPower: 0.5 }[e.stat]; if (w !== undefined) v += w * need(ind[e.stat]) * d * horizon * (e.bias ? 0.4 : 1); else if (e.stat === "unrest") v -= 1.0 * (1 + Math.max(0, (ind.unrest - 30) / 20)) * d * horizon * (e.bias ? 0.4 : 1); else if (e.stat === "armyLoyalty") v += 0.5 * need(ind.armyLoyalty) * d * horizon * (e.bias ? 0.4 : 1); else if (e.stat === "growth") v += 3 * d * horizon * (e.bias ? 0.4 : 1); else if (e.stat === "unemployment" || e.stat === "inflation") v -= 1.2 * d * horizon * (e.bias ? 0.4 : 1); } else if (e.sector !== undefined) { v += 0.35 * d * horizon * (e.bias ? 0.4 : 1); } else if (e.revenue !== undefined) { v += (e.revenue === "other" ? 8 : e.revenue === "corporate" ? 2 : 2.5) * d * (1 + Math.max(0, (fiscal.debtRatio - 60) / 40)); } else if (e.oneOff !== undefined) { v -= 4 * e.oneOff * (1 + Math.max(0, (fiscal.debtRatio - 60) / 40)); } else if (e.extraSpending !== undefined) { v -= 14 * e.extraSpending * (1 + Math.max(0, (fiscal.debtRatio - 60) / 40)); } } return v; } const POLICIES = { random: () => (state) => (E.nextFloat(state) < 0.5 ? "yes" : "no"), oracle: () => (state, card) => { const f = E.computeFiscal(state, bal); return optionValue(state, card.yes, f) >= optionValue(state, card.no, f) ? "yes" : "no"; }, intuitive: () => (state, card) => { const f = E.computeFiscal(state, bal); const best = optionValue(state, card.yes, f) >= optionValue(state, card.no, f) ? "yes" : "no"; return E.nextFloat(state) < 0.3 ? (best === "yes" ? "no" : "yes") : best; }, }; // ---------- budget policies ---------- const scaled = (state, k) => { const out = {}; for (const [id, s] of Object.entries(state.sectors)) out[id] = s.need * state.ref.ratio[id] * k; return out; }; // Per-sector multipliers on the scenario's starting ratio. `by` overrides single sectors. const withMult = (state, base, by = {}) => { const out = {}; for (const [id, s] of Object.entries(state.sectors)) { const m = by[id] ?? base; out[id] = Math.min(s.need * bal.limits.allocMaxRatio, s.need * state.ref.ratio[id] * m); } return out; }; const GROWTH = ["education", "research", "infrastructure", "industry", "transport"]; // A prudent manager: keeps the core services at their starting level, nudges taxes to hold the debt // ratio near a target and puts a little extra into the growth sectors when the books allow it. function steward({ invest = 1.1, target = 65, tight = 0.9 } = {}) { return (state) => { const f = E.computeFiscal(state, bal); const taxes = {}; const by = {}; if (f.debtRatio > target + 15 || f.deficit > 3) { for (const id of GROWTH) by[id] = 1.0; for (const id of ["culture", "foreignAffairs", "administration"]) by[id] = tight; taxes.vat = Math.min(bal.tax.vat.peak, state.economy.taxes.vat + 1); taxes.income = Math.min(bal.tax.income.peak, state.economy.taxes.income + 0.5); } else if (f.debtRatio < target - 10 && f.deficit < 0.5) { for (const id of GROWTH) by[id] = invest; taxes.vat = Math.max(state.ref.taxes.vat, state.economy.taxes.vat - 0.5); taxes.income = Math.max(state.ref.taxes.income, state.economy.taxes.income - 0.5); } else { for (const id of GROWTH) by[id] = 1.0; } return { alloc: withMult(state, 1.0, by), taxes }; }; } // Bold: borrow heavily at the start to lift education, research, infrastructure and industry, // then pull the brake in the last year. function investor({ mult = 1.8, until = 1100, brake = 0.85 } = {}) { return (state) => { const by = {}; const late = state.day >= until; for (const id of GROWTH) by[id] = late ? 1.0 : mult; const rest = late ? brake : 1.0; return { alloc: withMult(state, rest, by) }; }; } // What the hill-climbing search found (tools/balance-search.mjs): invest hard in growth, cut corporate tax. const grower = () => (state) => { const by = { education: 1.7, research: 1.7, infrastructure: 1.7, industry: 1.7, transport: 1.7, health: 0.9, social: 0.9, housing: 0.9, army: 1.2 }; return { alloc: withMult(state, 1.0, by), taxes: { corporate: state.ref.taxes.corporate - 7 } }; }; // Best policy of the two-phase search (balance-search.mjs, 2nd run): steady core services, more for growth, // soft power and (late) order, financed by income tax and VAT in the second half. // k scales every deviation from "normal" (k=1 is the searched policy, 0 = baseline, 2 = twice as bold). const planner = (k = 1) => (state) => { const late = state.day >= 730; const by = late ? { police: 1.7, justice: 1.7, administration: 1.7, army: 1.17, education: 1.7, research: 1.7, infrastructure: 1.7, industry: 1.7, transport: 1.7, culture: 1.3, foreignAffairs: 1.3, environment: 1.3 } : { education: 1.3, research: 1.3, infrastructure: 1.3, industry: 1.3, transport: 1.3, culture: 1.38, foreignAffairs: 1.38, environment: 1.38 }; const t = state.ref.taxes; for (const id of Object.keys(by)) by[id] = 1 + (by[id] - 1) * k; return { alloc: withMult(state, 1.0, by), taxes: late ? { income: t.income + 4.3 * k, vat: t.vat + 2.5 * k } : { income: t.income + 1.5 * k } }; }; // What a sensible player does in an emergency session (opened by a danger alert). function emergencyBudget(state) { const kind = state.alerts[0]?.kind; const t = state.economy.taxes; if (kind === "default") return { alloc: withMult(state, 0.8), taxes: { vat: t.vat + 3, income: t.income + 3 } }; if (kind === "coup") return { alloc: withMult(state, 1.0, { army: 1.8 }) }; return { alloc: withMult(state, 1.0, { social: 1.4, housing: 1.5, health: 1.3, justice: 1.4, police: 1.3 }), taxes: { income: Math.max(state.ref.taxes.income - 2, t.income - 2), vat: Math.max(state.ref.taxes.vat - 2, t.vat - 2) } }; } const rescued = (make) => (o) => { const f = make(o); return (state) => (state.risk.emergency ? emergencyBudget(state) : f(state)); }; const BUDGETS = { starveRescue: rescued(() => BUDGETS.starve()), austerityRescue: rescued(() => BUDGETS.austerity()), tyrantRescue: rescued(() => BUDGETS.tyrant()), neglectArmyRescue: rescued(() => BUDGETS.neglectArmy()), spendthriftRescue: rescued(() => BUDGETS.spendthrift()), baselineRescue: rescued(() => BUDGETS.baseline()), plannerRescue: rescued(() => planner(1)), planner: () => planner(1), planner05: () => planner(0.5), planner15: () => planner(1.5), planner2: () => planner(2), planner3: () => planner(3), grower, baseline: () => () => ({}), steward: (o) => steward(o), investor: (o) => investor(o), bold: () => investor({ mult: 2.2, until: 1000, brake: 0.8 }), spendthrift: () => (s) => ({ alloc: scaled(s, 1.4) }), austerity: () => (s) => ({ alloc: scaled(s, 0.75) }), austerityMild: () => (s) => ({ alloc: withMult(s, 0.92, { culture: 0.6, foreignAffairs: 0.6, administration: 0.7, research: 0.8, environment: 0.7 }), taxes: { vat: s.ref.taxes.vat + 1.5 } }), taxMild: () => (s) => ({ taxes: { income: s.ref.taxes.income + 3, vat: s.ref.taxes.vat + 2 } }), policeMild: () => (s) => ({ alloc: { ...withMult(s, 1.0), police: s.sectors.police.need * 1.6 } }), armyMild: () => (s) => ({ alloc: { ...withMult(s, 1.0), army: s.sectors.army.need * 1.5 } }), spendMild: () => (s) => ({ alloc: scaled(s, 1.15) }), welfareMild: () => (s) => ({ alloc: { ...withMult(s, 1.0), social: s.sectors.social.need * 1.2, health: s.sectors.health.need * 1.2, housing: s.sectors.housing.need * 1.3 }, taxes: { income: s.ref.taxes.income + 2, vat: s.ref.taxes.vat + 1.5 } }), deepCuts: () => (s) => ({ alloc: scaled(s, 0.5), taxes: { income: s.ref.taxes.income + 5, vat: s.ref.taxes.vat + 3 } }), taxHike: () => (s) => ({ taxes: { income: s.ref.taxes.income + 12, corporate: s.ref.taxes.corporate + 10, vat: s.ref.taxes.vat + 8 } }), taxCut: () => (s) => ({ taxes: { income: s.ref.taxes.income - 12, corporate: s.ref.taxes.corporate - 10, vat: s.ref.taxes.vat - 6 } }), policeState: () => (s) => ({ alloc: { ...withMult(s, 1.0), police: s.sectors.police.need * 3, army: s.sectors.army.need * 1.5 } }), tyrant: () => (s) => ({ alloc: { ...withMult(s, 0.7), police: s.sectors.police.need * 3, army: s.sectors.army.need * 2, justice: s.sectors.justice.need * 0.3, social: s.sectors.social.need * 0.3 } }), ruin: () => (s) => ({ alloc: scaled(s, 2), taxes: { income: 15, corporate: 12, vat: 10 } }), starve: () => (s) => ({ alloc: scaled(s, 0.25), taxes: { income: s.ref.taxes.income + 15, corporate: s.ref.taxes.corporate + 10, vat: s.ref.taxes.vat + 10 } }), neglectArmy: () => (s) => ({ alloc: { ...withMult(s, 1.1), army: s.sectors.army.need * 0.1 } }), militarist: () => (s) => ({ alloc: { ...withMult(s, 0.9), army: s.sectors.army.need * 3 } }), welfare: () => (s) => ({ alloc: { ...withMult(s, 1.0), social: s.sectors.social.need * 1.6, health: s.sectors.health.need * 1.5, housing: s.sectors.housing.need * 1.8 } }), }; // name -> [budget, cards] export const PLAYERS = { random: ["baseline", "random"], randomSteward: ["steward", "random"], plannerHuman: ["planner", "intuitive"], starveRescue: ["starveRescue", "intuitive"], austerityRescue: ["austerityRescue", "intuitive"], tyrantRescue: ["tyrantRescue", "intuitive"], neglectArmyRescue: ["neglectArmyRescue", "intuitive"], spendthriftRescue: ["spendthriftRescue", "intuitive"], randomRescue: ["baselineRescue", "random"], plannerRescue: ["plannerRescue", "intuitive"], planner05: ["planner05", "intuitive"], planner15: ["planner15", "intuitive"], planner2: ["planner2", "intuitive"], planner3: ["planner3", "intuitive"], plannerOracle: ["planner", "oracle"], plannerRandom: ["planner", "random"], growerHuman: ["grower", "intuitive"], growerOracle: ["grower", "oracle"], stewardOracle: ["steward", "oracle"], stewardHuman: ["steward", "intuitive"], investorOracle: ["investor", "oracle"], investorHuman: ["investor", "intuitive"], boldOracle: ["bold", "oracle"], boldHuman: ["bold", "intuitive"], baselineOracle: ["baseline", "oracle"], spendthrift: ["spendthrift", "intuitive"], austerity: ["austerity", "intuitive"], deepCuts: ["deepCuts", "intuitive"], austerityMild: ["austerityMild", "intuitive"], taxMild: ["taxMild", "intuitive"], policeMild: ["policeMild", "intuitive"], armyMild: ["armyMild", "intuitive"], spendMild: ["spendMild", "intuitive"], welfareMild: ["welfareMild", "intuitive"], taxHike: ["taxHike", "intuitive"], taxCut: ["taxCut", "intuitive"], policeState: ["policeState", "intuitive"], tyrant: ["tyrant", "intuitive"], ruin: ["ruin", "intuitive"], starve: ["starve", "intuitive"], neglectArmy: ["neglectArmy", "intuitive"], militarist: ["militarist", "intuitive"], welfare: ["welfare", "intuitive"], }; export const playOne = (budgetName, policyName, seed, opts = {}) => playWith(BUDGETS[budgetName](opts.budget), policyName, seed); // budget: (state) => { alloc?, taxes? } called at every quarterly review. export function playWith(budget, policyName, seed) { const state = E.createGame({ scenario: scn, balance: bal, seed, pronouns: "they" }); const answer = POLICIES[policyName](); const alerts = {}; for (let guard = 0; guard < 9000 && !state.over; guard++) { if (state.pending.review) { const res = E.setBudget(state, bal, budget(state)); if (!res.ok) throw new Error(`${budgetName}: ${res.errors.join(",")}`); E.confirmBudget(state); } while (state.pending.alert) { alerts[state.alerts[0].kind] = (alerts[state.alerts[0].kind] ?? 0) + 1; E.dismissAlert(state); } if (state.pending.weeklyCard) { const card = E.ensureCard(state, bal, cards); if (card) E.answerCard(state, bal, cards, answer(state, card)); } E.advanceDay(state, bal); } const review = E.buildReview(state, bal, cards, endgame); const f = E.summarize(state, bal); return { seed, end: state.over?.type ?? "term", day: state.day, score: review.score, grade: review.grade, title: review.titleId, debt: f.debtRatio, ind: f.indicators, alerts, crises: state.cardHistory.filter((h) => h.category === "crises").length, metrics: review.metrics, cardIds: state.cardHistory.map((h) => h.id), }; } const mean = (xs) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : 0); function report(name, runs) { const n = runs.length; const ends = {}; for (const r of runs) ends[r.end] = (ends[r.end] ?? 0) + 1; const survived = runs.filter((r) => r.end === "term"); const grades = {}; for (const r of survived) grades[r.grade] = (grades[r.grade] ?? 0) + 1; const scoreAll = mean(runs.map((r) => r.score)); const scoreSurv = mean(survived.map((r) => r.score)); const dayFail = mean(runs.filter((r) => r.end !== "term").map((r) => r.day)); const endTxt = Object.entries(ends).map(([k, v]) => `${k} ${v}`).join(" "); const gradeTxt = ["S", "A", "B", "C", "D", "F"].filter((g) => grades[g]).map((g) => `${g}${grades[g]}`).join(" "); const last = (k) => mean(runs.map((r) => r.ind[k])); console.log( `${name.padEnd(15)} survive ${String(Math.round((100 * survived.length) / n)).padStart(3)}% score ${scoreSurv.toFixed(1).padStart(5)} (all ${scoreAll.toFixed(1)}) ` + `grades ${gradeTxt.padEnd(14)} fall@day ${Number.isFinite(dayFail) ? dayFail.toFixed(0).padStart(4) : " -"} debt ${mean(runs.map((r) => r.debt)).toFixed(0).padStart(3)} ` + `opin ${last("opinion").toFixed(0)} stab ${last("stability").toFixed(0)} econ ${last("economy").toFixed(0)} unrest ${last("unrest").toFixed(0)} crises ${mean(runs.map((r) => r.crises)).toFixed(1)} | ${endTxt}`, ); } // Intended shape of the difficulty (M8). `node tools/balance-check.mjs --check` fails when a player type leaves its band. // survive: % of runs that reach the end of the term; score: mean score of the survivors; top: % of survivors graded A or S. const TARGETS = { // hard for passive play, forgiving once the cabinet reacts to a warning random: { survive: [55, 92], score: [36, 52] }, randomRescue: { survive: [85, 100] }, stewardHuman: { survive: [80, 100], score: [38, 54] }, // a competent, balanced plan works; audacity pays up to a point, then it is punished planner05: { survive: [96, 100], score: [50, 62] }, plannerHuman: { survive: [96, 100], score: [57, 67], top: [5, 60] }, plannerOracle: { survive: [100, 100], score: [62, 72], top: [30, 100] }, planner15: { survive: [85, 100], score: [54, 67], top: [10, 70] }, planner2: { survive: [40, 90] }, planner3: { survive: [0, 8] }, // extremes end in a fall ruin: { survive: [0, 0] }, starve: { survive: [0, 0] }, deepCuts: { survive: [0, 0] }, tyrant: { survive: [0, 0] }, neglectArmy: { survive: [0, 15] }, militarist: { survive: [0, 25] }, austerity: { survive: [0, 20] }, spendthrift: { survive: [0, 55] }, taxHike: { survive: [40, 100], score: [0, 48] }, policeState: { score: [0, 46] }, // milder versions of the same ideas stay playable austerityMild: { survive: [75, 100] }, taxMild: { survive: [85, 100] }, policeMild: { survive: [90, 100] }, spendMild: { survive: [95, 100] }, welfareMild: { survive: [95, 100] }, }; function check(seeds) { let failures = 0; for (const [name, want] of Object.entries(TARGETS)) { const [b, p] = PLAYERS[name]; const runs = []; for (let s = 1; s <= seeds; s++) runs.push(playOne(b, p, s)); const surv = runs.filter((r) => r.end === "term"); const got = { survive: (100 * surv.length) / runs.length, score: mean(surv.map((r) => r.score)), top: surv.length ? (100 * surv.filter((r) => r.grade === "A" || r.grade === "S").length) / surv.length : 0, }; const bad = Object.entries(want).filter(([k, [lo, hi]]) => !(got[k] >= lo && got[k] <= hi) && !(k !== "survive" && !surv.length && lo === 0)); if (bad.length) failures++; console.log(`${bad.length ? "FAIL" : "ok "} ${name.padEnd(15)} ` + Object.entries(want).map(([k, [lo, hi]]) => `${k} ${got[k].toFixed(0)} (want ${lo}-${hi})`).join(" ")); } console.log(failures ? `${failures} player type(s) out of band` : "balance targets met"); process.exitCode = failures ? 1 : 0; } 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 seeds = Number(args[0] ?? 12); const names = args[1] ? args[1].split(",") : Object.keys(PLAYERS); const t0 = Date.now(); if (raw.includes("--check")) { check(Number(args[0] ?? 24)); process.exit(); } for (const name of names) { const [b, p] = PLAYERS[name]; const runs = []; for (let s = 1; s <= seeds; s++) runs.push(playOne(b, p, s)); report(name, runs); } console.log(`(${((Date.now() - t0) / 1000).toFixed(0)} s, ${seeds} seeds each)`); }