// core/save.js (Lane F) — the only thing this game remembers between visits. // // One localStorage key holding one flat object. ponytail: no IndexedDB, no profiles, no server, // no migration ladder — a `v` mismatch starts you over, which is the honest cost of a save that // weighs 200 bytes. Add more when a run is worth more than a browser's clear-site-data checkbox. // // EVERY entry point here is wrapped, because localStorage is not a reliable API: Safari private // mode throws on setItem, a user can disable site data entirely, and another tab can leave half // a write behind. A save file must never be able to stop the game from booting — the worst it is // allowed to do is forget. const KEY = 'guts.save'; // Ascending, so index IS the ranking. cards.js grades a run; this decides whether that grade // beats the one on file. const RANK = ['none', 'bronze', 'silver', 'gold', 'platinum']; const EMPTY = () => ({ v: 1, diff: 'normal', cleared: 0, best: {} }); export function load() { try { const s = JSON.parse(localStorage.getItem(KEY)); if (!s || s.v !== 1) return EMPTY(); // Trust nothing that came off disk. A hand-edited file, a half-written record, or a save // from a future version must degrade to defaults rather than crash the title screen. return { ...EMPTY(), ...s, best: (s.best && typeof s.best === 'object') ? s.best : {}, cleared: Number.isFinite(s.cleared) ? s.cleared : 0, }; } catch { return EMPTY(); } } export function save(s) { try { localStorage.setItem(KEY, JSON.stringify(s)); } catch { /* forget, don't fail */ } return s; } export function setDiff(id) { const s = load(); s.diff = id; return save(s); } /** * File a finished run. * * Each column keeps its own best INDEPENDENTLY: your fastest time and your best medal need not * have come from the same run, and collapsing them to "the best run" throws away a record the * player actually earned. `runs` counts attempts — the only honest denominator if we ever want * to show a completion rate. */ export function record(id, stats = {}, medal = 'none') { if (!id) return null; const s = load(); const b = s.best[id] ?? { runs: 0, medal: 'none', time: null, score: 0 }; b.runs++; if (RANK.indexOf(medal) > RANK.indexOf(b.medal)) b.medal = medal; if (Number.isFinite(stats.time) && (b.time == null || stats.time < b.time)) b.time = stats.time; if (Number.isFinite(stats.score) && stats.score > b.score) b.score = stats.score; s.best[id] = b; return save(s); } /** The anal verge. Counted rather than flagged: a second clear is a thing worth knowing. */ export function clearCampaign() { const s = load(); s.cleared++; return save(s); } export function wipe() { try { localStorage.removeItem(KEY); } catch { /* already gone */ } } // --- selfcheck: `node web/js/core/save.js --selfcheck` ------------------------------------- // The per-column best-of is the only logic in here worth a check, and it is exactly the kind // that fails silently — a wrong comparison just quietly loses a record and nobody notices for // a month. Node has no localStorage, so it gets six lines of one. if (typeof process !== 'undefined' && process.argv?.[1]?.endsWith('save.js') && process.argv.includes('--selfcheck')) { const mem = new Map(); globalThis.localStorage = { getItem: (k) => (mem.has(k) ? mem.get(k) : null), setItem: (k, v) => mem.set(k, String(v)), removeItem: (k) => mem.delete(k), }; const ok = (c, m) => { if (!c) { console.error(`save selfcheck FAILED: ${m}`); process.exit(1); } }; ok(load().diff === 'normal', 'empty save defaults to normal'); record('L1_mouth', { time: 120, score: 900 }, 'silver'); record('L1_mouth', { time: 200, score: 1500 }, 'bronze'); // slower, richer, worse medal const b = load().best.L1_mouth; ok(b.time === 120, `keeps the FASTEST time (got ${b.time})`); ok(b.score === 1500, `keeps the HIGHEST score (got ${b.score})`); ok(b.medal === 'silver', `keeps the BEST medal (got ${b.medal})`); ok(b.runs === 2, `counts every attempt (got ${b.runs})`); record('L1_mouth', { time: 90, score: 100 }, 'platinum'); ok(load().best.L1_mouth.medal === 'platinum', 'a better medal replaces'); ok(load().best.L1_mouth.score === 1500, 'a worse score does not'); setDiff('hard'); ok(load().diff === 'hard', 'difficulty persists'); ok(load().best.L1_mouth, 'setDiff does not clobber records'); clearCampaign(); clearCampaign(); ok(load().cleared === 2, 'clears count'); mem.set(KEY, '{ this is not json'); ok(load().diff === 'normal', 'a corrupt save reads as empty rather than throwing'); mem.set(KEY, JSON.stringify({ v: 99, diff: 'hard' })); ok(load().diff === 'normal', 'a future version reads as empty'); mem.set(KEY, JSON.stringify({ v: 1, best: 'not an object' })); ok(typeof load().best === 'object', 'a malformed best[] is replaced, not trusted'); record('L2_esophagus', { time: 5 }, 'gold'); // must not throw on the repaired save ok(load().best.L2_esophagus.time === 5, 'recovers and records'); console.log('save selfcheck: ok'); }