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
116 lines
3.8 KiB
JavaScript
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;
|
|
}
|