feat: Cards writings and balancing (M8)

This commit is contained in:
gauvainboiche
2026-10-25 18:30:43 +01:00
parent 04193e2013
commit c2103c32e7
43 changed files with 25930 additions and 397 deletions
+358
View File
@@ -0,0 +1,358 @@
// 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)`);
}