First commit, done on M6 from the PLAN.MD

This commit is contained in:
gauvainboiche
2026-10-25 17:34:44 +01:00
parent d5dc2a3901
commit 04193e2013
86 changed files with 8396 additions and 0 deletions
+104
View File
@@ -0,0 +1,104 @@
// Weekly decision cards: drawing, eligibility and applying the player's answer.
//
// Card: { id, category, doc, sender, weight?, once?, cooldownDays?, followUpOnly?,
// conditions?: { min:{stat:n}, max:{stat:n}, flags:[], notFlags:[], dayMin, dayMax },
// title, body, yes: Option, no: Option }
// Option: { label, effects: [...see modifiers.js], setFlags?, clearFlags?, followUp?: { id, inDays } }
// Condition stats: any indicator, growth/unemployment/inflation, "debt" (% of GDP),
// "deficit" (% of GDP) and "q:<sector>" (sector quality).
import { IND_IDS, VAR_IDS, clamp } from "./state.js";
import { computeFiscal } from "./economy.js";
import { applyEffect } from "./modifiers.js";
import { pickWeighted } from "./rng.js";
export const indexCards = (cards) => new Map(cards.map((c) => [c.id, c]));
function statValue(state, key, fiscal) {
if (IND_IDS.includes(key)) return state.indicators[key];
if (VAR_IDS.includes(key)) return state.economy[key];
if (key === "debt") return fiscal.debtRatio;
if (key === "deficit") return fiscal.deficit;
if (key.startsWith("q:")) return state.sectors[key.slice(2)]?.quality ?? NaN;
return NaN; // unknown key: never eligible (content tests catch typos)
}
function meetsConditions(card, state, fiscal) {
const c = card.conditions;
if (!c) return true;
if (c.dayMin !== undefined && state.day < c.dayMin) return false;
if (c.dayMax !== undefined && state.day > c.dayMax) return false;
for (const [k, v] of Object.entries(c.min ?? {})) if (!(statValue(state, k, fiscal) >= v)) return false;
for (const [k, v] of Object.entries(c.max ?? {})) if (!(statValue(state, k, fiscal) <= v)) return false;
for (const f of c.flags ?? []) if (!state.flags[f]) return false;
for (const f of c.notFlags ?? []) if (state.flags[f]) return false;
return true;
}
// A sector (or indicator) in trouble makes cards about it more likely.
function boostFor(card, state, bal) {
const b = bal.cards.boost[card.category];
if (!b) return 1;
const v = b.sector ? state.sectors[b.sector].quality : state.indicators[b.indicator];
const below = bal.cards.boostBelow;
return 1 + bal.cards.boostMax * clamp((below - v) / below, 0, 1);
}
export function drawCard(state, bal, cards) {
const fiscal = computeFiscal(state, bal);
const lastAnswered = new Map();
for (const h of state.cardHistory) lastAnswered.set(h.id, h.day);
const recent = state.cardHistory.slice(-bal.cards.recentWindow).map((h) => h.category);
const pool = [];
const weights = [];
for (const card of cards) {
if (card.followUpOnly) continue;
const last = lastAnswered.get(card.id);
if (last !== undefined) {
if (card.once !== false) continue;
if (state.day - last < (card.cooldownDays ?? bal.cards.defaultCooldown)) continue;
}
if (!meetsConditions(card, state, fiscal)) continue;
const seen = recent.filter((c) => c === card.category).length;
pool.push(card);
weights.push((card.weight ?? 1) * boostFor(card, state, bal) * Math.pow(bal.cards.recentPenalty, seen));
}
return pickWeighted(state, pool, weights);
}
// Called once a weekly pause is pending: puts the card to answer in state.pendingCard.
// A due follow-up always comes first. With no eligible card the week is simply empty.
export function ensureCard(state, bal, cards) {
if (!state.pending.weeklyCard || state.pendingCard) return null;
const byId = indexCards(cards);
let card = null;
const due = state.followUps.findIndex((f) => f.due <= state.day);
if (due >= 0) card = byId.get(state.followUps.splice(due, 1)[0].id) ?? null;
card ??= drawCard(state, bal, cards);
if (!card) {
state.pending.weeklyCard = false;
return null;
}
state.pendingCard = { id: card.id, day: state.day };
return card;
}
export function answerCard(state, bal, cards, choice) {
if (!state.pendingCard || (choice !== "yes" && choice !== "no")) return { ok: false };
const card = indexCards(cards).get(state.pendingCard.id);
if (!card) return { ok: false };
const option = card[choice];
for (const effect of option.effects ?? []) applyEffect(state, bal, effect);
for (const f of option.setFlags ?? []) state.flags[f] = state.day;
for (const f of option.clearFlags ?? []) delete state.flags[f];
if (option.followUp) state.followUps.push({ id: option.followUp.id, due: state.day + option.followUp.inDays });
state.cardHistory.push({ id: card.id, category: card.category, day: state.day, choice });
state.pendingCard = null;
state.pending.weeklyCard = false;
return { ok: true, card };
}
+88
View File
@@ -0,0 +1,88 @@
// Public finances: revenue, spending, interest, debt and GDP. All flows are in
// % of GDP per year; debt and gdp are absolute (billions of the scenario currency).
import { TAX_IDS, clamp } from "./state.js";
// Laffer-style curve: revenue peaks at rate == peak, then falls.
export function taxRevenue(id, rate, bal) {
const t = bal.tax[id];
return t.coef * rate * (1 - rate / (2 * t.peak));
}
export function interestRate(debtRatio, bal) {
const d = bal.debt;
const excess = Math.max(0, debtRatio - d.premiumFrom);
return d.baseRate + d.premiumCoef * excess * excess;
}
export function computeFiscal(state, bal) {
const e = state.economy;
let revenue = e.otherRevenue;
for (const id of TAX_IDS) revenue += taxRevenue(id, e.taxes[id], bal);
const debtRatio = (e.debt / e.gdp) * 100;
const rate = interestRate(debtRatio, bal);
const interest = (debtRatio * rate) / 100;
let allocTotal = 0;
for (const s of Object.values(state.sectors)) allocTotal += s.alloc;
const spending = allocTotal + interest + e.extraSpending;
return { revenue, allocTotal, interest, spending, deficit: spending - revenue, debtRatio, rate };
}
// Derived quantities that indicator/variable targets are written against.
// ref values are captured at game creation so the start is an equilibrium.
export function computeDevs(state, bal, fiscal) {
const e = state.economy;
let taxPressure = 0;
let taxGrowthDrag = 0;
for (const id of TAX_IDS) {
const d = e.taxes[id] - state.ref.taxes[id];
taxPressure += bal.tax[id].opinion * d;
taxGrowthDrag += bal.tax[id].growth * d;
}
const start = bal.crackdown.unrestStart;
return {
growth: e.growth,
unemployment: e.unemployment,
inflation: e.inflation,
debt: Math.max(0, fiscal.debtRatio - bal.debt.freeBelow),
deficit: fiscal.deficit,
taxPressure,
taxGrowthDrag,
// police sent in when unrest is high: 0..1
crackdown: clamp((state.indicators.unrest - start) / (100 - start), 0, 1),
};
}
export function advanceEconomy(state, fiscal) {
const e = state.economy;
e.debt = Math.max(0, e.debt + ((fiscal.deficit / 100) * e.gdp) / 365);
e.gdp *= Math.pow(1 + e.growth / 100, 1 / 365);
}
// Player action, only allowed while the quarterly review is open.
export function setBudget(state, bal, { alloc = {}, taxes = {} } = {}) {
if (!state.pending.review) return { ok: false, errors: ["budget_locked"] };
const errors = [];
for (const [id, v] of Object.entries(alloc)) {
const s = state.sectors[id];
if (!s) errors.push(`unknown_sector:${id}`);
else if (!(v >= 0 && v <= s.need * bal.limits.allocMaxRatio)) errors.push(`alloc_out_of_range:${id}`);
}
for (const [id, v] of Object.entries(taxes)) {
if (!TAX_IDS.includes(id)) errors.push(`unknown_tax:${id}`);
else if (!(v >= 0 && v <= bal.tax[id].peak * bal.limits.taxMaxFactorOfPeak)) errors.push(`tax_out_of_range:${id}`);
}
if (errors.length) return { ok: false, errors };
for (const [id, v] of Object.entries(alloc)) state.sectors[id].alloc = v;
for (const [id, v] of Object.entries(taxes)) state.economy.taxes[id] = v;
return { ok: true, errors: [] };
}
export function confirmBudget(state) {
state.pending.review = false;
}
+161
View File
@@ -0,0 +1,161 @@
// Game creation and the daily tick. Pure: data (balance, scenario) is passed in.
import { STATE_VERSION, IND_IDS, VAR_IDS, qualityFromRatio } from "./state.js";
import { computeFiscal, computeDevs, advanceEconomy } from "./economy.js";
import { applyShifts, ageBias } from "./modifiers.js";
import { stepSectors } from "./sectors.js";
import { stepIndicators, stepVars } from "./indicators.js";
import { checkGameOver } from "./gameover.js";
export const PRONOUNS = ["he", "she", "they"];
// pronouns: omit it and the game opens with the "who are you" question (pending.profile).
export function createGame({ scenario, balance, seed = Date.now(), countryName, lang = "en", pronouns }) {
const se = scenario.economy;
const gdp = se.gdp;
const sectors = {};
for (const [id, s] of Object.entries(scenario.sectors)) {
sectors[id] = { need: s.need, alloc: s.alloc, quality: qualityFromRatio(s.alloc / s.need, balance) };
}
const state = {
version: STATE_VERSION,
seed: seed >>> 0,
rngState: seed >>> 0,
day: 0,
speed: 1, // 0 = paused, otherwise one of balance.time.speeds
startDate: scenario.startDate,
lang,
pronouns: PRONOUNS.includes(pronouns) ? pronouns : "they",
countryName: countryName ?? scenario.name[lang] ?? scenario.name.en,
scenarioId: scenario.id,
economy: {
gdp,
debt: (gdp * se.debtRatio) / 100,
growth: se.growth,
unemployment: se.unemployment,
inflation: se.inflation,
taxes: { ...se.taxes },
otherRevenue: se.otherRevenue,
extraSpending: 0,
},
sectors,
indicators: { ...scenario.indicators },
modifiers: [],
flags: {},
cardHistory: [],
pendingCard: null,
followUps: [],
// Day 0 opens with the pronoun question, the initial budget review and a first card.
// Any true flag here pauses time (see advanceDay).
pending: { profile: !PRONOUNS.includes(pronouns), weeklyCard: true, review: true, alert: false },
over: null,
risk: { revolution: 0, coup: 0, impeachment: 0, defaultZone: false }, // days in each danger zone
alerts: [], // urgent telegrams waiting to be read, oldest first
history: { t: [], series: {} },
log: [],
ref: null,
};
state.ref = {
taxes: { ...se.taxes },
ind: { ...scenario.indicators },
vars: { growth: se.growth, unemployment: se.unemployment, inflation: se.inflation },
sectors: Object.fromEntries(Object.entries(sectors).map(([id, s]) => [id, s.quality])),
ratio: Object.fromEntries(Object.entries(sectors).map(([id, s]) => [id, s.alloc / s.need])),
dev: null,
};
state.ref.dev = computeDevs(state, balance, computeFiscal(state, balance));
recordHistory(state, balance);
return state;
}
function recordHistory(state, bal) {
const f = computeFiscal(state, bal);
const h = state.history;
h.t.push(state.day);
const sample = { debtRatio: f.debtRatio, deficit: f.deficit };
for (const id of IND_IDS) sample[id] = state.indicators[id];
for (const id of VAR_IDS) sample[id] = state.economy[id];
for (const [k, v] of Object.entries(sample)) (h.series[k] ??= []).push(round(v));
}
const round = (v) => Math.round(v * 100) / 100;
export function setSpeed(state, bal, speed) {
if (speed !== 0 && !bal.time.speeds.includes(speed)) return false;
state.speed = speed;
return true;
}
// Clears a pause condition once the player has dealt with it.
export function clearPending(state, kind) {
state.pending[kind] = false;
}
export function setPronouns(state, pronouns) {
if (!PRONOUNS.includes(pronouns)) return false;
state.pronouns = pronouns;
state.pending.profile = false;
return true;
}
// True while the game is waiting on the player (profile, budget review, weekly card, alert).
export const isWaiting = (state) => Object.values(state.pending).some(Boolean);
// Advances one in-game day unless the game is over or waiting on the player.
export function advanceDay(state, bal) {
if (state.over) return { advanced: false, blocked: "over", events: null };
if (isWaiting(state)) return { advanced: false, blocked: "pending", events: null };
applyShifts(state, bal);
const fiscal = computeFiscal(state, bal);
const devs = computeDevs(state, bal, fiscal);
advanceEconomy(state, fiscal);
stepVars(state, bal, devs);
stepIndicators(state, bal, devs);
stepSectors(state, bal, fiscal);
ageBias(state);
state.day += 1;
const events = { weeklyCard: false, review: false, termEnd: false, gameOver: null };
const fallen = checkGameOver(state, bal);
if (fallen) {
state.over = { type: fallen, day: state.day };
events.gameOver = fallen;
recordHistory(state, bal);
} else if (state.day >= bal.time.termDays) {
state.over = { type: "term", day: state.day };
events.termEnd = true;
recordHistory(state, bal);
} else {
if (state.day % bal.time.historyEvery === 0) recordHistory(state, bal);
if (state.day % bal.time.daysPerWeek === 0) state.pending.weeklyCard = events.weeklyCard = true;
if (state.day % bal.time.daysPerQuarter === 0) state.pending.review = events.review = true;
}
return { advanced: true, blocked: null, events };
}
// Read-only snapshot for the UI and the sim.
export function summarize(state, bal) {
const f = computeFiscal(state, bal);
return {
day: state.day,
gdp: state.economy.gdp,
debt: state.economy.debt,
debtRatio: f.debtRatio,
interestRate: f.rate,
revenue: f.revenue,
spending: f.spending,
interest: f.interest,
deficit: f.deficit,
growth: state.economy.growth,
unemployment: state.economy.unemployment,
inflation: state.economy.inflation,
indicators: { ...state.indicators },
};
}
+63
View File
@@ -0,0 +1,63 @@
// Ways the term can end early. Checked once per day after the economy has moved.
//
// default debt above the limit: immediate
// impeachment civil rights and opinion both very low for N days in a row: certain
// coup army loyalty and stability both very low: a daily chance that grows each day in the zone
// revolution unrest very high and opinion very low: same growing daily chance
//
// Entering a danger zone queues an urgent telegram (state.alerts) and pauses the game
// until the player acknowledges it. state.risk counts the days spent in each zone.
import { nextFloat } from "./rng.js";
function alert(state, kind) {
state.alerts.push({ kind, day: state.day });
state.pending.alert = true;
}
// Returns the game-over type or null. Mutates state.risk, state.alerts and the RNG.
export function checkGameOver(state, bal) {
const g = bal.gameOver;
const ind = state.indicators;
const risk = state.risk;
const debtRatio = (state.economy.debt / state.economy.gdp) * 100;
// Zone bookkeeping: count consecutive days inside, fade (or reset) outside.
const track = (name, inZone, fade) => {
if (inZone) {
risk[name] += 1;
if (risk[name] === 1) alert(state, name);
} else {
risk[name] = fade === "reset" ? 0 : Math.max(0, risk[name] - fade);
}
};
const chance = (name, perDay) => risk[name] > 0 && nextFloat(state) < Math.min(1, perDay * risk[name]);
if (debtRatio >= g.default.warnAbove) {
if (!risk.defaultZone) {
risk.defaultZone = true;
alert(state, "default");
}
} else if (debtRatio < g.default.warnAbove - 10) {
risk.defaultZone = false;
}
const inImpeachment = ind.civilRights < g.impeachment.civilRightsBelow && ind.opinion < g.impeachment.opinionBelow;
const inCoup = ind.armyLoyalty < g.coup.armyBelow && ind.stability < g.coup.stabilityBelow;
const inRevolution = ind.unrest > g.revolution.unrestAbove && ind.opinion < g.revolution.opinionBelow;
track("impeachment", inImpeachment, "reset");
track("coup", inCoup, 1);
track("revolution", inRevolution, 1);
if (debtRatio > g.default.debtAbove) return "default";
if (risk.impeachment >= g.impeachment.days) return "impeachment";
if (inCoup && chance("coup", g.coup.chancePerDay)) return "coup";
if (inRevolution && chance("revolution", g.revolution.chancePerDay)) return "revolution";
return null;
}
// The player has read the telegram at the front of the queue.
export function dismissAlert(state) {
state.alerts.shift();
state.pending.alert = state.alerts.length > 0;
}
+14
View File
@@ -0,0 +1,14 @@
// Public engine API. The UI and tools import from here only.
export { createGame, advanceDay, clearPending, setSpeed, setPronouns, isWaiting, summarize, PRONOUNS } from "./game.js";
export { dismissAlert } from "./gameover.js";
export { buildReview, optionImpact, METRIC_IDS } from "./score.js";
export { restoreState } from "./validate.js";
export { setBudget, confirmBudget, computeFiscal, taxRevenue } from "./economy.js";
export { previewBudget } from "./projection.js";
export { qualityFromRatio } from "./state.js";
export { applyEffect } from "./modifiers.js";
export { ensureCard, answerCard, drawCard, indexCards } from "./cards.js";
export { createClock, consumeClock, dateOfDay, weekOfDay, quarterOfDay } from "./time.js";
export { nextFloat, nextInt, pickWeighted } from "./rng.js";
export { IND_IDS, VAR_IDS, TAX_IDS } from "./state.js";
+47
View File
@@ -0,0 +1,47 @@
// Indicators and economy variables move toward a target each day:
// target = start value + sum of weighted deviations from the start state + active biases
// Because every term is a deviation from the scenario start, the start is an equilibrium.
import { IND_IDS, VAR_IDS, clamp } from "./state.js";
import { biasFor } from "./modifiers.js";
import { nextNoise } from "./rng.js";
function computeTarget(def, base, group, key, state, devs) {
let t = base;
for (const [id, w] of Object.entries(def.sectors ?? {})) {
t += w * (state.sectors[id].quality - state.ref.sectors[id]);
}
for (const [id, w] of Object.entries(def.alloc ?? {})) {
const s = state.sectors[id];
t += w * (s.alloc / s.need - state.ref.ratio[id]);
}
for (const [k, w] of Object.entries(def.econ ?? {})) {
t += w * (devs[k] - state.ref.dev[k]);
}
for (const [k, w] of Object.entries(def.ind ?? {})) {
t += w * (state.indicators[k] - state.ref.ind[k]);
}
return t + biasFor(state, group, key);
}
export function stepVars(state, bal, devs) {
const next = {};
for (const id of VAR_IDS) {
const def = bal.vars[id];
const target = computeTarget(def, state.ref.vars[id], "var", id, state, devs);
const v = state.economy[id];
next[id] = clamp(v + (target - v) / def.tau + nextNoise(state, def.noise), def.min, def.max);
}
Object.assign(state.economy, next);
}
export function stepIndicators(state, bal, devs) {
const next = {};
for (const id of IND_IDS) {
const def = bal.indicators[id];
const target = computeTarget(def, state.ref.ind[id], "ind", id, state, devs);
const v = state.indicators[id];
next[id] = clamp(v + (target - v) / def.tau + nextNoise(state, def.noise ?? bal.noise.indicator), 0, 100);
}
Object.assign(state.indicators, next);
}
+84
View File
@@ -0,0 +1,84 @@
// Effects and timed modifiers. Card choices end up here.
//
// Effect shapes:
// { stat, delta } instant shift of an indicator or economy variable
// { stat, delta, days } shift spread evenly over `days` (then the value mean-reverts)
// { stat, delta, bias: true, days? } offset on the stat's *target* for `days` (permanent if omitted)
// { sector, delta[, days][, bias] } same, on a sector's quality
// { revenue: "income"|"corporate"|"vat", delta } change tax rate (rate points), permanent
// { revenue: "other", delta } change other revenue (% of GDP), permanent
// { oneOff: pct } one-time cost in % of GDP (negative = windfall)
// { extraSpending: delta } permanent extra spending in % of GDP
import { IND_IDS, VAR_IDS, TAX_IDS, clamp } from "./state.js";
function groupOf(effect, state) {
if (effect.sector !== undefined) return state.sectors[effect.sector] ? ["sector", effect.sector] : null;
if (IND_IDS.includes(effect.stat)) return ["ind", effect.stat];
if (VAR_IDS.includes(effect.stat)) return ["var", effect.stat];
return null;
}
function shift(state, bal, group, key, delta) {
if (group === "ind") state.indicators[key] = clamp(state.indicators[key] + delta, 0, 100);
else if (group === "sector") state.sectors[key].quality = clamp(state.sectors[key].quality + delta, 0, 100);
else {
const v = bal.vars[key];
state.economy[key] = clamp(state.economy[key] + delta, v.min, v.max);
}
}
export function applyEffect(state, bal, effect) {
if (effect.stat !== undefined || effect.sector !== undefined) {
const found = groupOf(effect, state);
if (!found) return false;
const [group, key] = found;
if (effect.bias) {
state.modifiers.push({ type: "bias", group, key, value: effect.delta, remaining: effect.days ?? null });
} else if (effect.days > 0) {
state.modifiers.push({ type: "shift", group, key, perDay: effect.delta / effect.days, remaining: effect.days });
} else {
shift(state, bal, group, key, effect.delta);
}
return true;
}
const e = state.economy;
if (effect.revenue !== undefined) {
if (TAX_IDS.includes(effect.revenue)) e.taxes[effect.revenue] = Math.max(0, e.taxes[effect.revenue] + effect.delta);
else if (effect.revenue === "other") e.otherRevenue = Math.max(0, e.otherRevenue + effect.delta);
else return false;
return true;
}
if (effect.oneOff !== undefined) {
e.debt = Math.max(0, e.debt + (effect.oneOff / 100) * e.gdp);
return true;
}
if (effect.extraSpending !== undefined) {
e.extraSpending += effect.extraSpending;
return true;
}
return false;
}
// Start of each day: apply the spread-out shifts.
export function applyShifts(state, bal) {
for (const m of state.modifiers) {
if (m.type !== "shift") continue;
shift(state, bal, m.group, m.key, m.perDay);
m.remaining -= 1;
}
state.modifiers = state.modifiers.filter((m) => m.type !== "shift" || m.remaining > 0);
}
// Sum of active target offsets for one stat.
export function biasFor(state, group, key) {
let sum = 0;
for (const m of state.modifiers) if (m.type === "bias" && m.group === group && m.key === key) sum += m.value;
return sum;
}
// End of each day: age timed biases.
export function ageBias(state) {
for (const m of state.modifiers) if (m.type === "bias" && m.remaining !== null) m.remaining -= 1;
state.modifiers = state.modifiers.filter((m) => m.type !== "bias" || m.remaining === null || m.remaining > 0);
}
+44
View File
@@ -0,0 +1,44 @@
// Budget preview for the quarterly ledger: applies a draft budget to a copy of
// the game and runs the real daily tick forward, without noise, so the player
// sees where debt, deficit and the main indicators are heading.
// It assumes no new policies (future cards are unknown).
import { advanceDay, summarize } from "./game.js";
import { computeFiscal, setBudget } from "./economy.js";
const quietCache = new WeakMap();
// Same balance with all daily noise switched off.
function quietBalance(bal) {
let q = quietCache.get(bal);
if (!q) {
q = structuredClone(bal);
q.noise.indicator = 0;
for (const def of Object.values(q.vars)) def.noise = 0;
for (const def of Object.values(q.indicators)) if ("noise" in def) def.noise = 0;
quietCache.set(bal, q);
}
return q;
}
// Returns { ok, errors, fiscal, points }. points[0] is "now with the draft applied";
// points[i] is the state i quarters later (fewer if the term ends first).
export function previewBudget(state, bal, draft, { quarters = 4 } = {}) {
const sim = structuredClone(state);
sim.pending.review = true; // the preview may edit the budget even outside a review
const res = setBudget(sim, bal, draft);
if (!res.ok) return { ok: false, errors: res.errors, fiscal: null, points: [] };
const quiet = quietBalance(bal);
const fiscal = computeFiscal(sim, quiet);
const points = [summarize(sim, quiet)];
for (let q = 0; q < quarters && !sim.over; q++) {
for (let d = 0; d < bal.time.daysPerQuarter && !sim.over; d++) {
for (const k of Object.keys(sim.pending)) sim.pending[k] = false;
advanceDay(sim, quiet);
}
points.push(summarize(sim, quiet));
}
return { ok: true, errors: [], fiscal, points };
}
+31
View File
@@ -0,0 +1,31 @@
// mulberry32 PRNG. The generator state lives in state.rngState (a uint32) so a
// saved game resumes with the exact same sequence.
export function nextFloat(state) {
state.rngState = (state.rngState + 0x6d2b79f5) >>> 0;
let t = state.rngState;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
// Uniform in [-amp, amp].
export function nextNoise(state, amp) {
return (nextFloat(state) * 2 - 1) * amp;
}
export function nextInt(state, maxExclusive) {
return Math.floor(nextFloat(state) * maxExclusive);
}
export function pickWeighted(state, items, weights) {
let total = 0;
for (const w of weights) total += w;
if (total <= 0) return null;
let r = nextFloat(state) * total;
for (let i = 0; i < items.length; i++) {
r -= weights[i];
if (r < 0) return items[i];
}
return items[items.length - 1];
}
+93
View File
@@ -0,0 +1,93 @@
// End-of-term review: a score from the whole term (not just the last day), a letter
// grade, the historian's title and the decisions that mattered most.
// Pure: works for a finished term and for a game that ended early.
import { clamp } from "./state.js";
import { indexCards } from "./cards.js";
// Names usable in the `min` / `max` conditions of endgame.json titles.
export const METRIC_IDS = [
"order", "stability", "civilRights", "opinion", "economy", "hardPower", "softPower", "unrest", "armyLoyalty",
"growth", "unemployment", "inflation", "debt", "score", "years",
];
const mean = (xs) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : 0);
// Averages over the whole term, plus the final debt ratio.
function termAverages(state) {
const s = state.history.series;
const m = {};
for (const id of ["order", "stability", "civilRights", "opinion", "economy", "hardPower", "softPower", "unrest", "armyLoyalty", "growth", "unemployment", "inflation"]) {
m[id] = mean(s[id] ?? []);
}
m.debt = s.debtRatio?.at(-1) ?? 0;
m.years = state.day / 365;
return m;
}
function computeScore(m, bal) {
const sc = bal.score;
let base = 0;
for (const [id, w] of Object.entries(sc.weights)) base += w * (id === "calm" ? 100 - m.unrest : m[id]);
const bonus = (cfg, value) => clamp((cfg.ref - value) * cfg.per, cfg.min, cfg.max);
const total = base
+ bonus(sc.debt, m.debt)
+ clamp((m.growth - sc.growth.ref) * sc.growth.per, sc.growth.min, sc.growth.max)
+ bonus(sc.unemployment, m.unemployment);
return clamp(total, 0, 100);
}
const gradeOf = (score, bal) => bal.score.grades.find(([min]) => score >= min)?.[1] ?? "F";
// How strongly one effect moves the country, in rough "points". Used to rank decisions.
export function effectImpact(e) {
const d = Math.abs(e.delta ?? 0);
if (e.stat !== undefined || e.sector !== undefined) {
if (e.bias) return d * clamp((e.days ?? 365) / 90, 1, 8);
return d;
}
if (e.revenue !== undefined) return d * (e.revenue === "other" ? 8 : 2);
if (e.oneOff !== undefined) return Math.abs(e.oneOff) * 5;
if (e.extraSpending !== undefined) return Math.abs(e.extraSpending) * 8;
return 0;
}
export const optionImpact = (option) => (option.effects ?? []).reduce((sum, e) => sum + effectImpact(e), 0);
function topDecisions(state, cards, count) {
const byId = indexCards(cards);
return state.cardHistory
.map((h) => {
const card = byId.get(h.id);
return card ? { id: h.id, day: h.day, choice: h.choice, impact: optionImpact(card[h.choice]) } : null;
})
.filter(Boolean)
.sort((a, b) => b.impact - a.impact || a.day - b.day)
.slice(0, count);
}
function meets(entry, m) {
const c = entry.conditions ?? {};
for (const [k, v] of Object.entries(c.min ?? {})) if (!(m[k] >= v)) return false;
for (const [k, v] of Object.entries(c.max ?? {})) if (!(m[k] <= v)) return false;
return true;
}
// endgame: the parsed data/endgame.json (its `titles` list is in priority order).
export function buildReview(state, bal, cards, endgame) {
const metrics = termAverages(state);
let score = computeScore(metrics, bal);
if (state.over && state.over.type !== "term") score = Math.min(score, bal.score.overCap);
metrics.score = score;
const earlyEnd = state.over && state.over.type !== "term" ? state.over.type : null;
const title = endgame.titles.find((t) => (t.over ?? null) === earlyEnd && (earlyEnd || meets(t, metrics)));
return {
over: state.over,
metrics,
score,
grade: earlyEnd ? "F" : gradeOf(score, bal),
titleId: title?.id ?? null,
topDecisions: topDecisions(state, cards, bal.score.topDecisions),
};
}
+16
View File
@@ -0,0 +1,16 @@
// Sector quality drifts toward a target set by the budget ratio (alloc / need).
// Each sector has its own time constant: police reacts in weeks, infrastructure in years.
import { clamp, qualityFromRatio } from "./state.js";
import { biasFor } from "./modifiers.js";
export function stepSectors(state, bal, fiscal) {
const s = bal.sector;
for (const [id, sec] of Object.entries(state.sectors)) {
let target = qualityFromRatio(sec.alloc / sec.need, bal) + biasFor(state, "sector", id);
if (id === "industry" && fiscal.debtRatio > s.industryDebtPenaltyFrom) {
target -= s.industryDebtPenaltyPerPoint * (fiscal.debtRatio - s.industryDebtPenaltyFrom);
}
sec.quality = clamp(sec.quality + (clamp(target, 0, 100) - sec.quality) / bal.sectorTau[id], 0, 100);
}
}
+17
View File
@@ -0,0 +1,17 @@
// Shared constants and tiny helpers. No game logic here.
export const STATE_VERSION = 1;
export const IND_IDS = [
"order", "stability", "civilRights", "opinion", "economy",
"hardPower", "softPower", "unrest", "armyLoyalty",
];
export const VAR_IDS = ["growth", "unemployment", "inflation"];
export const TAX_IDS = ["income", "corporate", "vat"];
export const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
// Saturating curve: overspending has diminishing returns, ratio 0 gives 0.
export function qualityFromRatio(ratio, bal) {
return 100 * (1 - Math.exp(-bal.sector.qualityCurve * Math.max(0, ratio)));
}
+31
View File
@@ -0,0 +1,31 @@
// Real-time clock accumulator and in-game calendar helpers.
export function createClock() {
return { acc: 0 };
}
// Feed it the real elapsed ms; it returns how many in-game days are due.
// dt is capped so a background tab doesn't fast-forward the game on return, and
// floored at 0: the first animation frame can carry a timestamp slightly before
// the one the caller recorded, and a negative step would pre-load a whole day.
export function consumeClock(clock, dtMs, msPerDay, speed) {
clock.acc += Math.max(0, Math.min(dtMs, 1000)) * speed;
const days = Math.floor(clock.acc / msPerDay);
clock.acc -= days * msPerDay;
return days;
}
export function dateOfDay(startDate, day) {
const [y, m, d] = startDate.split("-").map(Number);
const date = new Date(Date.UTC(y, m - 1, d + day));
return {
year: date.getUTCFullYear(),
month: date.getUTCMonth() + 1,
day: date.getUTCDate(),
weekday: date.getUTCDay(), // 0 = Sunday
iso: date.toISOString().slice(0, 10),
};
}
export const weekOfDay = (day, bal) => Math.floor(day / bal.time.daysPerWeek);
export const quarterOfDay = (day, bal) => Math.floor(day / bal.time.daysPerQuarter);
+96
View File
@@ -0,0 +1,96 @@
// Checks a game loaded from a save (autosave, slot or imported string) before the game
// resumes from it, and repairs what can be repaired. A hostile or damaged save must never
// get as far as the UI: every number is finite and in range, every id is known.
import { STATE_VERSION, IND_IDS, VAR_IDS, TAX_IDS } from "./state.js";
import { PRONOUNS } from "./game.js";
const DEV_KEYS = ["growth", "unemployment", "inflation", "debt", "deficit", "taxPressure", "taxGrowthDrag", "crackdown"];
const HISTORY_SERIES = ["debtRatio", "deficit", ...IND_IDS, ...VAR_IDS];
const OVER_TYPES = ["term", "revolution", "coup", "default", "impeachment"];
const ALERT_KINDS = ["revolution", "coup", "impeachment", "default"];
const PENDING_KINDS = ["profile", "weeklyCard", "review", "alert"];
const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
const isNum = (v, lo = -Infinity, hi = Infinity) => typeof v === "number" && Number.isFinite(v) && v >= lo && v <= hi;
const isInt = (v, lo, hi) => Number.isInteger(v) && v >= lo && v <= hi;
const sameKeys = (obj, keys) => isObj(obj) && Object.keys(obj).length === keys.length && keys.every((k) => k in obj);
const allNums = (obj, keys, lo, hi) => sameKeys(obj, keys) && keys.every((k) => isNum(obj[k], lo, hi));
// Returns { ok: true, state } (repaired in place) or { ok: false, error: "invalid" | "scenario" | "version" }.
export function restoreState(s, { scenarios, balance, cards }) {
const fail = (error = "invalid") => ({ ok: false, error });
if (!isObj(s) || s.version !== STATE_VERSION) return fail("version");
const scenario = scenarios[s.scenarioId];
if (!scenario) return fail("scenario");
const lim = balance.limits;
const cardIds = new Set(cards.map((c) => c.id));
const sectorIds = Object.keys(scenario.sectors);
const modGroups = { ind: IND_IDS, var: VAR_IDS, sector: sectorIds };
// Scalars
if (!isInt(s.day, 0, balance.time.termDays)) return fail();
if (!isInt(s.rngState, 0, 0xffffffff) || !isInt(s.seed, 0, 0xffffffff)) return fail();
if (s.speed !== 0 && !balance.time.speeds.includes(s.speed)) return fail();
if (s.startDate !== scenario.startDate) return fail();
if (!PRONOUNS.includes(s.pronouns)) return fail();
if (typeof s.countryName !== "string" || s.countryName.length < 1 || s.countryName.length > 60) return fail();
if (s.lang !== "en" && s.lang !== "fr") s.lang = "en";
// Economy, sectors, indicators
const e = s.economy;
if (!isObj(e) || !isNum(e.gdp, 1e-6) || !isNum(e.debt, 0) || !isNum(e.otherRevenue) || !isNum(e.extraSpending)) return fail();
if (!isNum(e.growth, -100, 100) || !isNum(e.unemployment, 0, 100) || !isNum(e.inflation, -100, 1000)) return fail();
if (!allNums(e.taxes, TAX_IDS, 0, 500)) return fail();
if (!sameKeys(s.sectors, sectorIds)) return fail();
for (const id of sectorIds) {
const x = s.sectors[id];
if (!isObj(x) || !isNum(x.need, 1e-6) || !isNum(x.alloc, 0, x.need * lim.allocMaxRatio + 1e-9) || !isNum(x.quality, 0, 100)) return fail();
}
if (!allNums(s.indicators, IND_IDS, 0, 100)) return fail();
// Reference values captured at the start of the game
const r = s.ref;
if (!isObj(r) || !allNums(r.taxes, TAX_IDS, 0, 500) || !allNums(r.ind, IND_IDS, 0, 100)) return fail();
if (!allNums(r.vars, VAR_IDS) || !allNums(r.sectors, sectorIds) || !allNums(r.ratio, sectorIds) || !allNums(r.dev, DEV_KEYS)) return fail();
// Timed modifiers
if (!Array.isArray(s.modifiers)) return fail();
for (const m of s.modifiers) {
if (!isObj(m) || !(m.group in modGroups) || !modGroups[m.group].includes(m.key)) return fail();
if (m.type === "shift" ? !(isNum(m.perDay) && isNum(m.remaining, 1)) : !(m.type === "bias" && isNum(m.value) && (m.remaining === null || isNum(m.remaining, 1)))) return fail();
}
// Decisions and flags
if (!isObj(s.flags) || !Object.values(s.flags).every((v) => isNum(v))) return fail();
if (!Array.isArray(s.cardHistory)) return fail();
for (const h of s.cardHistory) {
if (!isObj(h) || typeof h.id !== "string" || typeof h.category !== "string" || !isInt(h.day, 0, balance.time.termDays) || (h.choice !== "yes" && h.choice !== "no")) return fail();
}
if (!Array.isArray(s.followUps) || !s.followUps.every((f) => isObj(f) && typeof f.id === "string" && isNum(f.due))) return fail();
if (s.pendingCard !== null && !(isObj(s.pendingCard) && typeof s.pendingCard.id === "string" && isInt(s.pendingCard.day, 0, balance.time.termDays))) return fail();
// Pauses, alerts and endings
if (!isObj(s.pending)) return fail();
for (const k of PENDING_KINDS) s.pending[k] = s.pending[k] === true;
if (!Array.isArray(s.alerts) || !s.alerts.every((a) => isObj(a) && ALERT_KINDS.includes(a.kind) && isInt(a.day, 0, balance.time.termDays))) return fail();
if (!isObj(s.risk) || ![s.risk.revolution, s.risk.coup, s.risk.impeachment].every((v) => isNum(v, 0)) || typeof s.risk.defaultZone !== "boolean") return fail();
if (s.over !== null && !(isObj(s.over) && OVER_TYPES.includes(s.over.type) && isInt(s.over.day, 0, balance.time.termDays))) return fail();
// History for the charts: one equal-length series per tracked value
const h = s.history;
if (!isObj(h) || !Array.isArray(h.t) || !isObj(h.series) || !h.t.every((d) => isInt(d, 0, balance.time.termDays))) return fail();
for (const id of HISTORY_SERIES) {
const series = h.series[id];
if (!Array.isArray(series) || series.length !== h.t.length || !series.every((v) => isNum(v))) return fail();
}
if (!Array.isArray(s.log)) return fail();
// Repairs: content that changed since the save was made
s.followUps = s.followUps.filter((f) => cardIds.has(f.id));
if (s.pendingCard && !cardIds.has(s.pendingCard.id)) s.pendingCard = null;
s.pending.alert = s.alerts.length > 0;
return { ok: true, state: s };
}