feat: Cards writings and balancing (M8)
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
// Edits balance.json from the command line and keeps its compact layout.
|
||||
// node tools/bal-edit.mjs path=value [path=value ...] e.g. gain=2.8 debt.premiumCoef=0.0008
|
||||
import fs from "node:fs";
|
||||
import { format } from "./cardfmt.mjs";
|
||||
|
||||
const p = new URL("../web/data/balance.json", import.meta.url);
|
||||
const j = JSON.parse(fs.readFileSync(p, "utf8"));
|
||||
for (const a of process.argv.slice(2)) {
|
||||
const [path, val] = a.split("=");
|
||||
const keys = path.split(".");
|
||||
let o = j;
|
||||
for (const k of keys.slice(0, -1)) o = o[k] ??= {};
|
||||
o[keys.at(-1)] = JSON.parse(val);
|
||||
}
|
||||
fs.writeFileSync(p, `${format(j)}\n`);
|
||||
@@ -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)`);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// Hill-climbing search for the best budget policy (M8): shows what the ceiling of the game is and
|
||||
// how a strong player would play, so the balance can be tuned against it.
|
||||
// node tools/balance-search.mjs [iterations] [seeds] [cardPolicy]
|
||||
// Prints the best parameters found and re-checks them on fresh seeds.
|
||||
|
||||
import * as E from "../web/js/engine/index.js";
|
||||
import { bal, playWith } from "./balance-check.mjs";
|
||||
|
||||
const GROUPS = {
|
||||
core: ["health", "social", "housing"],
|
||||
order: ["police", "justice", "administration"],
|
||||
army: ["army"],
|
||||
growth: ["education", "research", "infrastructure", "industry", "transport"],
|
||||
soft: ["culture", "foreignAffairs", "environment"],
|
||||
};
|
||||
const GKEYS = Object.keys(GROUPS);
|
||||
const TAXES = ["income", "corporate", "vat"];
|
||||
// every group multiplier and every tax shift has an early value (first two years) and a late one
|
||||
const START = { brake: 0 };
|
||||
const RANGE = { brake: [0, 1] };
|
||||
for (const g of GKEYS) for (const ph of ["E", "L"]) { START[g + ph] = 1; RANGE[g + ph] = g === "army" ? [0.3, 2.5] : [0.4, 2.6]; }
|
||||
for (const t of TAXES) for (const ph of ["E", "L"]) { START[t + ph] = 0; RANGE[t + ph] = [-10, 12]; }
|
||||
|
||||
export function policyFrom(p) {
|
||||
return (state) => {
|
||||
const ph = state.day < 730 ? "E" : "L";
|
||||
const alloc = {};
|
||||
for (const [g, ids] of Object.entries(GROUPS)) {
|
||||
for (const id of ids) {
|
||||
const s = state.sectors[id];
|
||||
alloc[id] = Math.min(s.need * bal.limits.allocMaxRatio, s.need * state.ref.ratio[id] * p[g + ph]);
|
||||
}
|
||||
}
|
||||
const taxes = {};
|
||||
for (const id of TAXES) taxes[id] = Math.max(0, state.ref.taxes[id] + p[id + ph]);
|
||||
// debt brake: above 90 % of GDP, trim every sector toward the baseline and add a point of VAT
|
||||
if (p.brake > 0) {
|
||||
const f = E.computeFiscal(state, bal);
|
||||
if (f.debtRatio > 90) {
|
||||
const k = 1 - 0.25 * p.brake;
|
||||
for (const id of Object.keys(alloc)) alloc[id] *= k;
|
||||
taxes.vat += 2 * p.brake;
|
||||
}
|
||||
}
|
||||
return { alloc, taxes };
|
||||
};
|
||||
}
|
||||
|
||||
function score(p, seeds, policy) {
|
||||
let total = 0;
|
||||
let survived = 0;
|
||||
for (const seed of seeds) {
|
||||
const r = playWith(policyFrom(p), policy, seed);
|
||||
total += r.end === "term" ? r.score : r.score * 0.5;
|
||||
if (r.end === "term") survived++;
|
||||
}
|
||||
return { mean: total / seeds.length, survival: survived / seeds.length };
|
||||
}
|
||||
|
||||
const iterations = Number(process.argv[2] ?? 120);
|
||||
const nSeeds = Number(process.argv[3] ?? 8);
|
||||
const policy = process.argv[4] ?? "intuitive";
|
||||
const seeds = Array.from({ length: nSeeds }, (_, i) => 100 + i);
|
||||
|
||||
let best = { ...START };
|
||||
let bestScore = score(best, seeds, policy);
|
||||
console.log("start", JSON.stringify(best), bestScore);
|
||||
let seedState = 12345;
|
||||
const rnd = () => (seedState = (seedState * 1664525 + 1013904223) % 4294967296) / 4294967296;
|
||||
|
||||
for (let it = 0; it < iterations; it++) {
|
||||
const cand = { ...best };
|
||||
const keys = Object.keys(RANGE);
|
||||
const nChange = 1 + Math.floor(rnd() * 3);
|
||||
for (let c = 0; c < nChange; c++) {
|
||||
const k = keys[Math.floor(rnd() * keys.length)];
|
||||
const [lo, hi] = RANGE[k];
|
||||
const step = (hi - lo) * 0.18 * (rnd() * 2 - 1);
|
||||
cand[k] = Math.min(hi, Math.max(lo, cand[k] + step));
|
||||
}
|
||||
const sc = score(cand, seeds, policy);
|
||||
if (sc.mean > bestScore.mean) {
|
||||
best = cand;
|
||||
bestScore = sc;
|
||||
console.log(`it ${it}`, JSON.stringify(Object.fromEntries(Object.entries(best).map(([k, v]) => [k, +v.toFixed(2)]))), JSON.stringify({ mean: +sc.mean.toFixed(1), survival: sc.survival }));
|
||||
}
|
||||
}
|
||||
|
||||
const fresh = Array.from({ length: 24 }, (_, i) => 500 + i);
|
||||
const check = score(best, fresh, policy);
|
||||
console.log("fresh seeds:", JSON.stringify({ mean: +check.mean.toFixed(1), survival: check.survival }));
|
||||
+62
-2
@@ -170,6 +170,7 @@ assert.equal(E.advanceDay(s, bal).blocked, "over");
|
||||
const drawn = new Set();
|
||||
for (let guard = 0; guard < 6000 && !gg.over; guard++) {
|
||||
if (gg.pending.review) E.confirmBudget(gg);
|
||||
while (gg.pending.alert) E.dismissAlert(gg);
|
||||
if (gg.pending.weeklyCard) {
|
||||
const card = E.ensureCard(gg, bal, real);
|
||||
if (card) { drawn.add(card.id); E.answerCard(gg, bal, real, E.nextFloat(gg) < 0.5 ? "yes" : "no"); }
|
||||
@@ -179,7 +180,7 @@ assert.equal(E.advanceDay(s, bal).blocked, "over");
|
||||
return { gg, drawn };
|
||||
};
|
||||
const a = play(11), b = play(11);
|
||||
assert.ok(a.gg.over && a.gg.over.type === "term", "random play reaches the end of the term");
|
||||
assert.ok(a.gg.over, "random play ends one way or another (the term, or a fall: passive play is risky since M8)");
|
||||
assert.deepEqual(a.gg, b.gg, "same seed and answers give the same game");
|
||||
assert.ok(a.drawn.size >= 40, "most of the pool is seen in a run: " + a.drawn.size);
|
||||
assert.equal(a.gg.cardHistory.length, a.gg.cardHistory.filter((h) => real.some((c) => c.id === h.id)).length);
|
||||
@@ -274,7 +275,7 @@ assert.equal(E.advanceDay(s, bal).blocked, "over");
|
||||
const calm = run(21);
|
||||
assert.equal(calm.over.type, "term");
|
||||
const rv = E.buildReview(calm, bal, real, endgame);
|
||||
assert.ok(rv.score > 40 && rv.score < 80, "a hands-off term scores mid-range: " + rv.score);
|
||||
assert.ok(rv.score > 20 && rv.score < 80, "a hands-off term scores in range (low since M8): " + rv.score);
|
||||
assert.ok(["S", "A", "B", "C", "D", "F"].includes(rv.grade));
|
||||
assert.ok(endgame.titles.some((x) => x.id === rv.titleId && !x.over), "a term title");
|
||||
assert.ok(rv.topDecisions.length > 0 && rv.topDecisions.length <= 5);
|
||||
@@ -432,4 +433,63 @@ assert.equal(E.advanceDay(s, bal).blocked, "over");
|
||||
assert.deepEqual(readSave(angry, KEYS.auto).state, clone(g));
|
||||
}
|
||||
|
||||
// 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
|
||||
let w = fresh(); open(w);
|
||||
let lo = 0, hi = 0;
|
||||
for (let i = 0; i < 1500; i++) { run(w, 1); lo = Math.min(lo, w.world.cycle); hi = Math.max(hi, w.world.cycle); }
|
||||
assert.ok(hi - lo > 1, "the cycle moves: " + (hi - lo));
|
||||
assert.ok(lo >= -bal.world.max && hi <= bal.world.max);
|
||||
|
||||
// needs grow, so a frozen budget buys less quality
|
||||
const n0 = fresh().sectors.health.need;
|
||||
const g1 = fresh(); run(g1, 365);
|
||||
assert.ok(Math.abs(g1.sectors.health.need / n0 - (1 + bal.needGrowth.health)) < 1e-6, "health need grows by its yearly rate");
|
||||
|
||||
// conditions can read the player's choices: budget ratio and tax rate
|
||||
const c = fresh(); open(c);
|
||||
const cond = (conditions) => E.drawCard(c, bal, [{ id: "x", category: "crises", conditions, yes: {}, no: {} }]);
|
||||
assert.equal(cond({ min: { "ratio:police": 1.5 } }), null, "police at its starting ratio is below 1.5");
|
||||
assert.equal(cond({ max: { "ratio:police": 1.5 } })?.id, "x");
|
||||
c.sectors.police.alloc = c.sectors.police.need * 2;
|
||||
assert.equal(cond({ min: { "ratio:police": 1.5 } })?.id, "x");
|
||||
assert.equal(cond({ min: { "tax:income": 31 } }), null);
|
||||
c.economy.taxes.income = 45;
|
||||
assert.equal(cond({ min: { "tax:income": 31 } })?.id, "x");
|
||||
assert.equal(cond({ min: { "ratio:nope": 0 } }), null, "unknown sector is never eligible");
|
||||
|
||||
// severe army neglect alone is enough for a coup (stability fine)
|
||||
const k = fresh(); open(k);
|
||||
for (let i = 0; i < 400 && !k.over; i++) { k.indicators.armyLoyalty = bal.gameOver.coup.armyAlone - 1; k.indicators.stability = 90; while (k.pending.alert) E.dismissAlert(k); run(k, 1); }
|
||||
assert.equal(k.over?.type, "coup");
|
||||
|
||||
// a new danger warning opens an emergency budget session, at most once per cooldown
|
||||
const em = fresh(); open(em);
|
||||
em.indicators.unrest = 95; em.indicators.opinion = 2;
|
||||
E.advanceDay(em, bal);
|
||||
assert.equal(em.alerts.length, 1);
|
||||
assert.equal(em.pending.review, true, "the warning opens the budget");
|
||||
assert.equal(em.risk.emergency, true);
|
||||
E.confirmBudget(em);
|
||||
assert.equal(em.risk.emergency, false, "signing closes the emergency session");
|
||||
while (em.pending.alert) E.dismissAlert(em);
|
||||
// leave the zone, re-enter it inside the cooldown: a second alert but no second session
|
||||
em.indicators.unrest = 20; em.indicators.opinion = 60;
|
||||
for (let i = 0; i < 3; i++) { if (em.pending.weeklyCard) E.clearPending(em, "weeklyCard"); E.advanceDay(em, bal); }
|
||||
em.indicators.unrest = 95; em.indicators.opinion = 2;
|
||||
if (em.pending.weeklyCard) E.clearPending(em, "weeklyCard");
|
||||
E.advanceDay(em, bal);
|
||||
assert.equal(em.alerts.length, 1, "a second warning");
|
||||
assert.equal(em.pending.review, false, "no second session inside the cooldown");
|
||||
|
||||
// a version-1 save is upgraded with a neutral world and then resumes
|
||||
const clone = (x) => JSON.parse(JSON.stringify(x));
|
||||
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(E.restoreState(up.state, content).ok, true);
|
||||
}
|
||||
|
||||
console.log("all engine checks passed");
|
||||
|
||||
Reference in New Issue
Block a user