Audit items 9, 10 and 11 — three features whose backends were finished and whose front ends did not exist. DIFFICULTY WAS UNREACHABLE. levels/difficulty.js has carried three fully specified tiers since round 2 — Endoscopy, Clinical, Terminal, with per-tier assists and a note explaining each — and every single call site in the codebase took the default. `easy` and `hard` were a table nobody could select. getLevel now takes the saved difficulty (?diff= still wins, so a test URL stays absolute), and the title has the switch. Verified live: the world boots at 11 spawns on Terminal against 8 on Clinical. THE CAMPAIGN WAS SIX LEVELS WITH ONE WAY IN. Reaching anything but L1 meant hand-editing ?lvl= in the address bar. The title now lists the campaign, unlocking each segment as the one before it is filed, showing the medal you hold on each. `LS_` is C's secret prefix, so the appendix stays a row of question marks until you have earned your way in — naming it in the menu would have given away the one thing in this game you have to discover. THE CAMPAIGN HAD NO ENDING. C's anal_verge gate carries `to: null` — the only gate in the game that does, its own note calling it "the end of the campaign" — and boot's ui:continue handler opened with `if (!e.to) return`. Finishing GUTS parked you on a medal card forever with no way out but the address bar, and the card said "any key", the same words it uses when it has nothing to tell you. Now the clear is filed and counted, the card says you are out, and the title carries CANAL CLEARED. New core/save.js is the whole persistence layer: one localStorage key, one flat object, best time / best score / best medal kept per column (they need not come from the same run) plus an attempt count. Every entry point is wrapped — Safari private mode throws on setItem, and a save file must never be able to stop the game booting. Its selfcheck (`node web/js/core/save.js --selfcheck`, now in qa.sh) covers the best-of comparisons and feeds it corrupt, future-versioned and malformed saves. cards.js records rather than boot: this file computes the grade, so routing it through the bus for boot to re-derive would be two copies of the medal table waiting to disagree. One bug found by clicking rather than reading: the first cut of the chips had no pointer-events:auto. #ui is pointer-events:none and it inherits — the file's own header says so — so the buttons rendered, highlighted nothing, and passed the click through to the canvas, which on the title starts the run. It looked like a menu and behaved like wallpaper. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
108 lines
5.0 KiB
JavaScript
108 lines
5.0 KiB
JavaScript
// 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');
|
|
}
|