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
+15
View File
@@ -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`);
+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)`);
}
+91
View File
@@ -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
View File
@@ -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");