// Saving and loading. // // Local storage is the live save; the file export exists so a cleared cache or // a crashed browser does not take a career with it. A save is the whole game // state and nothing else — the engine keeps no state outside it, so this is a // complete record by construction. // // There is one slot per pack. A single global slot was fine while there was // one setting; the moment a second existed, switching settings would have // silently thrown away the other run — the guard in the app read the packId // off whatever was in the slot and started a new game when it did not match. const PREFIX = 'theladder.save.v1'; // The slot as it was before packs were selectable: one save, no pack in the // key. Read once, for Corporate Ladder only, so a run in progress survives // this change. Never written. const LEGACY_KEY = PREFIX; const LEGACY_PACK = 'corporateladder'; const LAST_PACK_KEY = 'theladder.pack.v1'; const FORMAT = 1; const slotFor = (packId) => `${PREFIX}.${packId}`; /** Local storage throws in private windows and when site data is blocked. */ function safely(action, fallback = null) { try { return action(); } catch { return fallback; } } export function saveLocal(packId, state) { return safely(() => { localStorage.setItem( slotFor(packId), JSON.stringify({ format: FORMAT, savedAt: new Date().toISOString(), state }), ); return true; }, false); } /** * The saved run for one pack, or null. Falls back to the pre-packs slot for * Corporate Ladder, and only when that slot really holds a Corporate Ladder * save — a stale slot from some other pack is not a reason to load a run into * a game whose events do not match it. */ export function loadLocal(packId) { return safely(() => { const raw = localStorage.getItem(slotFor(packId)); if (raw) return JSON.parse(raw)?.state ?? null; if (packId !== LEGACY_PACK) return null; const legacy = localStorage.getItem(LEGACY_KEY); if (!legacy) return null; const state = JSON.parse(legacy)?.state ?? null; return state?.meta?.packId === LEGACY_PACK ? state : null; }); } export function clearLocal(packId) { return safely(() => { localStorage.removeItem(slotFor(packId)); if (packId === LEGACY_PACK) localStorage.removeItem(LEGACY_KEY); return true; }, false); } /** Which pack was last played, so a returning player lands back in it. */ export function lastPlayed() { return safely(() => localStorage.getItem(LAST_PACK_KEY)); } export function rememberPack(packId) { return safely(() => { localStorage.setItem(LAST_PACK_KEY, packId); return true; }, false); } /** True if the browser will actually remember anything. */ export function storageAvailable() { return safely(() => { const probe = '__theladder_probe__'; localStorage.setItem(probe, '1'); localStorage.removeItem(probe); return true; }, false); } /** A save file's contents, as a string ready to be downloaded. */ export function serializeSave(state) { return JSON.stringify({ format: FORMAT, savedAt: new Date().toISOString(), state }, null, 2); } /** * Parse an imported save, refusing anything that is not one. A file from a * different pack is rejected by name rather than loaded into a game whose * events and characters do not match it. */ export function parseSave(text, expectedPackId) { let parsed; try { parsed = JSON.parse(text); } catch { throw new Error('That file is not valid JSON.'); } const state = parsed?.state; if (!state || typeof state !== 'object' || !state.meta || typeof state.turn !== 'number') { throw new Error('That does not look like a saved game.'); } if (expectedPackId && state.meta.packId !== expectedPackId) { throw new Error( `That save is from "${state.meta.packId}", but this is "${expectedPackId}".`, ); } return state; }