// Boot, game lifecycle (menu, new game, load, autosave), panel wiring and the real-time loop. // // Dev URL params skip the menu and start a new game at once (and never touch the real autosave): // ?lang=fr ?seed=N ?pronouns=he|she|they ?alloc=K (scale every sector budget by K) // ?skip=DAYS (fast-forward, answering cards at random) ?card=ID (show that card first) // ?dev (exposes window.__game) ?new (just start a game) ?menu (force the menu) import { createGame, advanceDay, setBudget, setSpeed, setPronouns, dismissAlert, isWaiting, createClock, consumeClock, ensureCard, answerCard, indexCards, nextFloat, } from "./engine/index.js"; import { initI18n, setLang, getLang, t } from "./i18n/i18n.js"; import { createStorage, writeSave, loadSettings, saveSettings, KEYS } from "./save/storage.js"; import { tilt } from "./ui/desk.js"; import { createDashboard } from "./ui/dashboard.js"; import { createCalendar } from "./ui/calendar.js"; import { createNewspaper } from "./ui/newspaper.js"; import { createDecision } from "./ui/decision.js"; import { createLedger } from "./ui/ledger.js"; import { createTelegram } from "./ui/telegram.js"; import { createEndgame } from "./ui/endgame.js"; import { createMenu } from "./ui/menu.js"; const params = new URLSearchParams(location.search); const getJson = (url) => fetch(url).then((r) => r.json()); // ---- Content ---- const [balance, newsTpl, endgame, characters, cardIndex, scenarioIndex] = await Promise.all([ getJson("data/balance.json"), getJson("data/newspaper.json"), getJson("data/endgame.json"), getJson("data/characters.json"), getJson("data/cards/index.json"), getJson("data/scenarios/index.json"), ]); const cards = (await Promise.all(cardIndex.files.map((f) => getJson(`data/cards/${f}`)))).flat(); const scenarios = Object.fromEntries( await Promise.all(scenarioIndex.scenarios.map(async (id) => [id, await getJson(`data/scenarios/${id}.json`)])), ); const cardsById = indexCards(cards); const content = { scenarios, balance, cards }; // ---- Settings and storage ---- const storage = createStorage(); let settings = loadSettings(storage); const browserLang = navigator.language?.startsWith("fr") ? "fr" : "en"; await initI18n(params.get("lang") ?? settings.lang ?? browserLang); const DEV_PARAMS = ["seed", "pronouns", "alloc", "skip", "card", "dev", "new"]; const devRun = DEV_PARAMS.some((k) => params.has(k)) && !params.has("menu"); // ---- Game and screen state ---- let state = null; // the running game, or null in the main menu let scenario = null; let panels = null; let menuOpen = true; let lastSpeed = 1; const el = { dash: document.getElementById("dashboard"), folder: document.getElementById("folder"), ledger: document.getElementById("ledger"), alerts: document.getElementById("alerts"), endgame: document.getElementById("endgame"), cal: document.getElementById("calendar"), news: document.getElementById("newspaper"), menu: document.getElementById("menu"), }; const pad = document.getElementById("pad"); for (const id of ["dash", "folder", "cal", "news"]) tilt(el[id], id); // A weekly pause needs a card; with none eligible the week is simply empty. function prepareWeek() { ensureCard(state, balance, cards); } function autosave() { if (!state || devRun) return; if (state.over) storage.remove(KEYS.auto); // a finished game has nothing to continue else writeSave(storage, KEYS.auto, state); } // ---- Panels ---- function build() { panels?.decision.destroy(); document.title = t("app.title"); state.lang = getLang(); panels = { dash: createDashboard(el.dash, scenario), decision: createDecision(el.folder, { cardsById, characters, onAnswer, onProfile, keysEnabled: () => !menuOpen }), ledger: createLedger(el.ledger, scenario, { onConfirm: render }), cal: createCalendar(el.cal, balance, { onSpeed, onMenu: openMenu }), news: createNewspaper(el.news, newsTpl), alerts: createTelegram(el.alerts, { alerts: endgame.alerts, onDismiss }), endgame: createEndgame(el.endgame, { newsName: newsTpl.name, endgame, cards, onAgain: quitToMenu }), }; render(); } function render() { if (!state) return; for (const p of Object.values(panels)) p.update(state, balance); // The ledger covers the folder and the side column; the end screen covers everything. pad.classList.toggle("is-ledger", Boolean(state.pending.review && !state.pending.profile && !state.over)); pad.classList.toggle("is-endgame", Boolean(state.over)); autosave(); } // ---- Game lifecycle ---- function newGame({ countryName, scenarioId = scenarioIndex.scenarios[0], pronouns }) { return createGame({ scenario: scenarios[scenarioId], balance, seed: params.has("seed") ? Number(params.get("seed")) : crypto.getRandomValues(new Uint32Array(1))[0], lang: getLang(), countryName, pronouns, }); } function enterGame(next) { state = next; scenario = scenarios[state.scenarioId]; if (params.has("dev")) window.__game = state; lastSpeed = state.speed || 1; prepareWeek(); closeMenu(); build(); } function openMenu() { if (!state) return; menuOpen = true; pad.classList.add("is-menu"); menu.show("game"); } function closeMenu() { menuOpen = false; pad.classList.remove("is-menu"); menu.hide(); clock.acc = 0; } function quitToMenu() { autosave(); panels?.decision.destroy(); state = null; panels = null; for (const id of ["dash", "folder", "ledger", "alerts", "endgame", "cal", "news"]) el[id].innerHTML = ""; el.ledger.hidden = true; el.alerts.hidden = true; el.endgame.hidden = true; pad.classList.remove("is-ledger", "is-endgame"); pad.classList.add("is-menu"); menuOpen = true; menu.show("main"); } function applySettings(patch) { const langChanged = patch.lang && patch.lang !== getLang(); settings = { ...settings, ...patch }; saveSettings(storage, settings); if (!langChanged) return; setLang(patch.lang); document.title = t("app.title"); if (state) build(); menu.refresh(); } const menu = createMenu(el.menu, { content, storage, getState: () => state, getSettings: () => settings, handlers: { onStart: (options) => enterGame(newGame(options)), onLoadState: enterGame, onResume: closeMenu, onQuit: quitToMenu, onSettings: applySettings, }, }); // ---- Player actions ---- function onSpeed(speed) { if (speed > 0) lastSpeed = speed; setSpeed(state, balance, speed); render(); } function onAnswer(choice) { answerCard(state, balance, cards, choice); render(); } function onProfile(pronouns) { setPronouns(state, pronouns); render(); } function onDismiss() { dismissAlert(state); render(); } document.addEventListener("keydown", (e) => { if (!state || e.target.closest?.("input, textarea, select") || e.ctrlKey || e.metaKey || e.altKey) return; if (menuOpen) return; // the menu handles its own keys if (e.key === "Escape" && !state.over) { openMenu(); } else if (e.code === "Space") { e.preventDefault(); onSpeed(state.speed > 0 ? 0 : lastSpeed); } else if (/^[1-9]$/.test(e.key) && balance.time.speeds[Number(e.key) - 1]) { onSpeed(balance.time.speeds[Number(e.key) - 1]); } }); addEventListener("pagehide", autosave); document.addEventListener("visibilitychange", () => { if (document.hidden) autosave(); }); // ---- Dev shortcuts ---- function devGame() { const s = newGame({ pronouns: params.get("pronouns") ?? undefined }); state = s; // the helpers below act on `state` if (params.has("alloc")) { const k = Number(params.get("alloc")); setBudget(s, balance, { alloc: Object.fromEntries(Object.entries(s.sectors).map(([id, x]) => [id, x.alloc * k])) }); } for (let i = 0, n = Number(params.get("skip") ?? 0); i < n && !s.over; i++) { if (s.pending.profile) setPronouns(s, "they"); s.pending.review = false; while (s.pending.alert) dismissAlert(s); prepareWeek(); if (s.pendingCard) answerCard(s, balance, cards, nextFloat(s) < 0.5 ? "yes" : "no"); advanceDay(s, balance); } if (cardsById.has(params.get("card"))) { s.pendingCard = { id: params.get("card"), day: s.day }; s.pending.weeklyCard = true; } return s; } // ---- Start ---- const clock = createClock(); let last = performance.now(); if (devRun) enterGame(devGame()); else quitToMenu(); function frame(now) { const dt = now - last; last = now; if (state && !menuOpen && state.speed > 0 && !state.over && !isWaiting(state)) { const msPerDay = settings.slow ? balance.time.slowMsPerDay : balance.time.msPerDay; let days = consumeClock(clock, dt, msPerDay, state.speed); while (days-- > 0) { const r = advanceDay(state, balance); if (!r.advanced) break; if (r.events.weeklyCard) prepareWeek(); render(); if (isWaiting(state) || state.over) { clock.acc = 0; break; } } } else { clock.acc = 0; } requestAnimationFrame(frame); } requestAnimationFrame(frame);