First commit, done on M6 from the PLAN.MD
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
// Headless balance runner. No DOM, no fetch: works in the browser (sim.html) and in Node.
|
||||
|
||||
import {
|
||||
createGame, advanceDay, summarize, setBudget, confirmBudget, ensureCard, answerCard, nextFloat, dismissAlert,
|
||||
} from "../js/engine/index.js";
|
||||
|
||||
// A strategy decides what to do at each quarterly review (review) and how to answer
|
||||
// each weekly card (answer, default: a coin flip from the game's own RNG).
|
||||
export const STRATEGIES = {
|
||||
baseline: { label: "Baseline (change nothing)", review: () => ({}) },
|
||||
yesMan: { label: "Yes to every card", review: () => ({}), answer: () => "yes" },
|
||||
noMan: { label: "No to every card", review: () => ({}), answer: () => "no" },
|
||||
spendthrift: {
|
||||
label: "Spendthrift (all budgets x1.4)",
|
||||
review: (state) => ({ alloc: scaled(state, 1.4) }),
|
||||
},
|
||||
austerity: {
|
||||
label: "Austerity (all budgets x0.75)",
|
||||
review: (state) => ({ alloc: scaled(state, 0.75) }),
|
||||
},
|
||||
policeState: {
|
||||
label: "Police state (police x3, army x1.5)",
|
||||
review: (state) => ({ alloc: { police: state.sectors.police.need * 3, army: state.sectors.army.need * 1.5 } }),
|
||||
},
|
||||
taxTheRich: {
|
||||
label: "Tax hike (income +10, corporate +10)",
|
||||
review: (state) => ({ taxes: { income: state.ref.taxes.income + 10, corporate: state.ref.taxes.corporate + 10 } }),
|
||||
},
|
||||
// Deliberately bad play: each should be able to end the term early.
|
||||
ruin: {
|
||||
label: "Ruin (all budgets x2, taxes cut to 15/12/10)",
|
||||
review: (state) => ({ alloc: scaled(state, 2), taxes: { income: 15, corporate: 12, vat: 10 } }),
|
||||
},
|
||||
starve: {
|
||||
label: "Starve (all budgets x0.25, taxes up)",
|
||||
review: (state) => ({
|
||||
alloc: scaled(state, 0.25),
|
||||
taxes: { income: state.ref.taxes.income + 15, corporate: state.ref.taxes.corporate + 10, vat: state.ref.taxes.vat + 10 },
|
||||
}),
|
||||
},
|
||||
tyrant: {
|
||||
label: "Tyrant (police x3, army x2, justice and social x0.3)",
|
||||
review: (state) => ({
|
||||
alloc: {
|
||||
...scaled(state, 0.7),
|
||||
police: state.sectors.police.need * 3,
|
||||
army: state.sectors.army.need * 2,
|
||||
justice: state.sectors.justice.need * 0.3,
|
||||
social: state.sectors.social.need * 0.3,
|
||||
},
|
||||
}),
|
||||
},
|
||||
neglectArmy: {
|
||||
label: "Neglect the army (army x0.1, the rest x1.1)",
|
||||
review: (state) => ({ alloc: { ...scaled(state, 1.1), army: state.sectors.army.need * 0.1 } }),
|
||||
},
|
||||
};
|
||||
|
||||
function 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;
|
||||
}
|
||||
|
||||
// Runs one game to the end of `years` (or the end of the term) and samples every `sampleEvery` days.
|
||||
export function runSim({ scenario, balance, cards = [], strategy, years = 1, seed = 1, sampleEvery = 30 }) {
|
||||
const state = createGame({ scenario, balance, seed, pronouns: "they" });
|
||||
const maxDay = Math.min(balance.time.termDays, Math.round(years * 365));
|
||||
const rows = [];
|
||||
const strat = STRATEGIES[strategy];
|
||||
|
||||
const sample = () => rows.push(summarize(state, balance));
|
||||
sample();
|
||||
|
||||
while (!state.over && state.day < maxDay) {
|
||||
if (state.pending.review) {
|
||||
const res = setBudget(state, balance, strat.review(state));
|
||||
if (!res.ok) throw new Error(`strategy ${strategy} produced an invalid budget: ${res.errors.join(", ")}`);
|
||||
confirmBudget(state);
|
||||
}
|
||||
while (state.pending.alert) dismissAlert(state);
|
||||
if (state.pending.weeklyCard) {
|
||||
const card = ensureCard(state, balance, cards);
|
||||
if (card) {
|
||||
const choice = strat.answer ? strat.answer(state, card) : nextFloat(state) < 0.5 ? "yes" : "no";
|
||||
answerCard(state, balance, cards, choice);
|
||||
}
|
||||
}
|
||||
advanceDay(state, balance);
|
||||
if (state.day % sampleEvery === 0) sample();
|
||||
}
|
||||
sample();
|
||||
return { rows, final: rows[rows.length - 1], over: state.over };
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Balance sim</title>
|
||||
<style>
|
||||
body { font: 13px/1.4 "Courier New", monospace; margin: 24px; background: #1d1a15; color: #e8dfc8; }
|
||||
h1, h2 { font-weight: normal; }
|
||||
label { margin-right: 16px; }
|
||||
input, select, button { font: inherit; background: #2b261e; color: inherit; border: 1px solid #6b5a3e; padding: 3px 6px; }
|
||||
button { cursor: pointer; }
|
||||
table { border-collapse: collapse; margin: 12px 0 28px; }
|
||||
th, td { border: 1px solid #4a4030; padding: 2px 8px; text-align: right; }
|
||||
th:first-child, td:first-child { text-align: left; }
|
||||
th { background: #2b261e; }
|
||||
.bad { color: #ff8a6b; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Balance sim</h1>
|
||||
<p>
|
||||
<label>Years <input id="years" type="number" min="1" max="5" value="1" style="width:4em"></label>
|
||||
<label>Seed <input id="seed" type="number" value="1" style="width:6em"></label>
|
||||
<label>Detail <select id="detail"></select></label>
|
||||
<button id="run">Run</button>
|
||||
</p>
|
||||
<h2>Final state per strategy</h2>
|
||||
<div id="summary"></div>
|
||||
<h2>Timeline</h2>
|
||||
<div id="timeline"></div>
|
||||
<script type="module">
|
||||
import { runSim, STRATEGIES } from "./sim-core.js";
|
||||
|
||||
const getJson = (u) => fetch(u).then((r) => r.json());
|
||||
const [balance, scenario, cardIndex] = await Promise.all([
|
||||
getJson("../data/balance.json"),
|
||||
getJson("../data/scenarios/default.json"),
|
||||
getJson("../data/cards/index.json"),
|
||||
]);
|
||||
const cards = (await Promise.all(cardIndex.files.map((f) => getJson(`../data/cards/${f}`)))).flat();
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const detail = $("detail");
|
||||
for (const [k, s] of Object.entries(STRATEGIES)) detail.add(new Option(s.label, k));
|
||||
|
||||
const COLS = [
|
||||
["day", "day", 0], ["growth", "growth %", 2], ["unemployment", "unempl %", 1], ["inflation", "infl %", 1],
|
||||
["debtRatio", "debt %GDP", 1], ["deficit", "deficit %GDP", 2], ["interestRate", "rate %", 2],
|
||||
["order", "order", 0], ["stability", "stab", 0], ["civilRights", "civil", 0], ["opinion", "opinion", 0],
|
||||
["economy", "econ", 0], ["hardPower", "hard", 0], ["softPower", "soft", 0], ["unrest", "unrest", 0], ["armyLoyalty", "army", 0],
|
||||
];
|
||||
const val = (row, k) => (k in row ? row[k] : row.indicators[k]);
|
||||
|
||||
function table(rows, firstCol) {
|
||||
const head = `<tr><th>${firstCol}</th>${COLS.map((c) => `<th>${c[1]}</th>`).join("")}</tr>`;
|
||||
const body = rows.map(([name, row]) =>
|
||||
`<tr><td>${name}</td>${COLS.map((c) => `<td>${val(row, c[0]).toFixed(c[2])}</td>`).join("")}</tr>`).join("");
|
||||
return `<table>${head}${body}</table>`;
|
||||
}
|
||||
|
||||
function run() {
|
||||
const years = Number($("years").value);
|
||||
const seed = Number($("seed").value);
|
||||
const results = {};
|
||||
for (const k of Object.keys(STRATEGIES)) results[k] = runSim({ scenario, balance, cards, strategy: k, years, seed });
|
||||
const ended = (r) => (r.over && r.over.type !== "term" ? ` ← ${r.over.type}, day ${r.over.day}` : "");
|
||||
$("summary").innerHTML = table(Object.entries(results).map(([k, r]) => [STRATEGIES[k].label + ended(r), r.final]), "strategy");
|
||||
const d = results[detail.value];
|
||||
$("timeline").innerHTML = table(d.rows.map((row) => [`d${row.day}`, row]), "day");
|
||||
}
|
||||
|
||||
$("run").addEventListener("click", run);
|
||||
detail.addEventListener("change", run);
|
||||
run();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user