First commit, done on M6 from the PLAN.MD
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user