Files
TheLadder/src/io/save.js
T
JesseMarkowitzandClaude Opus 5 7f20be83e8 Add The Frontier, a second setting, and a picker to choose between them
Cold Fork is a freight yard: 29 events across two stages, a four-day cycle, and
its own relationship and skill vocabulary. src/engine/ was not touched. Adding it
needed three changes outside the engine: one save slot per pack, app.js holding
the current pack, and turn.js no longer naming the mailroom (DECISIONS #34).
build.js now handles imports wrapped across lines. A conformance suite runs over
every registered pack. DECISIONS #35 records three calls on open questions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxdJFbLq1rBKsUeticGV1g
2026-09-12 21:03:50 -04:00

116 lines
3.8 KiB
JavaScript

// 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;
}