First commit, done on M6 from the PLAN.MD

This commit is contained in:
gauvainboiche
2026-10-25 17:34:44 +01:00
parent d5dc2a3901
commit 04193e2013
86 changed files with 8396 additions and 0 deletions
+60
View File
@@ -0,0 +1,60 @@
// Desk calendar: in-game date, term progress, next decision dates, the brass speed
// controls and the way into the menu.
import { t, dateParts } from "../i18n/i18n.js";
import { dateOfDay } from "../engine/index.js";
import { $ } from "./desk.js";
export function createCalendar(root, bal, { onSpeed, onMenu }) {
const speeds = bal.time.speeds;
root.innerHTML = `
<div class="cal__head"><span class="cal__weekday"></span></div>
<div class="cal__body">
<div class="cal__day"></div>
<div class="cal__month"></div>
<div class="cal__count"></div>
<div class="cal__term"><span></span></div>
<dl class="cal__next">
<dt>${t("cal.briefing")}</dt><dd data-next="briefing"></dd>
<dt>${t("cal.review")}</dt><dd data-next="review"></dd>
</dl>
<div class="cal__controls">
<button class="brass" data-speed="0" title="${t("cal.pause")}" aria-label="${t("cal.pause")}">II</button>
${speeds.map((s) => `<button class="brass" data-speed="${s}" aria-label="${t("cal.speed", { n: s })}">${s}×</button>`).join("")}
</div>
<button class="brass cal__menu" data-menu>${t("cal.menu")}</button>
</div>`;
root.addEventListener("click", (e) => {
const b = e.target.closest("button");
if (!b) return;
if (b.dataset.menu !== undefined) onMenu();
else onSpeed(Number(b.dataset.speed));
});
const until = (day, every) => (every - (day % every)) % every;
const when = (n) => {
if (n === 0) return t("cal.today");
return n === 1 ? t("cal.tomorrow") : t("cal.inDays", { n });
};
function update(state) {
const p = dateParts(dateOfDay(state.startDate, state.day).iso);
$(root, ".cal__weekday").textContent = p.weekday;
$(root, ".cal__day").textContent = p.day;
$(root, ".cal__month").textContent = `${p.month} ${p.year}`;
const shown = Math.min(state.day + 1, bal.time.termDays);
$(root, ".cal__count").textContent = t("cal.dayCount", { day: shown, total: bal.time.termDays });
$(root, ".cal__term span").style.width = `${(state.day / bal.time.termDays) * 100}%`;
const over = Boolean(state.over);
$(root, '[data-next="briefing"]').textContent = over ? "–" : when(state.pending.weeklyCard ? 0 : until(state.day, bal.time.daysPerWeek));
$(root, '[data-next="review"]').textContent = over ? "–" : when(state.pending.review ? 0 : until(state.day, bal.time.daysPerQuarter));
for (const b of root.querySelectorAll("button[data-speed]")) {
b.setAttribute("aria-pressed", String(Number(b.dataset.speed) === state.speed));
}
}
return { update };
}
+45
View File
@@ -0,0 +1,45 @@
// Hand-drawn SVG helpers. Output is built from numbers only, so it is safe as innerHTML.
// Bigger chart for the end-of-term review: weekly samples over the whole term,
// a gridline per year and per quarter of the value range.
export function lineChart(values, { w = 260, h = 96, min = 0, max = 100, yearEvery = 52 } = {}) {
const pad = 3;
const n = values.length;
const span = max - min || 1;
const x = (i) => pad + (n > 1 ? (i / (n - 1)) * (w - 2 * pad) : 0);
const y = (v) => h - pad - Math.min(1, Math.max(0, (v - min) / span)) * (h - 2 * pad);
const grid = [];
for (let i = yearEvery; i < n; i += yearEvery) grid.push(`<line x1="${x(i).toFixed(1)}" y1="0" x2="${x(i).toFixed(1)}" y2="${h}"/>`);
for (const f of [0.25, 0.5, 0.75]) grid.push(`<line x1="0" y1="${(h * f).toFixed(1)}" x2="${w}" y2="${(h * f).toFixed(1)}"/>`);
const pts = values.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(" ");
return `<svg class="chart" viewBox="0 0 ${w} ${h}" width="100%" preserveAspectRatio="none" aria-hidden="true">` +
`<g class="spark__grid">${grid.join("")}</g>` +
(n > 1 ? `<polyline class="spark__line" points="${pts}" vector-effect="non-scaling-stroke"/>` : "") +
`</svg>`;
}
// Small line chart on graph paper. `values` are plotted evenly left to right.
export function sparkline(values, { w = 84, h = 24, min = 0, max = 100 } = {}) {
const grid = [];
for (let x = 12; x < w; x += 12) grid.push(`<line x1="${x}" y1="0" x2="${x}" y2="${h}"/>`);
for (const f of [0.25, 0.5, 0.75]) grid.push(`<line x1="0" y1="${h * f}" x2="${w}" y2="${h * f}"/>`);
let line = "";
const n = values.length;
if (n > 1) {
const span = max - min || 1;
const pts = values.map((v, i) => {
const x = 2 + (i / (n - 1)) * (w - 4);
const y = h - 2 - Math.min(1, Math.max(0, (v - min) / span)) * (h - 4);
return [x.toFixed(1), y.toFixed(1)];
});
const last = pts[n - 1];
line =
`<polyline class="spark__line" points="${pts.map((p) => p.join(",")).join(" ")}"/>` +
`<circle class="spark__dot" cx="${last[0]}" cy="${last[1]}" r="2"/>`;
}
return `<svg class="spark" viewBox="0 0 ${w} ${h}" width="${w}" height="${h}" aria-hidden="true">` +
`<g class="spark__grid">${grid.join("")}</g>${line}</svg>`;
}
+127
View File
@@ -0,0 +1,127 @@
// Ministerial briefing: seven indicator gauges with sparklines, economic figures
// with trend arrows, and a classified intelligence summary for the hidden gauges.
import { t, fmt } from "../i18n/i18n.js";
import { sparkline } from "./charts.js";
import { $ } from "./desk.js";
import { summarize } from "../engine/index.js";
const MAIN = ["order", "stability", "civilRights", "opinion", "economy", "hardPower", "softPower"];
// key, label, digits, suffix, summary field, history series, noise floor for the arrow, good when rising
const ECON = [
["gdp", "econ.gdp", 0, "", "gdp", null, 0, true],
["growth", "econ.growth", 1, " %", "growth", "growth", 0.15, true],
["unemployment", "econ.unemployment", 1, " %", "unemployment", "unemployment", 0.15, false],
["inflation", "econ.inflation", 1, " %", "inflation", "inflation", 0.2, false],
["debt", "econ.debt", 0, " %", "debtRatio", "debtRatio", 1, false],
["deficit", "econ.deficit", 1, " %", "deficit", "deficit", 0.3, false],
["rate", "econ.rate", 2, " %", "interestRate", null, 0, false],
];
const bandIndex = (v, cuts) => cuts.filter((c) => v >= c).length;
const OPINION_CUTS = [15, 30, 45, 60, 75, 90];
const UNREST_CUTS = [20, 35, 50, 65];
const ARMY_CUTS = [25, 45, 65];
const SPARK_WEEKS = 26;
const SPARK_W = 76;
const MIN_SPAN = 16;
export function createDashboard(root, scenario) {
root.innerHTML = `
<header class="dash__head">
<h2>${t("dash.title")}</h2>
<span class="stamp stamp--red">${t("dash.confidential")}</span>
</header>
<div class="dash__gauges">
${MAIN.map((id) => `
<div class="gauge" data-ind="${id}">
<span class="gauge__label">${t("ind." + id)}<small class="gauge__band"></small></span>
<span class="gauge__track"><span class="gauge__fill"></span><i></i><i></i><i></i></span>
<span class="gauge__value"></span>
<span class="gauge__spark"></span>
</div>`).join("")}
</div>
<h3 class="dash__sub">${t("dash.econ")}</h3>
<table class="econ"><tbody>
${ECON.map(([key, label]) => `
<tr data-econ="${key}"><th>${t(label)}</th><td class="econ__val"></td><td class="econ__trend"></td></tr>`).join("")}
</tbody></table>
<h3 class="dash__sub">${t("dash.intel")} <span class="stamp stamp--small">${t("dash.classified")}</span></h3>
<ul class="intel">
<li data-intel="unrest"><span>${t("ind.unrest")}</span><b></b></li>
<li data-intel="armyLoyalty"><span>${t("ind.armyLoyalty")}</span><b></b></li>
<li data-intel="regime"><span>${t("ind.regime")}</span><b></b></li>
</ul>`;
const gauges = Object.fromEntries(MAIN.map((id) => [id, {
el: $(root, `.gauge[data-ind="${id}"]`),
fill: $(root, `.gauge[data-ind="${id}"] .gauge__fill`),
value: $(root, `.gauge[data-ind="${id}"] .gauge__value`),
band: $(root, `.gauge[data-ind="${id}"] .gauge__band`),
spark: $(root, `.gauge[data-ind="${id}"] .gauge__spark`),
}]));
const sparkLen = {};
function trend(series, eps, goodUp) {
if (!series || series.length < 2) return ["", ""];
const cur = series[series.length - 1];
const prev = series[Math.max(0, series.length - 5)];
const d = cur - prev;
if (Math.abs(d) < eps) return ["▬", "flat"];
return [d > 0 ? "▲" : "▼", d > 0 === goodUp ? "good" : "bad"];
}
function update(state, bal) {
const sum = summarize(state, bal);
for (const id of MAIN) {
const g = gauges[id];
const v = state.indicators[id];
g.fill.style.width = `${v}%`;
g.el.classList.toggle("is-low", v < 30);
g.el.classList.toggle("is-high", v >= 70);
g.value.textContent = Math.round(v);
if (id === "opinion") g.band.textContent = t("opinion." + bandIndex(v, OPINION_CUTS));
const series = state.history.series[id] ?? [];
if (sparkLen[id] !== series.length) {
sparkLen[id] = series.length;
const win = series.slice(-SPARK_WEEKS);
// Zoom on the data (at least MIN_SPAN points tall) so small trends stay visible.
const mid = (Math.min(...win) + Math.max(...win)) / 2;
const span = Math.max(Math.max(...win) - Math.min(...win), MIN_SPAN);
g.spark.innerHTML = sparkline(win, { w: SPARK_W, min: mid - span / 2, max: mid + span / 2 });
}
}
for (const [key, , digits, suffix, field, seriesId, eps, goodUp] of ECON) {
const row = $(root, `[data-econ="${key}"]`);
const val = sum[field];
$(row, ".econ__val").textContent = key === "gdp"
? `${fmt(val, 0)} ${t("econ.gdpUnit", { currency: scenario.currency })}`
: `${fmt(val, digits)}${suffix}`;
const [arrow, cls] = trend(seriesId && state.history.series[seriesId], eps, goodUp);
const cell = $(row, ".econ__trend");
cell.textContent = arrow;
cell.className = `econ__trend ${cls}`;
}
$(root, '[data-intel="unrest"] b').textContent = t("unrest." + bandIndex(state.indicators.unrest, UNREST_CUTS));
$(root, '[data-intel="armyLoyalty"] b').textContent = t("army." + bandIndex(state.indicators.armyLoyalty, ARMY_CUTS));
// Hold on power: Critical once any danger zone has been entered, Shaky when close to one.
const i = state.indicators;
const inZone = state.risk.revolution > 0 || state.risk.coup > 0 || state.risk.impeachment > 0 || state.risk.defaultZone;
const close = i.unrest >= 55 || i.armyLoyalty <= 45 || i.opinion <= 30 || i.stability <= 40
|| i.civilRights <= 20 || sum.debtRatio >= 130;
const regime = inZone ? 2 : close ? 1 : 0;
$(root, '[data-intel="regime"] b').textContent = t("regime." + regime);
$(root, '[data-intel="regime"]').dataset.level = [0, 2, 3][regime];
$(root, '[data-intel="unrest"]').dataset.level = bandIndex(state.indicators.unrest, UNREST_CUTS);
$(root, '[data-intel="armyLoyalty"]').dataset.level = 3 - bandIndex(state.indicators.armyLoyalty, ARMY_CUTS);
}
return { update };
}
+113
View File
@@ -0,0 +1,113 @@
// Centre folder: the weekly decision card as a paper document with two rubber
// stamps. Also shows the idle and end-of-term messages.
import { t, tx, dateParts } from "../i18n/i18n.js";
import { dateOfDay } from "../engine/index.js";
import { $, esc } from "./desk.js";
const STAMP_MS = 750; // how long the stamp animation plays before the answer is applied
export function createDecision(root, { cardsById, characters, onAnswer, onProfile, keysEnabled = () => true }) {
root.innerHTML = `
<h2 class="inbox__tab">${t("inbox.title")}</h2>
<div class="inbox__body"></div>`;
const body = $(root, ".inbox__body");
let shown = "";
let locked = false;
let latest = null;
function choose(choice) {
if (locked) return;
const card = $(body, ".card");
if (!card) return;
locked = true;
card.dataset.stamped = choice;
for (const b of card.querySelectorAll("button")) b.disabled = true;
const stamp = document.createElement("div");
stamp.className = `card__stamp card__stamp--${choice}`;
stamp.textContent = t(choice === "yes" ? "card.approved" : "card.rejected");
card.append(stamp);
setTimeout(() => onAnswer(choice), STAMP_MS);
}
body.addEventListener("click", (e) => {
const b = e.target.closest("button[data-choice]");
if (b) choose(b.dataset.choice);
const p = e.target.closest("button[data-pronouns]");
if (p) onProfile(p.dataset.pronouns);
});
// The panel is rebuilt on language changes and new games, so its key listener must be removable.
const onKey = (e) => {
if (!keysEnabled() || !latest?.pendingCard) return;
if (latest.pending.review || latest.pending.profile || latest.pending.alert) return;
if (e.ctrlKey || e.metaKey || e.altKey) return;
if (e.target.closest?.("input, textarea, select")) return;
const k = e.key.toLowerCase();
if (k === "y") choose("yes");
else if (k === "n") choose("no");
};
document.addEventListener("keydown", onKey);
function option(card, side) {
const label = side === "yes" ? t("card.approved") : t("card.rejected");
return `
<button class="stamp-btn stamp-btn--${side}" data-choice="${side}">
<span class="stamp-btn__label">${esc(tx(card[side].label))}</span>
<span class="stamp-btn__ink">${esc(label)}</span>
</button>`;
}
function cardHtml(card, state) {
const from = characters[card.sender];
const date = dateParts(dateOfDay(state.startDate, state.pendingCard.day).iso);
const paragraphs = tx(card.body).split("\n\n").map((p) => `<p>${esc(p)}</p>`).join("");
return `
<article class="card card--${esc(card.doc)}">
<header class="card__head">
<span class="card__kind">${t("kind." + card.doc)}</span>
<span class="card__date">${esc(date.day)} ${esc(date.month)} ${esc(date.year)}</span>
</header>
<div class="card__from">
<span class="seal">${esc(from.seal)}</span>
<span><b>${esc(tx(from.name))}</b><small>${esc(tx(from.role))}</small></span>
</div>
<h3 class="card__title">${esc(tx(card.title))}</h3>
<div class="card__body">${paragraphs}</div>
<div class="card__choices">${option(card, "yes")}${option(card, "no")}</div>
<p class="card__keys">${t("card.keys")}</p>
</article>`;
}
function update(state) {
latest = state;
let kind = "idle";
if (state.over) kind = "over";
else if (state.pending.profile) kind = "profile";
else if (state.pendingCard) kind = `card:${state.pendingCard.id}`;
if (kind === shown) return;
shown = kind;
locked = false;
body.classList.toggle("is-card", kind.startsWith("card:"));
if (kind === "profile") {
body.innerHTML = `
<h3>${t("profile.title")}</h3>
<p>${t("profile.lead")}</p>
<p class="profile__ask">${t("profile.body")}</p>
<div class="profile__choices">
${["he", "she", "they"].map((p) => `<button class="brass brass--wide" data-pronouns="${p}">${t("profile." + p)}</button>`).join("")}
</div>
<p class="profile__note">${t("profile.note")}</p>`;
} else if (kind === "idle") {
body.innerHTML = `<p class="inbox__idle">${t("inbox.idle")}</p>`;
} else if (kind === "over") {
body.innerHTML = `<h3>${t("inbox.overTitle")}</h3><p>${t("inbox.overBody")}</p>`;
} else {
body.innerHTML = cardHtml(cardsById.get(state.pendingCard.id), state);
}
}
return { update, destroy: () => document.removeEventListener("keydown", onKey) };
}
+18
View File
@@ -0,0 +1,18 @@
// Desk-level helpers shared by the paper panels.
export function hash(str) {
let h = 2166136261;
for (let i = 0; i < str.length; i++) h = Math.imul(h ^ str.charCodeAt(i), 16777619);
return h >>> 0;
}
// Give a paper a stable slight rotation derived from its id.
export function tilt(el, id, amp = 0.8) {
const r = ((hash(id) % 1000) / 1000 - 0.5) * 2 * amp;
el.style.setProperty("--rot", `${r.toFixed(2)}deg`);
}
export const $ = (root, sel) => root.querySelector(sel);
// Escape text for use inside innerHTML templates.
export const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
+118
View File
@@ -0,0 +1,118 @@
// End of the game: a retrospective front page. It covers both the normal end of
// the term and each way of losing power early.
import { t, tx, tp, tt, getLang, dateParts, fmt } from "../i18n/i18n.js";
import { buildReview, dateOfDay, indexCards } from "../engine/index.js";
import { lineChart } from "./charts.js";
import { $, esc } from "./desk.js";
const MIN_SPAN = 30;
// series id in state.history, label key, show the term average or the final value
const CHARTS = [
["opinion", "ind.opinion", "avg"],
["stability", "ind.stability", "avg"],
["economy", "ind.economy", "avg"],
["debtRatio", "econ.debt", "final"],
];
export function createEndgame(root, { newsName, endgame, cards, onAgain }) {
const cardsById = indexCards(cards);
root.addEventListener("click", (e) => {
if (e.target.closest("button[data-again]")) onAgain();
});
let shown = "";
function chart(state, [id, label, mode], review) {
const series = state.history.series[id] ?? [];
const value = mode === "avg" ? review.metrics[id] : series.at(-1);
const tag = mode === "avg" ? t("end.averages") : t("end.final");
// Zoom on the data (at least MIN_SPAN points tall) so trends are visible; stay within 0-100
// for indicators, and above 0 for debt.
const lo = Math.min(...series);
const hi = Math.max(...series);
const span = Math.max(hi - lo, MIN_SPAN);
let min = Math.max(0, (lo + hi) / 2 - span / 2);
let max = min + span;
if (id !== "debtRatio" && max > 100) {
max = 100;
min = 100 - span;
}
return `
<figure class="end__chart">
<figcaption><span>${t(label)}</span><b>${esc(fmt(value, 0))}</b><small>${tag}</small></figcaption>
${lineChart(series, { min, max })}
</figure>`;
}
function decision(d) {
const card = cardsById.get(d.id);
const date = dateParts(dateOfDay(root.dataset.startDate, d.day).iso);
return `
<li>
<span class="end__when">${esc(date.day)} ${esc(date.month)} ${esc(date.year)}</span>
<b>${esc(tx(card.title))}</b>
<span class="end__chose">${esc(t("end.chose", { label: tx(card[d.choice].label) }))}</span>
</li>`;
}
function update(state, bal) {
if (!state.over) {
if (shown) {
root.hidden = true;
root.innerHTML = "";
shown = "";
}
return;
}
const key = `${state.over.type}:${state.over.day}:${getLang()}`;
if (key === shown) return;
shown = key;
root.dataset.startDate = state.startDate;
const review = buildReview(state, bal, cards, endgame);
const banner = endgame.over[state.over.type];
const entry = endgame.titles.find((x) => x.id === review.titleId);
const date = dateParts(dateOfDay(state.startDate, state.over.day).iso);
const lost = state.over.type !== "term";
root.innerHTML = `
<article class="paper paper--news end ${lost ? "end--lost" : ""}">
<header class="news__mast">
<h2>${esc(tx(newsName))}</h2>
<div class="news__edition">${esc(t("news.edition", { date: date.full }))}</div>
</header>
<div class="end__banner">
<h1>${esc(tx(banner.h))}</h1>
<p>${esc(tx(banner.d))}</p>
</div>
<div class="end__grid">
<section class="end__verdict">
<h3>${t("end.verdict")}</h3>
<div class="end__grade" data-grade="${esc(review.grade)}">
<span class="end__letter">${esc(review.grade)}</span>
<span>${t("end.grade")}</span>
</div>
<p class="end__title">${entry ? esc(tt(entry.title, state.pronouns)) : ""}</p>
<p class="end__text">${entry ? esc(tp(tx(entry.verdict), state.pronouns)) : ""}</p>
<p class="end__score">${t("end.score", { n: Math.round(review.score) })}<br>${t("end.served", { days: state.over.day })}</p>
</section>
<section class="end__charts">${CHARTS.map((c) => chart(state, c, review)).join("")}</section>
<section class="end__decisions">
<h3>${t("end.decisions")}</h3>
${review.topDecisions.length
? `<ol>${review.topDecisions.map(decision).join("")}</ol>`
: `<p class="inbox__idle">${t("end.noDecisions")}</p>`}
</section>
</div>
<footer class="end__foot"><button class="brass brass--wide" data-again>${t("end.again")}</button></footer>
</article>`;
root.hidden = false;
$(root, "button[data-again]").focus();
}
return { update };
}
+213
View File
@@ -0,0 +1,213 @@
// Quarterly budget ledger: a two-page accounting book. The left page sets sector
// budgets, the right page sets tax rates and shows the Treasury summary and a
// four-quarter projection. Nothing is applied until the player signs.
import { t, fmt, getLang } from "../i18n/i18n.js";
import {
previewBudget, setBudget, confirmBudget, qualityFromRatio, taxRevenue, TAX_IDS,
} from "../engine/index.js";
import { $ } from "./desk.js";
const ALLOC_STEP = 0.05;
const TAX_STEP = 0.5;
// Projection rows: label key, field on the summary, digits, suffix
const PROJ_ROWS = [
["econ.debt", (p) => p.debtRatio, 1, ""],
["econ.deficit", (p) => p.deficit, 1, ""],
["econ.growth", (p) => p.growth, 1, ""],
["econ.unemployment", (p) => p.unemployment, 1, ""],
["ind.opinion", (p) => p.indicators.opinion, 0, ""],
["ind.stability", (p) => p.indicators.stability, 0, ""],
["ind.economy", (p) => p.indicators.economy, 0, ""],
];
export function createLedger(root, scenario, { onConfirm }) {
let openKey = null;
let ctx = null; // { state, bal }
let draft = null;
let base = null;
let queued = false;
const sectorIds = (state) => Object.keys(state.sectors);
function open(state, bal) {
ctx = { state, bal };
base = {
alloc: Object.fromEntries(Object.entries(state.sectors).map(([id, s]) => [id, s.alloc])),
taxes: { ...state.economy.taxes },
};
draft = structuredClone(base);
const allocMax = Math.min(bal.limits.allocMaxRatio, bal.limits.sliderMaxRatio);
const sectorRows = sectorIds(state).map((id) => {
const s = state.sectors[id];
const max = Math.max(s.need * allocMax, s.alloc);
return `
<div class="lrow" data-sector="${id}">
<label class="lrow__name" for="alloc-${id}" title="${t("sector." + id)}">${t("sector." + id)}</label>
<span class="lrow__ctl">
<input type="range" id="alloc-${id}" min="0" max="${max}" step="${ALLOC_STEP}" value="${s.alloc}">
<i class="lrow__tick" style="left:${(s.need / max) * 100}%"></i>
</span>
<output class="lrow__val"></output>
<span class="lrow__q"></span>
</div>`;
}).join("");
const taxRows = TAX_IDS.map((id) => {
const max = bal.tax[id].peak * bal.limits.taxMaxFactorOfPeak;
return `
<div class="lrow lrow--tax" data-tax="${id}">
<label class="lrow__name" for="tax-${id}">${t("tax." + id)}</label>
<span class="lrow__ctl">
<input type="range" id="tax-${id}" min="0" max="${max}" step="${TAX_STEP}" value="${state.economy.taxes[id]}">
<i class="lrow__tick" style="left:${(bal.tax[id].peak / max) * 100}%"></i>
</span>
<output class="lrow__val"></output>
<span class="lrow__yield"></span>
</div>`;
}).join("");
root.innerHTML = `
<div class="ledger__book">
<article class="ledger__page ledger__page--left">
<h2>${t("ledger.title")}</h2>
<p class="ledger__sub">${t("ledger.spending")}. ${t("ledger.spendingHint")}</p>
<div class="ledger__rows">${sectorRows}</div>
<footer class="ledger__total">${t("ledger.totalAlloc")} <b data-total></b></footer>
</article>
<article class="ledger__page ledger__page--right">
<h2>${t("ledger.revenue")}</h2>
<p class="ledger__sub">${t("ledger.revenueHint")}</p>
<div class="ledger__rows">${taxRows}</div>
<h3>${t("ledger.summary")}</h3>
<dl class="sum" data-summary></dl>
<h3>${t("ledger.projection")}</h3>
<table class="proj"><thead></thead><tbody></tbody></table>
<footer class="ledger__actions">
<button class="brass" data-act="reset">${t("ledger.reset")}</button>
<button class="brass brass--wide" data-act="confirm">${t("ledger.confirm")}</button>
</footer>
<p class="ledger__error" data-error hidden>${t("ledger.error")}</p>
</article>
</div>`;
root.hidden = false;
refresh();
}
function close() {
root.hidden = true;
root.innerHTML = "";
openKey = null;
ctx = null;
}
// Cheap parts (values next to sliders) run immediately; the preview is coalesced per frame.
function refresh() {
const { state, bal } = ctx;
let total = 0;
for (const id of sectorIds(state)) {
const s = state.sectors[id];
const v = draft.alloc[id];
total += v;
const row = $(root, `[data-sector="${id}"]`);
$(row, ".lrow__val").textContent = fmt(v, 2);
const target = qualityFromRatio(v / s.need, bal);
const diff = target - s.quality;
const q = $(row, ".lrow__q");
q.textContent = diff > 1 ? "▲" : diff < -1 ? "▼" : "=";
q.className = `lrow__q ${diff > 1 ? "up" : diff < -1 ? "down" : "flat"}`;
q.title = t("ledger.quality", { now: Math.round(s.quality), target: Math.round(target) });
row.classList.toggle("is-changed", Math.abs(v - base.alloc[id]) > 1e-9);
}
$(root, "[data-total]").textContent = `${fmt(total, 2)} %`;
for (const id of TAX_IDS) {
const row = $(root, `[data-tax="${id}"]`);
$(row, ".lrow__val").textContent = `${fmt(draft.taxes[id], 1)} %`;
$(row, ".lrow__yield").textContent = t("ledger.yield", { n: fmt(taxRevenue(id, draft.taxes[id], bal), 1) });
row.classList.toggle("is-changed", Math.abs(draft.taxes[id] - base.taxes[id]) > 1e-9);
}
if (!queued) {
queued = true;
requestAnimationFrame(() => {
queued = false;
if (ctx) renderPreview();
});
}
}
function renderPreview() {
const { state, bal } = ctx;
const pv = previewBudget(state, bal, draft);
if (!pv.ok) return;
const f = pv.fiscal;
const gdp = state.economy.gdp;
const bn = (pct) => fmt(Math.abs((pct / 100) * gdp), 1);
const balance = -f.deficit; // positive = surplus
$(root, "[data-summary]").innerHTML = `
<dt>${t("ledger.sumRevenue")}</dt><dd>${fmt(f.revenue, 1)} %</dd>
<dt>${t("ledger.sumSectors")}</dt><dd>${fmt(f.allocTotal, 1)} %</dd>
<dt>${t("ledger.sumInterest")}</dt><dd>${fmt(f.interest, 1)} %</dd>
<dt>${t("ledger.sumSpending")}</dt><dd>${fmt(f.spending, 1)} %</dd>
<dt class="sum__balance ${balance >= 0 ? "good" : "bad"}">${t(balance >= 0 ? "ledger.sumSurplus" : "ledger.sumDeficit")}</dt>
<dd class="sum__balance ${balance >= 0 ? "good" : "bad"}">${fmt(Math.abs(balance), 1)} %
<small>${t("ledger.perYear", { n: bn(balance), currency: scenario.currency })}</small></dd>`;
const months = (i) => (i === 0 ? t("ledger.now") : t("ledger.months", { n: i * 3 }));
$(root, ".proj thead").innerHTML = `<tr><th></th>${pv.points.map((_, i) => `<th>${months(i)}</th>`).join("")}</tr>`;
$(root, ".proj tbody").innerHTML = PROJ_ROWS.map(([label, get, digits, suffix]) =>
`<tr><th>${t(label)}</th>${pv.points.map((p) => `<td>${fmt(get(p), digits)}${suffix}</td>`).join("")}</tr>`).join("");
}
function syncSliders() {
for (const id of sectorIds(ctx.state)) $(root, `#alloc-${id}`).value = draft.alloc[id];
for (const id of TAX_IDS) $(root, `#tax-${id}`).value = draft.taxes[id];
}
root.addEventListener("input", (e) => {
const input = e.target;
if (!ctx || input.type !== "range") return;
const v = Number(input.value);
if (input.id.startsWith("alloc-")) draft.alloc[input.id.slice(6)] = v;
else if (input.id.startsWith("tax-")) draft.taxes[input.id.slice(4)] = v;
refresh();
});
root.addEventListener("click", (e) => {
const b = e.target.closest("button[data-act]");
if (!b || !ctx) return;
if (b.dataset.act === "reset") {
draft = structuredClone(base);
syncSliders();
refresh();
} else {
const { state, bal } = ctx;
const res = setBudget(state, bal, draft);
if (!res.ok) {
$(root, "[data-error]").hidden = false;
return;
}
confirmBudget(state);
close();
onConfirm();
}
});
function update(state, bal) {
if (!(state.pending.review && !state.pending.profile && !state.over)) {
if (openKey !== null) close();
return;
}
const key = `${state.day}:${getLang()}`;
if (key !== openKey) {
openKey = key;
open(state, bal);
}
}
return { update };
}
+369
View File
@@ -0,0 +1,369 @@
// Main menu and in-game pause menu: a "Top secret" folder with one screen at a time.
// Modes: "main" (before a game) and "game" (pause menu while a game is running).
// The menu never changes the game itself; it hands results to main.js through `handlers`.
import { t, tx, getLang, dateParts } from "../i18n/i18n.js";
import { dateOfDay, restoreState, PRONOUNS } from "../engine/index.js";
import { encodeSave, decodeSave, migrate } from "../save/codec.js";
import { KEYS, SLOT_COUNT, readSave, writeSave } from "../save/storage.js";
import { $, esc } from "./desk.js";
const ARM_MS = 4000; // how long a "Sure?" button stays armed
export function createMenu(root, { content, storage, getState, getSettings, handlers }) {
let mode = "main";
let screen = "home";
let message = null; // { text, kind: "ok" | "error" }
let armed = null; // "action:arg" waiting for a second click
let armTimer = 0;
let exportText = "";
const say = (text, kind = "ok") => { message = { text, kind }; };
const go = (next) => { screen = next; message = null; disarm(); render(); };
function disarm() {
armed = null;
clearTimeout(armTimer);
}
function arm(key) {
armed = key;
clearTimeout(armTimer);
armTimer = setTimeout(() => { armed = null; render(); }, ARM_MS);
render();
}
// ---- Reading saves ----
const autosave = () => readSave(storage, KEYS.auto);
function describe(entry) {
const scenario = content.scenarios[entry.meta.scenarioId];
let date = "";
if (scenario) {
const p = dateParts(dateOfDay(scenario.startDate, entry.meta.day).iso);
date = ` · ${p.day} ${p.month} ${p.year}`;
}
const saved = new Intl.DateTimeFormat(getLang(), { dateStyle: "medium", timeStyle: "short" }).format(new Date(entry.meta.savedAt));
const info = t("load.info", { country: entry.meta.countryName, day: entry.meta.day, date });
return `${esc(info)}${entry.meta.over ? ` · ${t("load.finished")}` : ""}<small>${esc(saved)}</small>`;
}
// Turns a stored or imported state into a playable one, or explains why not.
function restore(rawState) {
const migrated = migrate(rawState);
if (!migrated.ok) return migrated;
return restoreState(migrated.state, content);
}
function loadEntry(entry) {
const r = entry ? restore(entry.state) : { ok: false, error: "invalid" };
if (!r.ok) {
say(t("load.unreadable"), "error");
render();
return;
}
handlers.onLoadState(r.state);
}
// ---- Screens ----
const button = (action, label, extra = "", cls = "") =>
`<button class="brass brass--wide ${cls}" data-action="${action}" ${extra}>${label}</button>`;
const back = () => `<footer class="menu__foot">${button("back", t("menu.back"))}</footer>`;
const notice = () => (message ? `<p class="menu__msg menu__msg--${message.kind}" role="status">${esc(message.text)}</p>` : "");
function home() {
const state = getState();
if (mode === "game") {
return `
<h3>${t("menu.paused")}</h3>
<p class="menu__sub">${esc(t("menu.info", { country: state.countryName, day: state.day }))}</p>
<div class="menu__list">
${button("resume", t("menu.resume"))}
${button("save", t("menu.save"))}
${button("load", t("menu.load"))}
${button("export", t("menu.export"))}
${button("import", t("menu.import"))}
${button("settings", t("menu.settings"))}
${button("quit", t("menu.quit"))}
</div>`;
}
const auto = autosave();
const canContinue = auto && !auto.meta.over;
return `
<h1 class="menu__title">${t("app.title")}</h1>
<p class="menu__sub">${t("menu.tagline")}</p>
${notice()}
<div class="menu__list">
${canContinue ? button("continue", `${t("menu.continue")}<small>${esc(t("menu.info", { country: auto.meta.countryName, day: auto.meta.day }))}</small>`) : ""}
${button("new", t("menu.new"))}
${button("load", t("menu.load"))}
${button("import", t("menu.import"))}
${button("settings", t("menu.settings"))}
${button("credits", t("menu.credits"))}
</div>`;
}
function newGame() {
const ids = Object.keys(content.scenarios);
const first = content.scenarios[ids[0]];
return `
<h3>${t("new.title")}</h3>
<label class="menu__field">${t("new.name")}
<input id="new-name" type="text" maxlength="40" placeholder="${esc(tx(first.name))}" autocomplete="off">
</label>
${ids.length > 1
? `<label class="menu__field">${t("new.scenario")}
<select id="new-scenario">${ids.map((id) => `<option value="${esc(id)}">${esc(tx(content.scenarios[id].name))}</option>`).join("")}</select>
</label>`
: `<input id="new-scenario" type="hidden" value="${esc(ids[0])}">`}
<fieldset class="menu__field menu__radios">
<legend>${t("new.pronouns")}</legend>
${PRONOUNS.map((p) => `<label><input type="radio" name="pronouns" value="${p}" ${p === "they" ? "checked" : ""}> ${t("profile." + p)}</label>`).join("")}
</fieldset>
<div class="menu__list">${button("start", t("new.start"))}</div>
${back()}`;
}
function slotRow(label, entry, actions) {
return `
<li class="menu__slot">
<span class="menu__slotname">${label}</span>
<span class="menu__slotinfo">${entry ? describe(entry) : `<em>${t("load.empty")}</em>`}</span>
<span class="menu__slotbtns">${actions}</span>
</li>`;
}
const confirmable = (action, arg, label, enabled = true) => {
const isArmed = armed === `${action}:${arg}`;
return `<button class="brass ${isArmed ? "brass--armed" : ""}" data-action="${action}" data-arg="${arg}" ${enabled ? "" : "disabled"}>${isArmed ? t("load.sure") : label}</button>`;
};
function load() {
const rows = [];
const auto = autosave();
rows.push(slotRow(t("load.auto"), auto, auto ? confirmable("loadEntry", "auto", t("load.load")) : ""));
for (let n = 1; n <= SLOT_COUNT; n++) {
const entry = readSave(storage, KEYS.slot(n));
rows.push(slotRow(t("load.slot", { n }), entry,
entry ? `${confirmable("loadEntry", String(n), t("load.load"))}${confirmable("deleteSlot", String(n), t("load.delete"))}` : ""));
}
return `
<h3>${t("load.title")}</h3>
${mode === "game" ? `<p class="menu__sub">${t("load.replace")}</p>` : ""}
${notice()}
<ul class="menu__slots">${rows.join("")}</ul>
${back()}`;
}
function save() {
const rows = [];
for (let n = 1; n <= SLOT_COUNT; n++) {
const entry = readSave(storage, KEYS.slot(n));
rows.push(slotRow(t("load.slot", { n }), entry,
confirmable("saveSlot", String(n), entry ? t("save.overwrite") : t("save.here"))));
}
return `
<h3>${t("save.title")}</h3>
${notice()}
<ul class="menu__slots">${rows.join("")}</ul>
${back()}`;
}
function exportScreen() {
return `
<h3>${t("export.title")}</h3>
<p class="menu__sub">${t("export.hint")}</p>
<textarea class="menu__text" id="export-text" readonly rows="8" spellcheck="false">${esc(exportText)}</textarea>
${notice()}
<div class="menu__row">
${button("copy", t("export.copy"))}
${button("download", t("export.download"))}
</div>
${back()}`;
}
function importScreen() {
return `
<h3>${t("import.title")}</h3>
<p class="menu__sub">${t("import.hint")}${mode === "game" ? ` ${t("load.replace")}` : ""}</p>
<textarea class="menu__text" id="import-text" rows="8" spellcheck="false" placeholder="CG1:…"></textarea>
${notice()}
<div class="menu__row">
<label class="brass brass--wide menu__file">${t("import.file")}<input type="file" id="import-file" accept=".sav,.txt,text/plain" hidden></label>
${button("doImport", t("import.go"))}
</div>
${back()}`;
}
function settings() {
const s = getSettings();
const lang = getLang();
return `
<h3>${t("settings.title")}</h3>
<div class="menu__field">
<span>${t("settings.language")}</span>
<div class="menu__row">
<button class="brass" data-action="lang" data-arg="en" aria-pressed="${lang === "en"}">English</button>
<button class="brass" data-action="lang" data-arg="fr" aria-pressed="${lang === "fr"}">Français</button>
</div>
</div>
<label class="menu__field menu__check">
<input type="checkbox" id="setting-slow" ${s.slow ? "checked" : ""}>
<span><b>${t("settings.slow")}</b><small>${t("settings.slowHint")}</small></span>
</label>
${storage.persistent ? "" : `<p class="menu__msg menu__msg--error">${t("settings.storage")}</p>`}
${back()}`;
}
function credits() {
return `
<h3>${t("credits.title")}</h3>
<p>${t("credits.fiction")}</p>
<p>${t("credits.tech")}</p>
<p>${t("credits.fonts")}</p>
${back()}`;
}
const SCREENS = { home, new: newGame, load, save, export: exportScreen, import: importScreen, settings, credits };
function render() {
root.innerHTML = `
<div class="menu__folder">
<h2 class="inbox__tab">${t("menu.secret")}</h2>
<span class="stamp menu__stamp" aria-hidden="true">${t("menu.secret")}</span>
<div class="menu__sheet">${SCREENS[screen]()}</div>
</div>`;
}
// ---- Actions ----
async function copyExport() {
const box = $(root, "#export-text");
try {
await navigator.clipboard.writeText(exportText);
say(t("export.copied"));
} catch {
box.select();
say(document.execCommand?.("copy") ? t("export.copied") : t("export.copyFailed"), "ok");
}
render();
}
function download() {
const state = getState();
const slug = state.countryName.toLowerCase().normalize("NFD").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "game";
const url = URL.createObjectURL(new Blob([exportText], { type: "text/plain" }));
const a = document.createElement("a");
a.href = url;
a.download = `${slug}-day${state.day}.sav`;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
function doImport() {
const text = $(root, "#import-text").value;
if (!text.trim()) {
say(t("import.error.empty"), "error");
render();
return;
}
const decoded = decodeSave(text);
const r = decoded.ok ? restoreState(decoded.state, content) : decoded;
if (!r.ok) {
say(t("import.error." + r.error), "error");
const keep = text;
render();
$(root, "#import-text").value = keep;
return;
}
handlers.onLoadState(r.state);
}
function start() {
const id = $(root, "#new-scenario").value;
const pronouns = root.querySelector('input[name="pronouns"]:checked')?.value ?? "they";
const name = $(root, "#new-name").value.trim();
handlers.onStart({ countryName: name || tx(content.scenarios[id].name), scenarioId: id, pronouns });
}
const ACTIONS = {
continue: () => loadEntry(autosave()),
new: () => go("new"),
load: () => go("load"),
save: () => go("save"),
export: () => { exportText = encodeSave(getState()); go("export"); },
import: () => go("import"),
settings: () => go("settings"),
credits: () => go("credits"),
back: () => go("home"),
resume: () => handlers.onResume(),
quit: () => handlers.onQuit(),
start,
copy: copyExport,
download,
doImport,
lang: (arg) => { handlers.onSettings({ lang: arg }); render(); },
loadEntry: (arg) => loadEntry(readSave(storage, arg === "auto" ? KEYS.auto : KEYS.slot(arg))),
deleteSlot: (arg) => { storage.remove(KEYS.slot(arg)); disarm(); render(); },
saveSlot: (arg) => {
const ok = writeSave(storage, KEYS.slot(arg), getState());
disarm();
say(ok ? t("save.done", { n: arg }) : t("save.failed"), ok ? "ok" : "error");
render();
},
};
// Destructive actions need a second click on the same button.
const NEEDS_CONFIRM = {
loadEntry: () => mode === "game",
deleteSlot: () => true,
saveSlot: (arg) => readSave(storage, KEYS.slot(arg)) !== null,
};
root.addEventListener("click", (e) => {
const el = e.target.closest("[data-action]");
if (!el || el.disabled) return;
const { action, arg } = el.dataset;
const key = `${action}:${arg}`;
if (NEEDS_CONFIRM[action]?.(arg) && armed !== key) {
arm(key);
return;
}
disarm();
ACTIONS[action](arg);
});
root.addEventListener("change", async (e) => {
if (e.target.id === "setting-slow") handlers.onSettings({ slow: e.target.checked });
if (e.target.id === "import-file" && e.target.files[0]) {
$(root, "#import-text").value = await e.target.files[0].text();
}
});
document.addEventListener("keydown", (e) => {
if (root.hidden || e.key !== "Escape") return;
if (screen !== "home") go("home");
else if (mode === "game") handlers.onResume();
});
return {
show(nextMode, nextScreen = "home") {
mode = nextMode;
screen = nextScreen;
message = null;
disarm();
render();
root.hidden = false;
},
hide() {
root.hidden = true;
root.innerHTML = "";
disarm();
},
// Re-render after the language changed.
refresh() {
if (!root.hidden) render();
},
};
}
+96
View File
@@ -0,0 +1,96 @@
// Daily newspaper: headlines picked from templates by how much the indicators
// moved over the last few weeks. Selection is deterministic (hash of week + key)
// and never touches the game RNG, so reading the paper can't change the game.
import { t, tx, getLang, dateParts } from "../i18n/i18n.js";
import { IND_IDS, dateOfDay } from "../engine/index.js";
import { hash, $ } from "./desk.js";
const IND_THRESHOLD = 4; // points moved over the window to make news
const VAR_THRESHOLD = { growth: 0.4, unemployment: 0.4, inflation: 0.5, debtRatio: 3 };
// Each entry: [template key, test on the live state, severity score]
const LEVELS = [
["unrest.high", (s, f) => s.indicators.unrest >= 50, 1.7],
["opinion.low", (s, f) => s.indicators.opinion <= 30, 1.6],
["debt.high", (s, f) => f.debtRatio >= 100, 1.6],
["stability.low", (s, f) => s.indicators.stability <= 35, 1.6],
["civilRights.low", (s, f) => s.indicators.civilRights <= 25, 1.5],
["army.low", (s, f) => s.indicators.armyLoyalty <= 35, 1.7],
["inflation.high", (s, f) => s.economy.inflation >= 6, 1.5],
["deficit.high", (s, f) => f.deficit >= 8, 1.5],
];
function candidates(state, tpl) {
const out = [];
const h = state.history;
const n = h.t.length;
if (n >= 2) {
const back = Math.min(4, n - 1);
const consider = (group, id, threshold) => {
const s = h.series[id];
const d = s[n - 1] - s[n - 1 - back];
const dir = d > 0 ? "up" : "down";
const tpls = tpl[group][id]?.[dir];
if (tpls && Math.abs(d) / threshold >= 1) out.push({ score: Math.abs(d) / threshold, key: `${group}.${id}.${dir}`, tpls });
};
for (const id of IND_IDS) consider("ind", id, IND_THRESHOLD);
for (const [id, thr] of Object.entries(VAR_THRESHOLD)) consider("var", id, thr);
}
const debtRatio = h.series.debtRatio?.[n - 1] ?? 0;
const deficit = h.series.deficit?.[n - 1] ?? 0;
for (const [key, test, score] of LEVELS) {
if (tpl.level[key] && test(state, { debtRatio, deficit })) out.push({ score, key: `level.${key}`, tpls: tpl.level[key] });
}
return out.sort((a, b) => b.score - a.score);
}
export function computeHeadlines(state, tpl) {
const week = Math.floor(state.day / 7);
const pick = (tpls, key) => tpls[hash(`${key}:${week}`) % tpls.length];
if (state.history.t.length < 2 && state.day === 0) return [pick(tpl.intro, "intro")];
const found = candidates(state, tpl);
if (!found.length) return [pick(tpl.quiet, "quiet")];
return found.slice(0, 3).map((c) => pick(c.tpls, c.key));
}
export function createNewspaper(root, tpl) {
root.innerHTML = `
<header class="news__mast">
<h2>${tx(tpl.name)}</h2>
<div class="news__edition"></div>
</header>
<article class="news__lead"><h3></h3><p></p></article>
<div class="news__side"></div>`;
let shown = "";
function update(state) {
// The date line follows the calendar; headlines only change with the weekly history sample.
$(root, ".news__edition").textContent = t("news.edition", { date: dateParts(dateOfDay(state.startDate, state.day).iso).full });
const week = Math.floor(state.day / 7);
const key = `${week}|${getLang()}|${state.history.t.length}`;
if (key === shown) return;
shown = key;
const fill = (s) => s.replaceAll("{country}", state.countryName);
const [lead, ...rest] = computeHeadlines(state, tpl);
$(root, ".news__lead h3").textContent = fill(tx(lead.h));
$(root, ".news__lead p").textContent = fill(tx(lead.d));
const side = $(root, ".news__side");
side.replaceChildren(...rest.map((item) => {
const a = document.createElement("article");
const h = document.createElement("h4");
const p = document.createElement("p");
h.textContent = fill(tx(item.h));
p.textContent = fill(tx(item.d));
a.append(h, p);
return a;
}));
}
return { update };
}
+41
View File
@@ -0,0 +1,41 @@
// Urgent telegrams: the game warns that a way of losing power is getting close.
// They slide in over the desk and time stays stopped until the player acknowledges.
import { t, tx } from "../i18n/i18n.js";
import { $, esc } from "./desk.js";
export function createTelegram(root, { alerts, onDismiss }) {
root.addEventListener("click", (e) => {
if (e.target.closest("button[data-dismiss]")) onDismiss();
});
let shown = "";
function update(state) {
const alert = state.pending.alert && !state.over ? state.alerts[0] : null;
const key = alert ? `${alert.kind}:${alert.day}` : "";
if (key === shown) return;
shown = key;
if (!alert) {
root.hidden = true;
root.innerHTML = "";
return;
}
const text = alerts[alert.kind];
root.innerHTML = `
<article class="telegram" role="alertdialog" aria-labelledby="telegram-title">
<header class="telegram__head">
<span>${t("alert.urgent")}</span>
<span>${state.alerts.length > 1 ? `+${state.alerts.length - 1}` : ""}</span>
</header>
<h3 id="telegram-title">${esc(tx(text.title))}</h3>
<p>${esc(tx(text.body))}</p>
<button class="brass brass--wide" data-dismiss>${t("alert.ack")}</button>
</article>`;
root.hidden = false;
$(root, "button").focus();
}
return { update };
}