// 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 }));