359 lines
18 KiB
JavaScript
359 lines
18 KiB
JavaScript
// 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)`);
|
|
}
|