First commit, done on M6 from the PLAN.MD
This commit is contained in:
@@ -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