92 lines
3.6 KiB
JavaScript
92 lines
3.6 KiB
JavaScript
// 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 }));
|