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 };
}
+189
View File
@@ -0,0 +1,189 @@
{
"app.title": "The Desk",
"dash.title": "Ministerial Briefing",
"dash.confidential": "Strictly confidential",
"dash.econ": "Economic figures",
"dash.intel": "Intelligence summary",
"dash.classified": "Classified",
"ind.order": "Order",
"ind.stability": "Stability",
"ind.civilRights": "Civil rights",
"ind.opinion": "Public opinion",
"ind.economy": "Economy",
"ind.hardPower": "Hard power",
"ind.softPower": "Soft power",
"ind.unrest": "Unrest",
"ind.armyLoyalty": "Army loyalty",
"opinion.0": "Angry",
"opinion.1": "Hostile",
"opinion.2": "Uneasy",
"opinion.3": "Neutral",
"opinion.4": "Content",
"opinion.5": "Happy",
"opinion.6": "Ecstatic",
"unrest.0": "Calm",
"unrest.1": "Restless",
"unrest.2": "Tense",
"unrest.3": "Volatile",
"unrest.4": "Explosive",
"army.0": "Mutinous",
"army.1": "Wavering",
"army.2": "Steady",
"army.3": "Loyal",
"econ.gdp": "GDP",
"econ.growth": "Growth",
"econ.unemployment": "Unemployment",
"econ.inflation": "Inflation",
"econ.debt": "Debt / GDP",
"econ.deficit": "Deficit / GDP",
"econ.rate": "Interest rate",
"econ.gdpUnit": "bn {currency}",
"cal.dayCount": "Day {day} of {total}",
"cal.briefing": "Next briefing",
"cal.review": "Budget review",
"cal.inDays": "in {n} days",
"cal.today": "today",
"cal.tomorrow": "tomorrow",
"cal.pause": "Pause",
"cal.speed": "Speed {n}×",
"inbox.title": "In-tray",
"inbox.idle": "Nothing awaits your signature. The country carries on.",
"inbox.overTitle": "End of term",
"inbox.overBody": "Your term has ended. (The final review arrives in a later build.)",
"news.edition": "Edition of {date}",
"sector.education": "Education",
"sector.health": "Health",
"sector.social": "Social & pensions",
"sector.police": "Police",
"sector.army": "Army",
"sector.justice": "Justice",
"sector.industry": "Industry & energy",
"sector.transport": "Public transport",
"sector.infrastructure": "Infrastructure",
"sector.housing": "Housing",
"sector.research": "Research",
"sector.culture": "Culture & media",
"sector.foreignAffairs": "Foreign affairs",
"sector.environment": "Environment",
"sector.administration": "Administration",
"tax.income": "Income tax",
"tax.corporate": "Corporate tax",
"tax.vat": "VAT",
"ledger.title": "Treasury ledger",
"ledger.spending": "Spending, % of GDP",
"ledger.spendingHint": "The tick on each line marks what the sector needs.",
"ledger.totalAlloc": "Allocated to sectors",
"ledger.revenue": "Revenue: tax rates",
"ledger.revenueHint": "The tick marks the rate where revenue peaks. Beyond it, you collect less.",
"ledger.yield": "{n} % of GDP",
"ledger.quality": "Quality {now} → {target}",
"ledger.summary": "Treasury summary",
"ledger.sumRevenue": "Revenue",
"ledger.sumSectors": "Sector spending",
"ledger.sumInterest": "Debt interest",
"ledger.sumSpending": "Total spending",
"ledger.sumSurplus": "Surplus",
"ledger.sumDeficit": "Deficit",
"ledger.perYear": "{n} bn {currency} per year",
"ledger.projection": "Next four quarters, if nothing else changes",
"ledger.now": "Now",
"ledger.months": "+{n} mo",
"ledger.reset": "Reset",
"ledger.confirm": "Sign and file",
"ledger.error": "The Treasury rejected this budget.",
"kind.memo": "Memorandum",
"kind.telegram": "Telegram",
"kind.petition": "Petition",
"kind.report": "Report",
"kind.letter": "Letter",
"card.approved": "Approved",
"card.rejected": "Rejected",
"card.keys": "Keys: Y approve · N reject",
"card.from": "From",
"profile.title": "Sworn in",
"profile.lead": "Before you take office, the Press Office needs one thing for the official records.",
"profile.body": "How should the press refer to you?",
"profile.he": "He / Him",
"profile.she": "She / Her",
"profile.they": "They / Them",
"profile.note": "This only changes how you are named in the history books.",
"alert.urgent": "Urgent telegram",
"alert.ack": "Understood",
"ind.regime": "Hold on power",
"regime.0": "Secure",
"regime.1": "Shaky",
"regime.2": "Critical",
"end.verdict": "The historians' verdict",
"end.score": "Score {n} / 100",
"end.grade": "Grade",
"end.served": "Served {days} days",
"end.decisions": "Decisions that defined the term",
"end.noDecisions": "No decision was signed.",
"end.chose": "You chose: {label}",
"end.again": "Play again",
"end.averages": "avg",
"end.final": "end",
"cal.menu": "Menu",
"menu.secret": "Top secret",
"menu.tagline": "A country. Five years. Every decision carries your signature.",
"menu.paused": "Game paused",
"menu.continue": "Continue",
"menu.resume": "Resume",
"menu.new": "New game",
"menu.load": "Load game",
"menu.save": "Save game",
"menu.export": "Export",
"menu.import": "Import",
"menu.settings": "Settings",
"menu.credits": "Credits",
"menu.quit": "Main menu",
"menu.back": "Back",
"menu.info": "{country} · day {day}",
"new.title": "New game",
"new.name": "Name of the country",
"new.scenario": "Scenario",
"new.pronouns": "How should the press refer to you?",
"new.start": "Take office",
"load.title": "Load game",
"load.auto": "Autosave",
"load.slot": "Slot {n}",
"load.empty": "Empty",
"load.info": "{country} · day {day}{date}",
"load.finished": "finished",
"load.unreadable": "Unreadable save",
"load.load": "Load",
"load.delete": "Delete",
"load.sure": "Sure?",
"load.replace": "Loading replaces the game in progress.",
"save.title": "Save game",
"save.here": "Save here",
"save.overwrite": "Overwrite",
"save.done": "Saved to slot {n}.",
"save.failed": "The browser refused the save. Use Export to keep your game.",
"export.title": "Export",
"export.hint": "Copy this text or download the file. You can import it later, on any device.",
"export.copy": "Copy",
"export.copied": "Copied.",
"export.copyFailed": "Select the text and copy it by hand.",
"export.download": "Download .sav",
"import.title": "Import",
"import.hint": "Paste a save string, or choose a .sav file.",
"import.file": "Choose a file",
"import.go": "Import",
"import.error.format": "That does not look like a save string.",
"import.error.checksum": "The save is damaged or incomplete (checksum mismatch).",
"import.error.tooNew": "This save comes from a newer version of the game.",
"import.error.version": "This save is from a version that cannot be converted.",
"import.error.invalid": "The contents of this save are not valid.",
"import.error.scenario": "This save uses a scenario that is not installed.",
"import.error.empty": "Nothing to import yet.",
"settings.title": "Settings",
"settings.language": "Language",
"settings.slow": "Slow time",
"settings.slowHint": "One in-game day takes 60 seconds instead of 10.",
"settings.storage": "This browser is not keeping saves between visits (private window or blocked storage). Use Export to keep your game.",
"credits.title": "Credits",
"credits.fiction": "Valdoria and everyone in it are fictional.",
"credits.tech": "Built with Go and plain JavaScript, with no third-party libraries.",
"credits.fonts": "Fonts: Special Elite (Apache License 2.0), IM Fell English and Courier Prime (SIL Open Font License). The licence files are shipped with the game."
}
+189
View File
@@ -0,0 +1,189 @@
{
"app.title": "Le Bureau",
"dash.title": "Note ministérielle",
"dash.confidential": "Strictement confidentiel",
"dash.econ": "Chiffres économiques",
"dash.intel": "Synthèse du renseignement",
"dash.classified": "Classifié",
"ind.order": "Ordre public",
"ind.stability": "Stabilité",
"ind.civilRights": "Droits civiques",
"ind.opinion": "Opinion publique",
"ind.economy": "Économie",
"ind.hardPower": "Puissance militaire",
"ind.softPower": "Influence",
"ind.unrest": "Agitation",
"ind.armyLoyalty": "Loyauté de l'armée",
"opinion.0": "En colère",
"opinion.1": "Hostile",
"opinion.2": "Inquiète",
"opinion.3": "Neutre",
"opinion.4": "Satisfaite",
"opinion.5": "Heureuse",
"opinion.6": "Extatique",
"unrest.0": "Calme",
"unrest.1": "Agité",
"unrest.2": "Tendu",
"unrest.3": "Volatil",
"unrest.4": "Explosif",
"army.0": "Mutinée",
"army.1": "Hésitante",
"army.2": "Stable",
"army.3": "Loyale",
"econ.gdp": "PIB",
"econ.growth": "Croissance",
"econ.unemployment": "Chômage",
"econ.inflation": "Inflation",
"econ.debt": "Dette / PIB",
"econ.deficit": "Déficit / PIB",
"econ.rate": "Taux d'intérêt",
"econ.gdpUnit": "Md {currency}",
"cal.dayCount": "Jour {day} sur {total}",
"cal.briefing": "Prochain dossier",
"cal.review": "Revue budgétaire",
"cal.inDays": "dans {n} jours",
"cal.today": "aujourd'hui",
"cal.tomorrow": "demain",
"cal.pause": "Pause",
"cal.speed": "Vitesse {n}×",
"inbox.title": "Corbeille d'arrivée",
"inbox.idle": "Rien n'attend votre signature. Le pays poursuit sa route.",
"inbox.overTitle": "Fin de mandat",
"inbox.overBody": "Votre mandat est terminé. (Le bilan final arrive dans une prochaine version.)",
"news.edition": "Édition du {date}",
"sector.education": "Éducation",
"sector.health": "Santé",
"sector.social": "Social, retraites",
"sector.police": "Police",
"sector.army": "Armée",
"sector.justice": "Justice",
"sector.industry": "Industrie, énergie",
"sector.transport": "Transports publics",
"sector.infrastructure": "Infrastructures",
"sector.housing": "Logement",
"sector.research": "Recherche",
"sector.culture": "Culture, médias",
"sector.foreignAffairs": "Diplomatie",
"sector.environment": "Environnement",
"sector.administration": "Administration",
"tax.income": "Impôt revenu",
"tax.corporate": "Impôt sociétés",
"tax.vat": "TVA",
"ledger.title": "Registre du Trésor",
"ledger.spending": "Dépenses, % du PIB",
"ledger.spendingHint": "Le repère sur chaque ligne marque le besoin du secteur.",
"ledger.totalAlloc": "Alloué aux secteurs",
"ledger.revenue": "Recettes : taux d'imposition",
"ledger.revenueHint": "Le repère marque le taux où les recettes sont maximales. Au-delà, vous collectez moins.",
"ledger.yield": "{n} % du PIB",
"ledger.quality": "Qualité {now} → {target}",
"ledger.summary": "Synthèse du Trésor",
"ledger.sumRevenue": "Recettes",
"ledger.sumSectors": "Dépenses sectorielles",
"ledger.sumInterest": "Intérêts de la dette",
"ledger.sumSpending": "Dépenses totales",
"ledger.sumSurplus": "Excédent",
"ledger.sumDeficit": "Déficit",
"ledger.perYear": "{n} Md {currency} par an",
"ledger.projection": "Quatre prochains trimestres, sans autre changement",
"ledger.now": "Auj.",
"ledger.months": "+{n} m",
"ledger.reset": "Réinitialiser",
"ledger.confirm": "Signer et classer",
"ledger.error": "Le Trésor a rejeté ce budget.",
"kind.memo": "Mémorandum",
"kind.telegram": "Télégramme",
"kind.petition": "Pétition",
"kind.report": "Rapport",
"kind.letter": "Lettre",
"card.approved": "Approuvé",
"card.rejected": "Rejeté",
"card.keys": "Touches : Y approuver · N rejeter",
"card.from": "De la part de",
"profile.title": "Prestation de serment",
"profile.lead": "Avant votre entrée en fonction, le service de presse a besoin d'une précision pour les registres officiels.",
"profile.body": "Comment la presse doit-elle s'adresser à vous ?",
"profile.he": "Il / Lui",
"profile.she": "Elle / Elle",
"profile.they": "Iel / Ielle",
"profile.note": "Cela ne change que la manière dont vous serez nommé dans les livres d'histoire.",
"alert.urgent": "Télégramme urgent",
"alert.ack": "Compris",
"ind.regime": "Emprise sur le pouvoir",
"regime.0": "Solide",
"regime.1": "Fragile",
"regime.2": "Critique",
"end.verdict": "Le verdict des historiens",
"end.score": "Score {n} / 100",
"end.grade": "Note",
"end.served": "{days} jours au pouvoir",
"end.decisions": "Les décisions qui ont marqué le mandat",
"end.noDecisions": "Aucune décision n'a été signée.",
"end.chose": "Votre choix : {label}",
"end.again": "Rejouer",
"end.averages": "moy.",
"end.final": "fin",
"cal.menu": "Menu",
"menu.secret": "Secret défense",
"menu.tagline": "Un pays. Cinq ans. Chaque décision porte votre signature.",
"menu.paused": "Partie en pause",
"menu.continue": "Continuer",
"menu.resume": "Reprendre",
"menu.new": "Nouvelle partie",
"menu.load": "Charger une partie",
"menu.save": "Sauvegarder",
"menu.export": "Exporter",
"menu.import": "Importer",
"menu.settings": "Réglages",
"menu.credits": "Crédits",
"menu.quit": "Menu principal",
"menu.back": "Retour",
"menu.info": "{country} · jour {day}",
"new.title": "Nouvelle partie",
"new.name": "Nom du pays",
"new.scenario": "Scénario",
"new.pronouns": "Comment la presse doit-elle s'adresser à vous ?",
"new.start": "Prendre ses fonctions",
"load.title": "Charger une partie",
"load.auto": "Sauvegarde auto",
"load.slot": "Emplacement {n}",
"load.empty": "Vide",
"load.info": "{country} · jour {day}{date}",
"load.finished": "terminée",
"load.unreadable": "Sauvegarde illisible",
"load.load": "Charger",
"load.delete": "Supprimer",
"load.sure": "Sûr ?",
"load.replace": "Charger remplace la partie en cours.",
"save.title": "Sauvegarder",
"save.here": "Sauvegarder ici",
"save.overwrite": "Écraser",
"save.done": "Sauvegardé dans l'emplacement {n}.",
"save.failed": "Le navigateur a refusé la sauvegarde. Utilisez Exporter pour garder votre partie.",
"export.title": "Exporter",
"export.hint": "Copiez ce texte ou téléchargez le fichier. Vous pourrez l'importer plus tard, sur n'importe quel appareil.",
"export.copy": "Copier",
"export.copied": "Copié.",
"export.copyFailed": "Sélectionnez le texte et copiez-le à la main.",
"export.download": "Télécharger le .sav",
"import.title": "Importer",
"import.hint": "Collez une chaîne de sauvegarde, ou choisissez un fichier .sav.",
"import.file": "Choisir un fichier",
"import.go": "Importer",
"import.error.format": "Cela ne ressemble pas à une chaîne de sauvegarde.",
"import.error.checksum": "La sauvegarde est endommagée ou incomplète (somme de contrôle invalide).",
"import.error.tooNew": "Cette sauvegarde vient d'une version plus récente du jeu.",
"import.error.version": "Cette sauvegarde vient d'une version qui ne peut pas être convertie.",
"import.error.invalid": "Le contenu de cette sauvegarde n'est pas valide.",
"import.error.scenario": "Cette sauvegarde utilise un scénario qui n'est pas installé.",
"import.error.empty": "Rien à importer pour l'instant.",
"settings.title": "Réglages",
"settings.language": "Langue",
"settings.slow": "Temps ralenti",
"settings.slowHint": "Un jour en jeu dure 60 secondes au lieu de 10.",
"settings.storage": "Ce navigateur ne conserve pas les sauvegardes entre deux visites (fenêtre privée ou stockage bloqué). Utilisez Exporter pour garder votre partie.",
"credits.title": "Crédits",
"credits.fiction": "La Valdorie et tous ses habitants sont fictifs.",
"credits.tech": "Réalisé avec Go et du JavaScript pur, sans bibliothèque tierce.",
"credits.fonts": "Polices : Special Elite (licence Apache 2.0), IM Fell English et Courier Prime (licence SIL Open Font). Les fichiers de licence sont fournis avec le jeu."
}
+72
View File
@@ -0,0 +1,72 @@
// UI strings live in en.json / fr.json. Content fields (cards, newspaper) are
// { en, fr } objects read through tx().
const dict = {};
let lang = "en";
export async function initI18n(initial = "en") {
for (const l of ["en", "fr"]) {
dict[l] = await (await fetch(`js/i18n/${l}.json`)).json();
}
setLang(initial);
}
export function setLang(l) {
lang = dict[l] ? l : "en";
document.documentElement.lang = lang;
}
export const getLang = () => lang;
export function t(key, vars) {
let s = dict[lang]?.[key] ?? dict.en?.[key] ?? key;
if (vars) for (const [k, v] of Object.entries(vars)) s = s.replaceAll(`{${k}}`, v);
return s;
}
// Pick the current language from a { en, fr } content field.
export const tx = (obj) => obj?.[lang] ?? obj?.en ?? "";
// Pronoun tokens for text that refers to the player: {sub} {Sub} {obj} {pos} {is} {has} {was}
// in English, {sub} {Sub} {tonic} {e} (agreement ending) in French. Text that needs none of
// them should avoid pronouns altogether.
const PRONOUN_FORMS = {
en: {
he: { sub: "he", Sub: "He", obj: "him", pos: "his", is: "is", has: "has", was: "was" },
she: { sub: "she", Sub: "She", obj: "her", pos: "her", is: "is", has: "has", was: "was" },
they: { sub: "they", Sub: "They", obj: "them", pos: "their", is: "are", has: "have", was: "were" },
},
fr: {
he: { sub: "il", Sub: "Il", tonic: "lui", e: "" },
she: { sub: "elle", Sub: "Elle", tonic: "elle", e: "e" },
they: { sub: "iel", Sub: "Iel", tonic: "ielle", e: "·e" },
},
};
export function tp(text, pronouns = "they") {
const forms = PRONOUN_FORMS[lang]?.[pronouns] ?? PRONOUN_FORMS[lang]?.they ?? {};
return text.replace(/\{(\w+)\}/g, (match, key) => forms[key] ?? match);
}
// A historian's title: English is one string, French has a form per pronoun set.
export function tt(title, pronouns = "they") {
const v = title?.[lang] ?? title?.en ?? "";
return typeof v === "string" ? v : (v[pronouns] ?? v.they);
}
export function fmt(n, digits = 1) {
return new Intl.NumberFormat(lang, { minimumFractionDigits: digits, maximumFractionDigits: digits }).format(n);
}
// ISO date (YYYY-MM-DD, treated as UTC) → long localized parts.
export function dateParts(iso) {
const d = new Date(`${iso}T00:00:00Z`);
const f = (opts) => new Intl.DateTimeFormat(lang, { timeZone: "UTC", ...opts }).format(d);
return {
weekday: f({ weekday: "long" }),
day: f({ day: "numeric" }),
month: f({ month: "long" }),
year: f({ year: "numeric" }),
full: f({ dateStyle: "full" }),
};
}
+282
View File
@@ -0,0 +1,282 @@
// Boot, game lifecycle (menu, new game, load, autosave), panel wiring and the real-time loop.
//
// Dev URL params skip the menu and start a new game at once (and never touch the real autosave):
// ?lang=fr ?seed=N ?pronouns=he|she|they ?alloc=K (scale every sector budget by K)
// ?skip=DAYS (fast-forward, answering cards at random) ?card=ID (show that card first)
// ?dev (exposes window.__game) ?new (just start a game) ?menu (force the menu)
import {
createGame, advanceDay, setBudget, setSpeed, setPronouns, dismissAlert, isWaiting,
createClock, consumeClock, ensureCard, answerCard, indexCards, nextFloat,
} from "./engine/index.js";
import { initI18n, setLang, getLang, t } from "./i18n/i18n.js";
import { createStorage, writeSave, loadSettings, saveSettings, KEYS } from "./save/storage.js";
import { tilt } from "./ui/desk.js";
import { createDashboard } from "./ui/dashboard.js";
import { createCalendar } from "./ui/calendar.js";
import { createNewspaper } from "./ui/newspaper.js";
import { createDecision } from "./ui/decision.js";
import { createLedger } from "./ui/ledger.js";
import { createTelegram } from "./ui/telegram.js";
import { createEndgame } from "./ui/endgame.js";
import { createMenu } from "./ui/menu.js";
const params = new URLSearchParams(location.search);
const getJson = (url) => fetch(url).then((r) => r.json());
// ---- Content ----
const [balance, newsTpl, endgame, characters, cardIndex, scenarioIndex] = await Promise.all([
getJson("data/balance.json"),
getJson("data/newspaper.json"),
getJson("data/endgame.json"),
getJson("data/characters.json"),
getJson("data/cards/index.json"),
getJson("data/scenarios/index.json"),
]);
const cards = (await Promise.all(cardIndex.files.map((f) => getJson(`data/cards/${f}`)))).flat();
const scenarios = Object.fromEntries(
await Promise.all(scenarioIndex.scenarios.map(async (id) => [id, await getJson(`data/scenarios/${id}.json`)])),
);
const cardsById = indexCards(cards);
const content = { scenarios, balance, cards };
// ---- Settings and storage ----
const storage = createStorage();
let settings = loadSettings(storage);
const browserLang = navigator.language?.startsWith("fr") ? "fr" : "en";
await initI18n(params.get("lang") ?? settings.lang ?? browserLang);
const DEV_PARAMS = ["seed", "pronouns", "alloc", "skip", "card", "dev", "new"];
const devRun = DEV_PARAMS.some((k) => params.has(k)) && !params.has("menu");
// ---- Game and screen state ----
let state = null; // the running game, or null in the main menu
let scenario = null;
let panels = null;
let menuOpen = true;
let lastSpeed = 1;
const el = {
dash: document.getElementById("dashboard"),
folder: document.getElementById("folder"),
ledger: document.getElementById("ledger"),
alerts: document.getElementById("alerts"),
endgame: document.getElementById("endgame"),
cal: document.getElementById("calendar"),
news: document.getElementById("newspaper"),
menu: document.getElementById("menu"),
};
const pad = document.getElementById("pad");
for (const id of ["dash", "folder", "cal", "news"]) tilt(el[id], id);
// A weekly pause needs a card; with none eligible the week is simply empty.
function prepareWeek() {
ensureCard(state, balance, cards);
}
function autosave() {
if (!state || devRun) return;
if (state.over) storage.remove(KEYS.auto); // a finished game has nothing to continue
else writeSave(storage, KEYS.auto, state);
}
// ---- Panels ----
function build() {
panels?.decision.destroy();
document.title = t("app.title");
state.lang = getLang();
panels = {
dash: createDashboard(el.dash, scenario),
decision: createDecision(el.folder, { cardsById, characters, onAnswer, onProfile, keysEnabled: () => !menuOpen }),
ledger: createLedger(el.ledger, scenario, { onConfirm: render }),
cal: createCalendar(el.cal, balance, { onSpeed, onMenu: openMenu }),
news: createNewspaper(el.news, newsTpl),
alerts: createTelegram(el.alerts, { alerts: endgame.alerts, onDismiss }),
endgame: createEndgame(el.endgame, { newsName: newsTpl.name, endgame, cards, onAgain: quitToMenu }),
};
render();
}
function render() {
if (!state) return;
for (const p of Object.values(panels)) p.update(state, balance);
// The ledger covers the folder and the side column; the end screen covers everything.
pad.classList.toggle("is-ledger", Boolean(state.pending.review && !state.pending.profile && !state.over));
pad.classList.toggle("is-endgame", Boolean(state.over));
autosave();
}
// ---- Game lifecycle ----
function newGame({ countryName, scenarioId = scenarioIndex.scenarios[0], pronouns }) {
return createGame({
scenario: scenarios[scenarioId],
balance,
seed: params.has("seed") ? Number(params.get("seed")) : crypto.getRandomValues(new Uint32Array(1))[0],
lang: getLang(),
countryName,
pronouns,
});
}
function enterGame(next) {
state = next;
scenario = scenarios[state.scenarioId];
if (params.has("dev")) window.__game = state;
lastSpeed = state.speed || 1;
prepareWeek();
closeMenu();
build();
}
function openMenu() {
if (!state) return;
menuOpen = true;
pad.classList.add("is-menu");
menu.show("game");
}
function closeMenu() {
menuOpen = false;
pad.classList.remove("is-menu");
menu.hide();
clock.acc = 0;
}
function quitToMenu() {
autosave();
panels?.decision.destroy();
state = null;
panels = null;
for (const id of ["dash", "folder", "ledger", "alerts", "endgame", "cal", "news"]) el[id].innerHTML = "";
el.ledger.hidden = true;
el.alerts.hidden = true;
el.endgame.hidden = true;
pad.classList.remove("is-ledger", "is-endgame");
pad.classList.add("is-menu");
menuOpen = true;
menu.show("main");
}
function applySettings(patch) {
const langChanged = patch.lang && patch.lang !== getLang();
settings = { ...settings, ...patch };
saveSettings(storage, settings);
if (!langChanged) return;
setLang(patch.lang);
document.title = t("app.title");
if (state) build();
menu.refresh();
}
const menu = createMenu(el.menu, {
content,
storage,
getState: () => state,
getSettings: () => settings,
handlers: {
onStart: (options) => enterGame(newGame(options)),
onLoadState: enterGame,
onResume: closeMenu,
onQuit: quitToMenu,
onSettings: applySettings,
},
});
// ---- Player actions ----
function onSpeed(speed) {
if (speed > 0) lastSpeed = speed;
setSpeed(state, balance, speed);
render();
}
function onAnswer(choice) {
answerCard(state, balance, cards, choice);
render();
}
function onProfile(pronouns) {
setPronouns(state, pronouns);
render();
}
function onDismiss() {
dismissAlert(state);
render();
}
document.addEventListener("keydown", (e) => {
if (!state || e.target.closest?.("input, textarea, select") || e.ctrlKey || e.metaKey || e.altKey) return;
if (menuOpen) return; // the menu handles its own keys
if (e.key === "Escape" && !state.over) {
openMenu();
} else if (e.code === "Space") {
e.preventDefault();
onSpeed(state.speed > 0 ? 0 : lastSpeed);
} else if (/^[1-9]$/.test(e.key) && balance.time.speeds[Number(e.key) - 1]) {
onSpeed(balance.time.speeds[Number(e.key) - 1]);
}
});
addEventListener("pagehide", autosave);
document.addEventListener("visibilitychange", () => { if (document.hidden) autosave(); });
// ---- Dev shortcuts ----
function devGame() {
const s = newGame({ pronouns: params.get("pronouns") ?? undefined });
state = s; // the helpers below act on `state`
if (params.has("alloc")) {
const k = Number(params.get("alloc"));
setBudget(s, balance, { alloc: Object.fromEntries(Object.entries(s.sectors).map(([id, x]) => [id, x.alloc * k])) });
}
for (let i = 0, n = Number(params.get("skip") ?? 0); i < n && !s.over; i++) {
if (s.pending.profile) setPronouns(s, "they");
s.pending.review = false;
while (s.pending.alert) dismissAlert(s);
prepareWeek();
if (s.pendingCard) answerCard(s, balance, cards, nextFloat(s) < 0.5 ? "yes" : "no");
advanceDay(s, balance);
}
if (cardsById.has(params.get("card"))) {
s.pendingCard = { id: params.get("card"), day: s.day };
s.pending.weeklyCard = true;
}
return s;
}
// ---- Start ----
const clock = createClock();
let last = performance.now();
if (devRun) enterGame(devGame());
else quitToMenu();
function frame(now) {
const dt = now - last;
last = now;
if (state && !menuOpen && state.speed > 0 && !state.over && !isWaiting(state)) {
const msPerDay = settings.slow ? balance.time.slowMsPerDay : balance.time.msPerDay;
let days = consumeClock(clock, dt, msPerDay, state.speed);
while (days-- > 0) {
const r = advanceDay(state, balance);
if (!r.advanced) break;
if (r.events.weeklyCard) prepareWeek();
render();
if (isWaiting(state) || state.over) {
clock.acc = 0;
break;
}
}
} else {
clock.acc = 0;
}
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
+81
View File
@@ -0,0 +1,81 @@
// Save strings for export and import: "CG1:" + CRC32 of the JSON (8 hex digits) + ":" + base64(UTF-8 JSON).
// The checksum only catches corrupted or truncated strings. It is not protection against editing:
// anyone can decode, change and re-encode a save, and a single-player game does not need more.
import { STATE_VERSION } from "../engine/state.js";
const PREFIX = "CG1:";
const FORMAT = /^CG1:([0-9a-f]{8}):([A-Za-z0-9+/]+={0,2})$/;
const CRC_TABLE = (() => {
const table = new Uint32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
table[n] = c >>> 0;
}
return table;
})();
export function crc32(bytes) {
let c = 0xffffffff;
for (const b of bytes) c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
function toBase64(bytes) {
let binary = "";
for (let i = 0; i < bytes.length; i += 0x8000) binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
return btoa(binary);
}
function fromBase64(text) {
const binary = atob(text);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes;
}
export function encodeSave(state) {
const bytes = new TextEncoder().encode(JSON.stringify(state));
return `${PREFIX}${crc32(bytes).toString(16).padStart(8, "0")}:${toBase64(bytes)}`;
}
// Upgrades older saves one version at a time. Add an entry here whenever STATE_VERSION
// changes: MIGRATIONS[n] turns a version n state into a version n+1 state.
export const MIGRATIONS = {};
export function migrate(state) {
if (!Number.isInteger(state?.version)) return { ok: false, error: "format" };
if (state.version > STATE_VERSION) return { ok: false, error: "tooNew" };
let s = state;
while (s.version < STATE_VERSION) {
const step = MIGRATIONS[s.version];
if (!step) return { ok: false, error: "version" };
s = step(s);
}
return { ok: true, state: s };
}
// Returns { ok: true, state } or { ok: false, error: "format" | "checksum" | "tooNew" | "version" }.
// Whitespace is ignored, so strings that were wrapped when pasted still work.
export function decodeSave(text) {
const match = FORMAT.exec(String(text).replace(/\s+/g, ""));
if (!match) return { ok: false, error: "format" };
let bytes;
try {
bytes = fromBase64(match[2]);
} catch {
return { ok: false, error: "format" };
}
if (crc32(bytes).toString(16).padStart(8, "0") !== match[1]) return { ok: false, error: "checksum" };
let state;
try {
state = JSON.parse(new TextDecoder().decode(bytes));
} catch {
return { ok: false, error: "format" };
}
return migrate(state);
}
+107
View File
@@ -0,0 +1,107 @@
// Browser storage for the autosave, the manual slots and the settings.
// Every access is wrapped: if localStorage is missing or throws (private window, blocked
// site data, full quota) the game keeps working and uses memory for the rest of the session.
export const SLOT_COUNT = 3;
export const KEYS = {
auto: "cg.autosave",
slot: (n) => `cg.slot.${n}`,
settings: "cg.settings",
};
function browserStorage() {
try {
const probe = "cg.probe";
globalThis.localStorage.setItem(probe, "1");
globalThis.localStorage.removeItem(probe);
return globalThis.localStorage;
} catch {
return null;
}
}
// backend: anything with getItem/setItem/removeItem, for tests. Default: localStorage.
export function createStorage(backend = browserStorage()) {
const memory = new Map();
const store = { persistent: backend !== null };
store.get = (key) => {
try {
if (backend) return backend.getItem(key);
} catch {
store.persistent = false;
}
return memory.get(key) ?? null;
};
// Returns false when the write could not be made durable.
store.set = (key, value) => {
try {
if (backend) {
backend.setItem(key, value);
return true;
}
} catch {
store.persistent = false;
}
memory.set(key, value);
return false;
};
store.remove = (key) => {
memory.delete(key);
try {
backend?.removeItem(key);
} catch {
store.persistent = false;
}
};
return store;
}
// ---- Saved games: { meta, state } under one key ----
export function writeSave(store, key, state) {
const meta = {
savedAt: Date.now(),
day: state.day,
countryName: state.countryName,
pronouns: state.pronouns,
scenarioId: state.scenarioId,
over: state.over?.type ?? null,
};
return store.set(key, JSON.stringify({ meta, state }));
}
// Returns { meta, state } or null when the slot is empty or unreadable.
export function readSave(store, key) {
const raw = store.get(key);
if (!raw) return null;
try {
const parsed = JSON.parse(raw);
return parsed?.state && parsed?.meta ? parsed : null;
} catch {
return null;
}
}
// ---- Settings ----
const DEFAULT_SETTINGS = { lang: null, slow: false };
export function loadSettings(store) {
try {
const s = JSON.parse(store.get(KEYS.settings) ?? "{}");
return {
lang: s.lang === "en" || s.lang === "fr" ? s.lang : DEFAULT_SETTINGS.lang,
slow: s.slow === true,
};
} catch {
return { ...DEFAULT_SETTINGS };
}
}
export function saveSettings(store, settings) {
return store.set(KEYS.settings, JSON.stringify(settings));
}
+60
View File
@@ -0,0 +1,60 @@
// Desk calendar: in-game date, term progress, next decision dates, the brass speed
// controls and the way into the menu.
import { t, dateParts } from "../i18n/i18n.js";
import { dateOfDay } from "../engine/index.js";
import { $ } from "./desk.js";
export function createCalendar(root, bal, { onSpeed, onMenu }) {
const speeds = bal.time.speeds;
root.innerHTML = `
<div class="cal__head"><span class="cal__weekday"></span></div>
<div class="cal__body">
<div class="cal__day"></div>
<div class="cal__month"></div>
<div class="cal__count"></div>
<div class="cal__term"><span></span></div>
<dl class="cal__next">
<dt>${t("cal.briefing")}</dt><dd data-next="briefing"></dd>
<dt>${t("cal.review")}</dt><dd data-next="review"></dd>
</dl>
<div class="cal__controls">
<button class="brass" data-speed="0" title="${t("cal.pause")}" aria-label="${t("cal.pause")}">II</button>
${speeds.map((s) => `<button class="brass" data-speed="${s}" aria-label="${t("cal.speed", { n: s })}">${s}×</button>`).join("")}
</div>
<button class="brass cal__menu" data-menu>${t("cal.menu")}</button>
</div>`;
root.addEventListener("click", (e) => {
const b = e.target.closest("button");
if (!b) return;
if (b.dataset.menu !== undefined) onMenu();
else onSpeed(Number(b.dataset.speed));
});
const until = (day, every) => (every - (day % every)) % every;
const when = (n) => {
if (n === 0) return t("cal.today");
return n === 1 ? t("cal.tomorrow") : t("cal.inDays", { n });
};
function update(state) {
const p = dateParts(dateOfDay(state.startDate, state.day).iso);
$(root, ".cal__weekday").textContent = p.weekday;
$(root, ".cal__day").textContent = p.day;
$(root, ".cal__month").textContent = `${p.month} ${p.year}`;
const shown = Math.min(state.day + 1, bal.time.termDays);
$(root, ".cal__count").textContent = t("cal.dayCount", { day: shown, total: bal.time.termDays });
$(root, ".cal__term span").style.width = `${(state.day / bal.time.termDays) * 100}%`;
const over = Boolean(state.over);
$(root, '[data-next="briefing"]').textContent = over ? "–" : when(state.pending.weeklyCard ? 0 : until(state.day, bal.time.daysPerWeek));
$(root, '[data-next="review"]').textContent = over ? "–" : when(state.pending.review ? 0 : until(state.day, bal.time.daysPerQuarter));
for (const b of root.querySelectorAll("button[data-speed]")) {
b.setAttribute("aria-pressed", String(Number(b.dataset.speed) === state.speed));
}
}
return { update };
}
+45
View File
@@ -0,0 +1,45 @@
// Hand-drawn SVG helpers. Output is built from numbers only, so it is safe as innerHTML.
// Bigger chart for the end-of-term review: weekly samples over the whole term,
// a gridline per year and per quarter of the value range.
export function lineChart(values, { w = 260, h = 96, min = 0, max = 100, yearEvery = 52 } = {}) {
const pad = 3;
const n = values.length;
const span = max - min || 1;
const x = (i) => pad + (n > 1 ? (i / (n - 1)) * (w - 2 * pad) : 0);
const y = (v) => h - pad - Math.min(1, Math.max(0, (v - min) / span)) * (h - 2 * pad);
const grid = [];
for (let i = yearEvery; i < n; i += yearEvery) grid.push(`<line x1="${x(i).toFixed(1)}" y1="0" x2="${x(i).toFixed(1)}" y2="${h}"/>`);
for (const f of [0.25, 0.5, 0.75]) grid.push(`<line x1="0" y1="${(h * f).toFixed(1)}" x2="${w}" y2="${(h * f).toFixed(1)}"/>`);
const pts = values.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(" ");
return `<svg class="chart" viewBox="0 0 ${w} ${h}" width="100%" preserveAspectRatio="none" aria-hidden="true">` +
`<g class="spark__grid">${grid.join("")}</g>` +
(n > 1 ? `<polyline class="spark__line" points="${pts}" vector-effect="non-scaling-stroke"/>` : "") +
`</svg>`;
}
// Small line chart on graph paper. `values` are plotted evenly left to right.
export function sparkline(values, { w = 84, h = 24, min = 0, max = 100 } = {}) {
const grid = [];
for (let x = 12; x < w; x += 12) grid.push(`<line x1="${x}" y1="0" x2="${x}" y2="${h}"/>`);
for (const f of [0.25, 0.5, 0.75]) grid.push(`<line x1="0" y1="${h * f}" x2="${w}" y2="${h * f}"/>`);
let line = "";
const n = values.length;
if (n > 1) {
const span = max - min || 1;
const pts = values.map((v, i) => {
const x = 2 + (i / (n - 1)) * (w - 4);
const y = h - 2 - Math.min(1, Math.max(0, (v - min) / span)) * (h - 4);
return [x.toFixed(1), y.toFixed(1)];
});
const last = pts[n - 1];
line =
`<polyline class="spark__line" points="${pts.map((p) => p.join(",")).join(" ")}"/>` +
`<circle class="spark__dot" cx="${last[0]}" cy="${last[1]}" r="2"/>`;
}
return `<svg class="spark" viewBox="0 0 ${w} ${h}" width="${w}" height="${h}" aria-hidden="true">` +
`<g class="spark__grid">${grid.join("")}</g>${line}</svg>`;
}
+127
View File
@@ -0,0 +1,127 @@
// Ministerial briefing: seven indicator gauges with sparklines, economic figures
// with trend arrows, and a classified intelligence summary for the hidden gauges.
import { t, fmt } from "../i18n/i18n.js";
import { sparkline } from "./charts.js";
import { $ } from "./desk.js";
import { summarize } from "../engine/index.js";
const MAIN = ["order", "stability", "civilRights", "opinion", "economy", "hardPower", "softPower"];
// key, label, digits, suffix, summary field, history series, noise floor for the arrow, good when rising
const ECON = [
["gdp", "econ.gdp", 0, "", "gdp", null, 0, true],
["growth", "econ.growth", 1, " %", "growth", "growth", 0.15, true],
["unemployment", "econ.unemployment", 1, " %", "unemployment", "unemployment", 0.15, false],
["inflation", "econ.inflation", 1, " %", "inflation", "inflation", 0.2, false],
["debt", "econ.debt", 0, " %", "debtRatio", "debtRatio", 1, false],
["deficit", "econ.deficit", 1, " %", "deficit", "deficit", 0.3, false],
["rate", "econ.rate", 2, " %", "interestRate", null, 0, false],
];
const bandIndex = (v, cuts) => cuts.filter((c) => v >= c).length;
const OPINION_CUTS = [15, 30, 45, 60, 75, 90];
const UNREST_CUTS = [20, 35, 50, 65];
const ARMY_CUTS = [25, 45, 65];
const SPARK_WEEKS = 26;
const SPARK_W = 76;
const MIN_SPAN = 16;
export function createDashboard(root, scenario) {
root.innerHTML = `
<header class="dash__head">
<h2>${t("dash.title")}</h2>
<span class="stamp stamp--red">${t("dash.confidential")}</span>
</header>
<div class="dash__gauges">
${MAIN.map((id) => `
<div class="gauge" data-ind="${id}">
<span class="gauge__label">${t("ind." + id)}<small class="gauge__band"></small></span>
<span class="gauge__track"><span class="gauge__fill"></span><i></i><i></i><i></i></span>
<span class="gauge__value"></span>
<span class="gauge__spark"></span>
</div>`).join("")}
</div>
<h3 class="dash__sub">${t("dash.econ")}</h3>
<table class="econ"><tbody>
${ECON.map(([key, label]) => `
<tr data-econ="${key}"><th>${t(label)}</th><td class="econ__val"></td><td class="econ__trend"></td></tr>`).join("")}
</tbody></table>
<h3 class="dash__sub">${t("dash.intel")} <span class="stamp stamp--small">${t("dash.classified")}</span></h3>
<ul class="intel">
<li data-intel="unrest"><span>${t("ind.unrest")}</span><b></b></li>
<li data-intel="armyLoyalty"><span>${t("ind.armyLoyalty")}</span><b></b></li>
<li data-intel="regime"><span>${t("ind.regime")}</span><b></b></li>
</ul>`;
const gauges = Object.fromEntries(MAIN.map((id) => [id, {
el: $(root, `.gauge[data-ind="${id}"]`),
fill: $(root, `.gauge[data-ind="${id}"] .gauge__fill`),
value: $(root, `.gauge[data-ind="${id}"] .gauge__value`),
band: $(root, `.gauge[data-ind="${id}"] .gauge__band`),
spark: $(root, `.gauge[data-ind="${id}"] .gauge__spark`),
}]));
const sparkLen = {};
function trend(series, eps, goodUp) {
if (!series || series.length < 2) return ["", ""];
const cur = series[series.length - 1];
const prev = series[Math.max(0, series.length - 5)];
const d = cur - prev;
if (Math.abs(d) < eps) return ["▬", "flat"];
return [d > 0 ? "▲" : "▼", d > 0 === goodUp ? "good" : "bad"];
}
function update(state, bal) {
const sum = summarize(state, bal);
for (const id of MAIN) {
const g = gauges[id];
const v = state.indicators[id];
g.fill.style.width = `${v}%`;
g.el.classList.toggle("is-low", v < 30);
g.el.classList.toggle("is-high", v >= 70);
g.value.textContent = Math.round(v);
if (id === "opinion") g.band.textContent = t("opinion." + bandIndex(v, OPINION_CUTS));
const series = state.history.series[id] ?? [];
if (sparkLen[id] !== series.length) {
sparkLen[id] = series.length;
const win = series.slice(-SPARK_WEEKS);
// Zoom on the data (at least MIN_SPAN points tall) so small trends stay visible.
const mid = (Math.min(...win) + Math.max(...win)) / 2;
const span = Math.max(Math.max(...win) - Math.min(...win), MIN_SPAN);
g.spark.innerHTML = sparkline(win, { w: SPARK_W, min: mid - span / 2, max: mid + span / 2 });
}
}
for (const [key, , digits, suffix, field, seriesId, eps, goodUp] of ECON) {
const row = $(root, `[data-econ="${key}"]`);
const val = sum[field];
$(row, ".econ__val").textContent = key === "gdp"
? `${fmt(val, 0)} ${t("econ.gdpUnit", { currency: scenario.currency })}`
: `${fmt(val, digits)}${suffix}`;
const [arrow, cls] = trend(seriesId && state.history.series[seriesId], eps, goodUp);
const cell = $(row, ".econ__trend");
cell.textContent = arrow;
cell.className = `econ__trend ${cls}`;
}
$(root, '[data-intel="unrest"] b').textContent = t("unrest." + bandIndex(state.indicators.unrest, UNREST_CUTS));
$(root, '[data-intel="armyLoyalty"] b').textContent = t("army." + bandIndex(state.indicators.armyLoyalty, ARMY_CUTS));
// Hold on power: Critical once any danger zone has been entered, Shaky when close to one.
const i = state.indicators;
const inZone = state.risk.revolution > 0 || state.risk.coup > 0 || state.risk.impeachment > 0 || state.risk.defaultZone;
const close = i.unrest >= 55 || i.armyLoyalty <= 45 || i.opinion <= 30 || i.stability <= 40
|| i.civilRights <= 20 || sum.debtRatio >= 130;
const regime = inZone ? 2 : close ? 1 : 0;
$(root, '[data-intel="regime"] b').textContent = t("regime." + regime);
$(root, '[data-intel="regime"]').dataset.level = [0, 2, 3][regime];
$(root, '[data-intel="unrest"]').dataset.level = bandIndex(state.indicators.unrest, UNREST_CUTS);
$(root, '[data-intel="armyLoyalty"]').dataset.level = 3 - bandIndex(state.indicators.armyLoyalty, ARMY_CUTS);
}
return { update };
}
+113
View File
@@ -0,0 +1,113 @@
// Centre folder: the weekly decision card as a paper document with two rubber
// stamps. Also shows the idle and end-of-term messages.
import { t, tx, dateParts } from "../i18n/i18n.js";
import { dateOfDay } from "../engine/index.js";
import { $, esc } from "./desk.js";
const STAMP_MS = 750; // how long the stamp animation plays before the answer is applied
export function createDecision(root, { cardsById, characters, onAnswer, onProfile, keysEnabled = () => true }) {
root.innerHTML = `
<h2 class="inbox__tab">${t("inbox.title")}</h2>
<div class="inbox__body"></div>`;
const body = $(root, ".inbox__body");
let shown = "";
let locked = false;
let latest = null;
function choose(choice) {
if (locked) return;
const card = $(body, ".card");
if (!card) return;
locked = true;
card.dataset.stamped = choice;
for (const b of card.querySelectorAll("button")) b.disabled = true;
const stamp = document.createElement("div");
stamp.className = `card__stamp card__stamp--${choice}`;
stamp.textContent = t(choice === "yes" ? "card.approved" : "card.rejected");
card.append(stamp);
setTimeout(() => onAnswer(choice), STAMP_MS);
}
body.addEventListener("click", (e) => {
const b = e.target.closest("button[data-choice]");
if (b) choose(b.dataset.choice);
const p = e.target.closest("button[data-pronouns]");
if (p) onProfile(p.dataset.pronouns);
});
// The panel is rebuilt on language changes and new games, so its key listener must be removable.
const onKey = (e) => {
if (!keysEnabled() || !latest?.pendingCard) return;
if (latest.pending.review || latest.pending.profile || latest.pending.alert) return;
if (e.ctrlKey || e.metaKey || e.altKey) return;
if (e.target.closest?.("input, textarea, select")) return;
const k = e.key.toLowerCase();
if (k === "y") choose("yes");
else if (k === "n") choose("no");
};
document.addEventListener("keydown", onKey);
function option(card, side) {
const label = side === "yes" ? t("card.approved") : t("card.rejected");
return `
<button class="stamp-btn stamp-btn--${side}" data-choice="${side}">
<span class="stamp-btn__label">${esc(tx(card[side].label))}</span>
<span class="stamp-btn__ink">${esc(label)}</span>
</button>`;
}
function cardHtml(card, state) {
const from = characters[card.sender];
const date = dateParts(dateOfDay(state.startDate, state.pendingCard.day).iso);
const paragraphs = tx(card.body).split("\n\n").map((p) => `<p>${esc(p)}</p>`).join("");
return `
<article class="card card--${esc(card.doc)}">
<header class="card__head">
<span class="card__kind">${t("kind." + card.doc)}</span>
<span class="card__date">${esc(date.day)} ${esc(date.month)} ${esc(date.year)}</span>
</header>
<div class="card__from">
<span class="seal">${esc(from.seal)}</span>
<span><b>${esc(tx(from.name))}</b><small>${esc(tx(from.role))}</small></span>
</div>
<h3 class="card__title">${esc(tx(card.title))}</h3>
<div class="card__body">${paragraphs}</div>
<div class="card__choices">${option(card, "yes")}${option(card, "no")}</div>
<p class="card__keys">${t("card.keys")}</p>
</article>`;
}
function update(state) {
latest = state;
let kind = "idle";
if (state.over) kind = "over";
else if (state.pending.profile) kind = "profile";
else if (state.pendingCard) kind = `card:${state.pendingCard.id}`;
if (kind === shown) return;
shown = kind;
locked = false;
body.classList.toggle("is-card", kind.startsWith("card:"));
if (kind === "profile") {
body.innerHTML = `
<h3>${t("profile.title")}</h3>
<p>${t("profile.lead")}</p>
<p class="profile__ask">${t("profile.body")}</p>
<div class="profile__choices">
${["he", "she", "they"].map((p) => `<button class="brass brass--wide" data-pronouns="${p}">${t("profile." + p)}</button>`).join("")}
</div>
<p class="profile__note">${t("profile.note")}</p>`;
} else if (kind === "idle") {
body.innerHTML = `<p class="inbox__idle">${t("inbox.idle")}</p>`;
} else if (kind === "over") {
body.innerHTML = `<h3>${t("inbox.overTitle")}</h3><p>${t("inbox.overBody")}</p>`;
} else {
body.innerHTML = cardHtml(cardsById.get(state.pendingCard.id), state);
}
}
return { update, destroy: () => document.removeEventListener("keydown", onKey) };
}
+18
View File
@@ -0,0 +1,18 @@
// Desk-level helpers shared by the paper panels.
export function hash(str) {
let h = 2166136261;
for (let i = 0; i < str.length; i++) h = Math.imul(h ^ str.charCodeAt(i), 16777619);
return h >>> 0;
}
// Give a paper a stable slight rotation derived from its id.
export function tilt(el, id, amp = 0.8) {
const r = ((hash(id) % 1000) / 1000 - 0.5) * 2 * amp;
el.style.setProperty("--rot", `${r.toFixed(2)}deg`);
}
export const $ = (root, sel) => root.querySelector(sel);
// Escape text for use inside innerHTML templates.
export const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
+118
View File
@@ -0,0 +1,118 @@
// End of the game: a retrospective front page. It covers both the normal end of
// the term and each way of losing power early.
import { t, tx, tp, tt, getLang, dateParts, fmt } from "../i18n/i18n.js";
import { buildReview, dateOfDay, indexCards } from "../engine/index.js";
import { lineChart } from "./charts.js";
import { $, esc } from "./desk.js";
const MIN_SPAN = 30;
// series id in state.history, label key, show the term average or the final value
const CHARTS = [
["opinion", "ind.opinion", "avg"],
["stability", "ind.stability", "avg"],
["economy", "ind.economy", "avg"],
["debtRatio", "econ.debt", "final"],
];
export function createEndgame(root, { newsName, endgame, cards, onAgain }) {
const cardsById = indexCards(cards);
root.addEventListener("click", (e) => {
if (e.target.closest("button[data-again]")) onAgain();
});
let shown = "";
function chart(state, [id, label, mode], review) {
const series = state.history.series[id] ?? [];
const value = mode === "avg" ? review.metrics[id] : series.at(-1);
const tag = mode === "avg" ? t("end.averages") : t("end.final");
// Zoom on the data (at least MIN_SPAN points tall) so trends are visible; stay within 0-100
// for indicators, and above 0 for debt.
const lo = Math.min(...series);
const hi = Math.max(...series);
const span = Math.max(hi - lo, MIN_SPAN);
let min = Math.max(0, (lo + hi) / 2 - span / 2);
let max = min + span;
if (id !== "debtRatio" && max > 100) {
max = 100;
min = 100 - span;
}
return `
<figure class="end__chart">
<figcaption><span>${t(label)}</span><b>${esc(fmt(value, 0))}</b><small>${tag}</small></figcaption>
${lineChart(series, { min, max })}
</figure>`;
}
function decision(d) {
const card = cardsById.get(d.id);
const date = dateParts(dateOfDay(root.dataset.startDate, d.day).iso);
return `
<li>
<span class="end__when">${esc(date.day)} ${esc(date.month)} ${esc(date.year)}</span>
<b>${esc(tx(card.title))}</b>
<span class="end__chose">${esc(t("end.chose", { label: tx(card[d.choice].label) }))}</span>
</li>`;
}
function update(state, bal) {
if (!state.over) {
if (shown) {
root.hidden = true;
root.innerHTML = "";
shown = "";
}
return;
}
const key = `${state.over.type}:${state.over.day}:${getLang()}`;
if (key === shown) return;
shown = key;
root.dataset.startDate = state.startDate;
const review = buildReview(state, bal, cards, endgame);
const banner = endgame.over[state.over.type];
const entry = endgame.titles.find((x) => x.id === review.titleId);
const date = dateParts(dateOfDay(state.startDate, state.over.day).iso);
const lost = state.over.type !== "term";
root.innerHTML = `
<article class="paper paper--news end ${lost ? "end--lost" : ""}">
<header class="news__mast">
<h2>${esc(tx(newsName))}</h2>
<div class="news__edition">${esc(t("news.edition", { date: date.full }))}</div>
</header>
<div class="end__banner">
<h1>${esc(tx(banner.h))}</h1>
<p>${esc(tx(banner.d))}</p>
</div>
<div class="end__grid">
<section class="end__verdict">
<h3>${t("end.verdict")}</h3>
<div class="end__grade" data-grade="${esc(review.grade)}">
<span class="end__letter">${esc(review.grade)}</span>
<span>${t("end.grade")}</span>
</div>
<p class="end__title">${entry ? esc(tt(entry.title, state.pronouns)) : ""}</p>
<p class="end__text">${entry ? esc(tp(tx(entry.verdict), state.pronouns)) : ""}</p>
<p class="end__score">${t("end.score", { n: Math.round(review.score) })}<br>${t("end.served", { days: state.over.day })}</p>
</section>
<section class="end__charts">${CHARTS.map((c) => chart(state, c, review)).join("")}</section>
<section class="end__decisions">
<h3>${t("end.decisions")}</h3>
${review.topDecisions.length
? `<ol>${review.topDecisions.map(decision).join("")}</ol>`
: `<p class="inbox__idle">${t("end.noDecisions")}</p>`}
</section>
</div>
<footer class="end__foot"><button class="brass brass--wide" data-again>${t("end.again")}</button></footer>
</article>`;
root.hidden = false;
$(root, "button[data-again]").focus();
}
return { update };
}
+213
View File
@@ -0,0 +1,213 @@
// Quarterly budget ledger: a two-page accounting book. The left page sets sector
// budgets, the right page sets tax rates and shows the Treasury summary and a
// four-quarter projection. Nothing is applied until the player signs.
import { t, fmt, getLang } from "../i18n/i18n.js";
import {
previewBudget, setBudget, confirmBudget, qualityFromRatio, taxRevenue, TAX_IDS,
} from "../engine/index.js";
import { $ } from "./desk.js";
const ALLOC_STEP = 0.05;
const TAX_STEP = 0.5;
// Projection rows: label key, field on the summary, digits, suffix
const PROJ_ROWS = [
["econ.debt", (p) => p.debtRatio, 1, ""],
["econ.deficit", (p) => p.deficit, 1, ""],
["econ.growth", (p) => p.growth, 1, ""],
["econ.unemployment", (p) => p.unemployment, 1, ""],
["ind.opinion", (p) => p.indicators.opinion, 0, ""],
["ind.stability", (p) => p.indicators.stability, 0, ""],
["ind.economy", (p) => p.indicators.economy, 0, ""],
];
export function createLedger(root, scenario, { onConfirm }) {
let openKey = null;
let ctx = null; // { state, bal }
let draft = null;
let base = null;
let queued = false;
const sectorIds = (state) => Object.keys(state.sectors);
function open(state, bal) {
ctx = { state, bal };
base = {
alloc: Object.fromEntries(Object.entries(state.sectors).map(([id, s]) => [id, s.alloc])),
taxes: { ...state.economy.taxes },
};
draft = structuredClone(base);
const allocMax = Math.min(bal.limits.allocMaxRatio, bal.limits.sliderMaxRatio);
const sectorRows = sectorIds(state).map((id) => {
const s = state.sectors[id];
const max = Math.max(s.need * allocMax, s.alloc);
return `
<div class="lrow" data-sector="${id}">
<label class="lrow__name" for="alloc-${id}" title="${t("sector." + id)}">${t("sector." + id)}</label>
<span class="lrow__ctl">
<input type="range" id="alloc-${id}" min="0" max="${max}" step="${ALLOC_STEP}" value="${s.alloc}">
<i class="lrow__tick" style="left:${(s.need / max) * 100}%"></i>
</span>
<output class="lrow__val"></output>
<span class="lrow__q"></span>
</div>`;
}).join("");
const taxRows = TAX_IDS.map((id) => {
const max = bal.tax[id].peak * bal.limits.taxMaxFactorOfPeak;
return `
<div class="lrow lrow--tax" data-tax="${id}">
<label class="lrow__name" for="tax-${id}">${t("tax." + id)}</label>
<span class="lrow__ctl">
<input type="range" id="tax-${id}" min="0" max="${max}" step="${TAX_STEP}" value="${state.economy.taxes[id]}">
<i class="lrow__tick" style="left:${(bal.tax[id].peak / max) * 100}%"></i>
</span>
<output class="lrow__val"></output>
<span class="lrow__yield"></span>
</div>`;
}).join("");
root.innerHTML = `
<div class="ledger__book">
<article class="ledger__page ledger__page--left">
<h2>${t("ledger.title")}</h2>
<p class="ledger__sub">${t("ledger.spending")}. ${t("ledger.spendingHint")}</p>
<div class="ledger__rows">${sectorRows}</div>
<footer class="ledger__total">${t("ledger.totalAlloc")} <b data-total></b></footer>
</article>
<article class="ledger__page ledger__page--right">
<h2>${t("ledger.revenue")}</h2>
<p class="ledger__sub">${t("ledger.revenueHint")}</p>
<div class="ledger__rows">${taxRows}</div>
<h3>${t("ledger.summary")}</h3>
<dl class="sum" data-summary></dl>
<h3>${t("ledger.projection")}</h3>
<table class="proj"><thead></thead><tbody></tbody></table>
<footer class="ledger__actions">
<button class="brass" data-act="reset">${t("ledger.reset")}</button>
<button class="brass brass--wide" data-act="confirm">${t("ledger.confirm")}</button>
</footer>
<p class="ledger__error" data-error hidden>${t("ledger.error")}</p>
</article>
</div>`;
root.hidden = false;
refresh();
}
function close() {
root.hidden = true;
root.innerHTML = "";
openKey = null;
ctx = null;
}
// Cheap parts (values next to sliders) run immediately; the preview is coalesced per frame.
function refresh() {
const { state, bal } = ctx;
let total = 0;
for (const id of sectorIds(state)) {
const s = state.sectors[id];
const v = draft.alloc[id];
total += v;
const row = $(root, `[data-sector="${id}"]`);
$(row, ".lrow__val").textContent = fmt(v, 2);
const target = qualityFromRatio(v / s.need, bal);
const diff = target - s.quality;
const q = $(row, ".lrow__q");
q.textContent = diff > 1 ? "▲" : diff < -1 ? "▼" : "=";
q.className = `lrow__q ${diff > 1 ? "up" : diff < -1 ? "down" : "flat"}`;
q.title = t("ledger.quality", { now: Math.round(s.quality), target: Math.round(target) });
row.classList.toggle("is-changed", Math.abs(v - base.alloc[id]) > 1e-9);
}
$(root, "[data-total]").textContent = `${fmt(total, 2)} %`;
for (const id of TAX_IDS) {
const row = $(root, `[data-tax="${id}"]`);
$(row, ".lrow__val").textContent = `${fmt(draft.taxes[id], 1)} %`;
$(row, ".lrow__yield").textContent = t("ledger.yield", { n: fmt(taxRevenue(id, draft.taxes[id], bal), 1) });
row.classList.toggle("is-changed", Math.abs(draft.taxes[id] - base.taxes[id]) > 1e-9);
}
if (!queued) {
queued = true;
requestAnimationFrame(() => {
queued = false;
if (ctx) renderPreview();
});
}
}
function renderPreview() {
const { state, bal } = ctx;
const pv = previewBudget(state, bal, draft);
if (!pv.ok) return;
const f = pv.fiscal;
const gdp = state.economy.gdp;
const bn = (pct) => fmt(Math.abs((pct / 100) * gdp), 1);
const balance = -f.deficit; // positive = surplus
$(root, "[data-summary]").innerHTML = `
<dt>${t("ledger.sumRevenue")}</dt><dd>${fmt(f.revenue, 1)} %</dd>
<dt>${t("ledger.sumSectors")}</dt><dd>${fmt(f.allocTotal, 1)} %</dd>
<dt>${t("ledger.sumInterest")}</dt><dd>${fmt(f.interest, 1)} %</dd>
<dt>${t("ledger.sumSpending")}</dt><dd>${fmt(f.spending, 1)} %</dd>
<dt class="sum__balance ${balance >= 0 ? "good" : "bad"}">${t(balance >= 0 ? "ledger.sumSurplus" : "ledger.sumDeficit")}</dt>
<dd class="sum__balance ${balance >= 0 ? "good" : "bad"}">${fmt(Math.abs(balance), 1)} %
<small>${t("ledger.perYear", { n: bn(balance), currency: scenario.currency })}</small></dd>`;
const months = (i) => (i === 0 ? t("ledger.now") : t("ledger.months", { n: i * 3 }));
$(root, ".proj thead").innerHTML = `<tr><th></th>${pv.points.map((_, i) => `<th>${months(i)}</th>`).join("")}</tr>`;
$(root, ".proj tbody").innerHTML = PROJ_ROWS.map(([label, get, digits, suffix]) =>
`<tr><th>${t(label)}</th>${pv.points.map((p) => `<td>${fmt(get(p), digits)}${suffix}</td>`).join("")}</tr>`).join("");
}
function syncSliders() {
for (const id of sectorIds(ctx.state)) $(root, `#alloc-${id}`).value = draft.alloc[id];
for (const id of TAX_IDS) $(root, `#tax-${id}`).value = draft.taxes[id];
}
root.addEventListener("input", (e) => {
const input = e.target;
if (!ctx || input.type !== "range") return;
const v = Number(input.value);
if (input.id.startsWith("alloc-")) draft.alloc[input.id.slice(6)] = v;
else if (input.id.startsWith("tax-")) draft.taxes[input.id.slice(4)] = v;
refresh();
});
root.addEventListener("click", (e) => {
const b = e.target.closest("button[data-act]");
if (!b || !ctx) return;
if (b.dataset.act === "reset") {
draft = structuredClone(base);
syncSliders();
refresh();
} else {
const { state, bal } = ctx;
const res = setBudget(state, bal, draft);
if (!res.ok) {
$(root, "[data-error]").hidden = false;
return;
}
confirmBudget(state);
close();
onConfirm();
}
});
function update(state, bal) {
if (!(state.pending.review && !state.pending.profile && !state.over)) {
if (openKey !== null) close();
return;
}
const key = `${state.day}:${getLang()}`;
if (key !== openKey) {
openKey = key;
open(state, bal);
}
}
return { update };
}
+369
View File
@@ -0,0 +1,369 @@
// Main menu and in-game pause menu: a "Top secret" folder with one screen at a time.
// Modes: "main" (before a game) and "game" (pause menu while a game is running).
// The menu never changes the game itself; it hands results to main.js through `handlers`.
import { t, tx, getLang, dateParts } from "../i18n/i18n.js";
import { dateOfDay, restoreState, PRONOUNS } from "../engine/index.js";
import { encodeSave, decodeSave, migrate } from "../save/codec.js";
import { KEYS, SLOT_COUNT, readSave, writeSave } from "../save/storage.js";
import { $, esc } from "./desk.js";
const ARM_MS = 4000; // how long a "Sure?" button stays armed
export function createMenu(root, { content, storage, getState, getSettings, handlers }) {
let mode = "main";
let screen = "home";
let message = null; // { text, kind: "ok" | "error" }
let armed = null; // "action:arg" waiting for a second click
let armTimer = 0;
let exportText = "";
const say = (text, kind = "ok") => { message = { text, kind }; };
const go = (next) => { screen = next; message = null; disarm(); render(); };
function disarm() {
armed = null;
clearTimeout(armTimer);
}
function arm(key) {
armed = key;
clearTimeout(armTimer);
armTimer = setTimeout(() => { armed = null; render(); }, ARM_MS);
render();
}
// ---- Reading saves ----
const autosave = () => readSave(storage, KEYS.auto);
function describe(entry) {
const scenario = content.scenarios[entry.meta.scenarioId];
let date = "";
if (scenario) {
const p = dateParts(dateOfDay(scenario.startDate, entry.meta.day).iso);
date = ` · ${p.day} ${p.month} ${p.year}`;
}
const saved = new Intl.DateTimeFormat(getLang(), { dateStyle: "medium", timeStyle: "short" }).format(new Date(entry.meta.savedAt));
const info = t("load.info", { country: entry.meta.countryName, day: entry.meta.day, date });
return `${esc(info)}${entry.meta.over ? ` · ${t("load.finished")}` : ""}<small>${esc(saved)}</small>`;
}
// Turns a stored or imported state into a playable one, or explains why not.
function restore(rawState) {
const migrated = migrate(rawState);
if (!migrated.ok) return migrated;
return restoreState(migrated.state, content);
}
function loadEntry(entry) {
const r = entry ? restore(entry.state) : { ok: false, error: "invalid" };
if (!r.ok) {
say(t("load.unreadable"), "error");
render();
return;
}
handlers.onLoadState(r.state);
}
// ---- Screens ----
const button = (action, label, extra = "", cls = "") =>
`<button class="brass brass--wide ${cls}" data-action="${action}" ${extra}>${label}</button>`;
const back = () => `<footer class="menu__foot">${button("back", t("menu.back"))}</footer>`;
const notice = () => (message ? `<p class="menu__msg menu__msg--${message.kind}" role="status">${esc(message.text)}</p>` : "");
function home() {
const state = getState();
if (mode === "game") {
return `
<h3>${t("menu.paused")}</h3>
<p class="menu__sub">${esc(t("menu.info", { country: state.countryName, day: state.day }))}</p>
<div class="menu__list">
${button("resume", t("menu.resume"))}
${button("save", t("menu.save"))}
${button("load", t("menu.load"))}
${button("export", t("menu.export"))}
${button("import", t("menu.import"))}
${button("settings", t("menu.settings"))}
${button("quit", t("menu.quit"))}
</div>`;
}
const auto = autosave();
const canContinue = auto && !auto.meta.over;
return `
<h1 class="menu__title">${t("app.title")}</h1>
<p class="menu__sub">${t("menu.tagline")}</p>
${notice()}
<div class="menu__list">
${canContinue ? button("continue", `${t("menu.continue")}<small>${esc(t("menu.info", { country: auto.meta.countryName, day: auto.meta.day }))}</small>`) : ""}
${button("new", t("menu.new"))}
${button("load", t("menu.load"))}
${button("import", t("menu.import"))}
${button("settings", t("menu.settings"))}
${button("credits", t("menu.credits"))}
</div>`;
}
function newGame() {
const ids = Object.keys(content.scenarios);
const first = content.scenarios[ids[0]];
return `
<h3>${t("new.title")}</h3>
<label class="menu__field">${t("new.name")}
<input id="new-name" type="text" maxlength="40" placeholder="${esc(tx(first.name))}" autocomplete="off">
</label>
${ids.length > 1
? `<label class="menu__field">${t("new.scenario")}
<select id="new-scenario">${ids.map((id) => `<option value="${esc(id)}">${esc(tx(content.scenarios[id].name))}</option>`).join("")}</select>
</label>`
: `<input id="new-scenario" type="hidden" value="${esc(ids[0])}">`}
<fieldset class="menu__field menu__radios">
<legend>${t("new.pronouns")}</legend>
${PRONOUNS.map((p) => `<label><input type="radio" name="pronouns" value="${p}" ${p === "they" ? "checked" : ""}> ${t("profile." + p)}</label>`).join("")}
</fieldset>
<div class="menu__list">${button("start", t("new.start"))}</div>
${back()}`;
}
function slotRow(label, entry, actions) {
return `
<li class="menu__slot">
<span class="menu__slotname">${label}</span>
<span class="menu__slotinfo">${entry ? describe(entry) : `<em>${t("load.empty")}</em>`}</span>
<span class="menu__slotbtns">${actions}</span>
</li>`;
}
const confirmable = (action, arg, label, enabled = true) => {
const isArmed = armed === `${action}:${arg}`;
return `<button class="brass ${isArmed ? "brass--armed" : ""}" data-action="${action}" data-arg="${arg}" ${enabled ? "" : "disabled"}>${isArmed ? t("load.sure") : label}</button>`;
};
function load() {
const rows = [];
const auto = autosave();
rows.push(slotRow(t("load.auto"), auto, auto ? confirmable("loadEntry", "auto", t("load.load")) : ""));
for (let n = 1; n <= SLOT_COUNT; n++) {
const entry = readSave(storage, KEYS.slot(n));
rows.push(slotRow(t("load.slot", { n }), entry,
entry ? `${confirmable("loadEntry", String(n), t("load.load"))}${confirmable("deleteSlot", String(n), t("load.delete"))}` : ""));
}
return `
<h3>${t("load.title")}</h3>
${mode === "game" ? `<p class="menu__sub">${t("load.replace")}</p>` : ""}
${notice()}
<ul class="menu__slots">${rows.join("")}</ul>
${back()}`;
}
function save() {
const rows = [];
for (let n = 1; n <= SLOT_COUNT; n++) {
const entry = readSave(storage, KEYS.slot(n));
rows.push(slotRow(t("load.slot", { n }), entry,
confirmable("saveSlot", String(n), entry ? t("save.overwrite") : t("save.here"))));
}
return `
<h3>${t("save.title")}</h3>
${notice()}
<ul class="menu__slots">${rows.join("")}</ul>
${back()}`;
}
function exportScreen() {
return `
<h3>${t("export.title")}</h3>
<p class="menu__sub">${t("export.hint")}</p>
<textarea class="menu__text" id="export-text" readonly rows="8" spellcheck="false">${esc(exportText)}</textarea>
${notice()}
<div class="menu__row">
${button("copy", t("export.copy"))}
${button("download", t("export.download"))}
</div>
${back()}`;
}
function importScreen() {
return `
<h3>${t("import.title")}</h3>
<p class="menu__sub">${t("import.hint")}${mode === "game" ? ` ${t("load.replace")}` : ""}</p>
<textarea class="menu__text" id="import-text" rows="8" spellcheck="false" placeholder="CG1:…"></textarea>
${notice()}
<div class="menu__row">
<label class="brass brass--wide menu__file">${t("import.file")}<input type="file" id="import-file" accept=".sav,.txt,text/plain" hidden></label>
${button("doImport", t("import.go"))}
</div>
${back()}`;
}
function settings() {
const s = getSettings();
const lang = getLang();
return `
<h3>${t("settings.title")}</h3>
<div class="menu__field">
<span>${t("settings.language")}</span>
<div class="menu__row">
<button class="brass" data-action="lang" data-arg="en" aria-pressed="${lang === "en"}">English</button>
<button class="brass" data-action="lang" data-arg="fr" aria-pressed="${lang === "fr"}">Français</button>
</div>
</div>
<label class="menu__field menu__check">
<input type="checkbox" id="setting-slow" ${s.slow ? "checked" : ""}>
<span><b>${t("settings.slow")}</b><small>${t("settings.slowHint")}</small></span>
</label>
${storage.persistent ? "" : `<p class="menu__msg menu__msg--error">${t("settings.storage")}</p>`}
${back()}`;
}
function credits() {
return `
<h3>${t("credits.title")}</h3>
<p>${t("credits.fiction")}</p>
<p>${t("credits.tech")}</p>
<p>${t("credits.fonts")}</p>
${back()}`;
}
const SCREENS = { home, new: newGame, load, save, export: exportScreen, import: importScreen, settings, credits };
function render() {
root.innerHTML = `
<div class="menu__folder">
<h2 class="inbox__tab">${t("menu.secret")}</h2>
<span class="stamp menu__stamp" aria-hidden="true">${t("menu.secret")}</span>
<div class="menu__sheet">${SCREENS[screen]()}</div>
</div>`;
}
// ---- Actions ----
async function copyExport() {
const box = $(root, "#export-text");
try {
await navigator.clipboard.writeText(exportText);
say(t("export.copied"));
} catch {
box.select();
say(document.execCommand?.("copy") ? t("export.copied") : t("export.copyFailed"), "ok");
}
render();
}
function download() {
const state = getState();
const slug = state.countryName.toLowerCase().normalize("NFD").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "game";
const url = URL.createObjectURL(new Blob([exportText], { type: "text/plain" }));
const a = document.createElement("a");
a.href = url;
a.download = `${slug}-day${state.day}.sav`;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
function doImport() {
const text = $(root, "#import-text").value;
if (!text.trim()) {
say(t("import.error.empty"), "error");
render();
return;
}
const decoded = decodeSave(text);
const r = decoded.ok ? restoreState(decoded.state, content) : decoded;
if (!r.ok) {
say(t("import.error." + r.error), "error");
const keep = text;
render();
$(root, "#import-text").value = keep;
return;
}
handlers.onLoadState(r.state);
}
function start() {
const id = $(root, "#new-scenario").value;
const pronouns = root.querySelector('input[name="pronouns"]:checked')?.value ?? "they";
const name = $(root, "#new-name").value.trim();
handlers.onStart({ countryName: name || tx(content.scenarios[id].name), scenarioId: id, pronouns });
}
const ACTIONS = {
continue: () => loadEntry(autosave()),
new: () => go("new"),
load: () => go("load"),
save: () => go("save"),
export: () => { exportText = encodeSave(getState()); go("export"); },
import: () => go("import"),
settings: () => go("settings"),
credits: () => go("credits"),
back: () => go("home"),
resume: () => handlers.onResume(),
quit: () => handlers.onQuit(),
start,
copy: copyExport,
download,
doImport,
lang: (arg) => { handlers.onSettings({ lang: arg }); render(); },
loadEntry: (arg) => loadEntry(readSave(storage, arg === "auto" ? KEYS.auto : KEYS.slot(arg))),
deleteSlot: (arg) => { storage.remove(KEYS.slot(arg)); disarm(); render(); },
saveSlot: (arg) => {
const ok = writeSave(storage, KEYS.slot(arg), getState());
disarm();
say(ok ? t("save.done", { n: arg }) : t("save.failed"), ok ? "ok" : "error");
render();
},
};
// Destructive actions need a second click on the same button.
const NEEDS_CONFIRM = {
loadEntry: () => mode === "game",
deleteSlot: () => true,
saveSlot: (arg) => readSave(storage, KEYS.slot(arg)) !== null,
};
root.addEventListener("click", (e) => {
const el = e.target.closest("[data-action]");
if (!el || el.disabled) return;
const { action, arg } = el.dataset;
const key = `${action}:${arg}`;
if (NEEDS_CONFIRM[action]?.(arg) && armed !== key) {
arm(key);
return;
}
disarm();
ACTIONS[action](arg);
});
root.addEventListener("change", async (e) => {
if (e.target.id === "setting-slow") handlers.onSettings({ slow: e.target.checked });
if (e.target.id === "import-file" && e.target.files[0]) {
$(root, "#import-text").value = await e.target.files[0].text();
}
});
document.addEventListener("keydown", (e) => {
if (root.hidden || e.key !== "Escape") return;
if (screen !== "home") go("home");
else if (mode === "game") handlers.onResume();
});
return {
show(nextMode, nextScreen = "home") {
mode = nextMode;
screen = nextScreen;
message = null;
disarm();
render();
root.hidden = false;
},
hide() {
root.hidden = true;
root.innerHTML = "";
disarm();
},
// Re-render after the language changed.
refresh() {
if (!root.hidden) render();
},
};
}
+96
View File
@@ -0,0 +1,96 @@
// Daily newspaper: headlines picked from templates by how much the indicators
// moved over the last few weeks. Selection is deterministic (hash of week + key)
// and never touches the game RNG, so reading the paper can't change the game.
import { t, tx, getLang, dateParts } from "../i18n/i18n.js";
import { IND_IDS, dateOfDay } from "../engine/index.js";
import { hash, $ } from "./desk.js";
const IND_THRESHOLD = 4; // points moved over the window to make news
const VAR_THRESHOLD = { growth: 0.4, unemployment: 0.4, inflation: 0.5, debtRatio: 3 };
// Each entry: [template key, test on the live state, severity score]
const LEVELS = [
["unrest.high", (s, f) => s.indicators.unrest >= 50, 1.7],
["opinion.low", (s, f) => s.indicators.opinion <= 30, 1.6],
["debt.high", (s, f) => f.debtRatio >= 100, 1.6],
["stability.low", (s, f) => s.indicators.stability <= 35, 1.6],
["civilRights.low", (s, f) => s.indicators.civilRights <= 25, 1.5],
["army.low", (s, f) => s.indicators.armyLoyalty <= 35, 1.7],
["inflation.high", (s, f) => s.economy.inflation >= 6, 1.5],
["deficit.high", (s, f) => f.deficit >= 8, 1.5],
];
function candidates(state, tpl) {
const out = [];
const h = state.history;
const n = h.t.length;
if (n >= 2) {
const back = Math.min(4, n - 1);
const consider = (group, id, threshold) => {
const s = h.series[id];
const d = s[n - 1] - s[n - 1 - back];
const dir = d > 0 ? "up" : "down";
const tpls = tpl[group][id]?.[dir];
if (tpls && Math.abs(d) / threshold >= 1) out.push({ score: Math.abs(d) / threshold, key: `${group}.${id}.${dir}`, tpls });
};
for (const id of IND_IDS) consider("ind", id, IND_THRESHOLD);
for (const [id, thr] of Object.entries(VAR_THRESHOLD)) consider("var", id, thr);
}
const debtRatio = h.series.debtRatio?.[n - 1] ?? 0;
const deficit = h.series.deficit?.[n - 1] ?? 0;
for (const [key, test, score] of LEVELS) {
if (tpl.level[key] && test(state, { debtRatio, deficit })) out.push({ score, key: `level.${key}`, tpls: tpl.level[key] });
}
return out.sort((a, b) => b.score - a.score);
}
export function computeHeadlines(state, tpl) {
const week = Math.floor(state.day / 7);
const pick = (tpls, key) => tpls[hash(`${key}:${week}`) % tpls.length];
if (state.history.t.length < 2 && state.day === 0) return [pick(tpl.intro, "intro")];
const found = candidates(state, tpl);
if (!found.length) return [pick(tpl.quiet, "quiet")];
return found.slice(0, 3).map((c) => pick(c.tpls, c.key));
}
export function createNewspaper(root, tpl) {
root.innerHTML = `
<header class="news__mast">
<h2>${tx(tpl.name)}</h2>
<div class="news__edition"></div>
</header>
<article class="news__lead"><h3></h3><p></p></article>
<div class="news__side"></div>`;
let shown = "";
function update(state) {
// The date line follows the calendar; headlines only change with the weekly history sample.
$(root, ".news__edition").textContent = t("news.edition", { date: dateParts(dateOfDay(state.startDate, state.day).iso).full });
const week = Math.floor(state.day / 7);
const key = `${week}|${getLang()}|${state.history.t.length}`;
if (key === shown) return;
shown = key;
const fill = (s) => s.replaceAll("{country}", state.countryName);
const [lead, ...rest] = computeHeadlines(state, tpl);
$(root, ".news__lead h3").textContent = fill(tx(lead.h));
$(root, ".news__lead p").textContent = fill(tx(lead.d));
const side = $(root, ".news__side");
side.replaceChildren(...rest.map((item) => {
const a = document.createElement("article");
const h = document.createElement("h4");
const p = document.createElement("p");
h.textContent = fill(tx(item.h));
p.textContent = fill(tx(item.d));
a.append(h, p);
return a;
}));
}
return { update };
}
+41
View File
@@ -0,0 +1,41 @@
// Urgent telegrams: the game warns that a way of losing power is getting close.
// They slide in over the desk and time stays stopped until the player acknowledges.
import { t, tx } from "../i18n/i18n.js";
import { $, esc } from "./desk.js";
export function createTelegram(root, { alerts, onDismiss }) {
root.addEventListener("click", (e) => {
if (e.target.closest("button[data-dismiss]")) onDismiss();
});
let shown = "";
function update(state) {
const alert = state.pending.alert && !state.over ? state.alerts[0] : null;
const key = alert ? `${alert.kind}:${alert.day}` : "";
if (key === shown) return;
shown = key;
if (!alert) {
root.hidden = true;
root.innerHTML = "";
return;
}
const text = alerts[alert.kind];
root.innerHTML = `
<article class="telegram" role="alertdialog" aria-labelledby="telegram-title">
<header class="telegram__head">
<span>${t("alert.urgent")}</span>
<span>${state.alerts.length > 1 ? `+${state.alerts.length - 1}` : ""}</span>
</header>
<h3 id="telegram-title">${esc(tx(text.title))}</h3>
<p>${esc(tx(text.body))}</p>
<button class="brass brass--wide" data-dismiss>${t("alert.ack")}</button>
</article>`;
root.hidden = false;
$(root, "button").focus();
}
return { update };
}