// 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";
import { renderMarkdown } from "./markdown.js";
import { MIX_GROUPS } from "./audio.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 rulesCache = {}; // language -> rendered html, or "error"
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")}` : ""}${esc(saved)} `;
}
// 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 = "") =>
`${label} `;
const back = () => ``;
const notice = () => (message ? `
` : "");
function home() {
const state = getState();
if (mode === "game") {
return `
${t("menu.paused")}
`;
}
const auto = autosave();
const canContinue = auto && !auto.meta.over;
return `
${notice()}
`;
}
function newGame() {
const ids = Object.keys(content.scenarios);
const first = content.scenarios[ids[0]];
return `
${t("new.title")}
${ids.length > 1
? ``
: ` `}
${back()}`;
}
function slotRow(label, entry, actions) {
return `
`;
}
const confirmable = (action, arg, label, enabled = true) => {
const isArmed = armed === `${action}:${arg}`;
return `${isArmed ? t("load.sure") : label} `;
};
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 `
${t("load.title")}
${mode === "game" ? `` : ""}
${notice()}
${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 `
${t("save.title")}
${notice()}
${back()}`;
}
function exportScreen() {
return `
${t("export.title")}
${notice()}
${back()}`;
}
function importScreen() {
return `
${t("import.title")}
${notice()}
${back()}`;
}
function settings() {
const s = getSettings();
const lang = getLang();
return `
${t("settings.title")}
${storage.persistent ? "" : ``}
${back()}`;
}
function credits() {
return `
${t("credits.title")}
${t("credits.fiction")}
${t("credits.tech")}
${t("credits.fonts")}
${back()}`;
}
// The rules are plain Markdown files (data/rules/rules..md), fetched on first use.
function rules() {
const lang = getLang();
const body = rulesCache[lang];
if (body === undefined) {
rulesCache[lang] = null; // loading
fetch(`data/rules/rules.${lang}.md`)
.then((r) => (r.ok ? r.text() : Promise.reject(new Error(String(r.status)))))
.then((md) => { rulesCache[lang] = renderMarkdown(md); })
.catch(() => { rulesCache[lang] = "error"; })
.finally(() => { if (screen === "rules" && !root.hidden) render(); });
}
const html = body === "error" ? `` : body ?? ``;
return `${html}
${back()}`;
}
// One slider per sound group, plus the overall volume and the music. Levels apply as you drag.
function mixer() {
const s = getSettings();
const rows = [["master", s.volume], ["music", s.mix.music], ...MIX_GROUPS.map((g) => [g, s.mix[g]])];
return `
${t("mixer.title")}
${rows.map(([key, v]) => `
${t("mixer." + key)}
${Math.round(v * 100)}%
`).join("")}
`;
}
const SCREENS = { home, new: newGame, load, save, export: exportScreen, import: importScreen, settings, credits, rules, mixer };
function render() {
root.innerHTML = `
`;
}
// ---- 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"),
rules: () => go("rules"),
mixer: () => go("mixer"),
mixerReset: () => { handlers.onSettings({ resetMix: true }); render(); },
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("input", (e) => {
const key = e.target.dataset?.mix;
if (!key) return;
const v = Number(e.target.value);
e.target.parentElement.querySelector("output").textContent = `${Math.round(v * 100)}%`;
handlers.onSettings(key === "master" ? { volume: v } : { mix: { [key]: v } });
});
root.addEventListener("change", async (e) => {
if (e.target.dataset?.mix) handlers.onPreview?.(e.target.dataset.mix);
if (e.target.id === "setting-slow") handlers.onSettings({ slow: e.target.checked });
if (e.target.id === "setting-sound") { handlers.onSettings({ sound: e.target.checked }); render(); }
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();
},
};
}