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