Audit item 14. THE DEPLOY VERIFIED THE ONE URL A PLAYER NEVER LOADS. Every check in deploy.sh appends ?v=$BUST — which is exactly what makes it bypass Cloudflare's edge — so all six could pass green while every actual player was still being served the previous build from a POP. The script's answer was a line of prose at the end asking a human to remember to purge. It now fetches js/boot.js the way a browser does, with no query string, and byte-compares it to what was just shipped; a stale edge fails the deploy and prints the purge steps. No credentials needed, and it asks the question the prose was only gesturing at. This is the same mistake as the `assets` bug and the unwinnable saliva tide: verifying the mechanism rather than the path the player actually takes through it. Third time, so it is written into the tool this time rather than into a comment. QUALITY SWITCH. renderer.setPixelRatio was hardcoded to min(dpr, 2), so a retina laptop drew four times the pixels of a 1x one with no way for its owner to decline — and this game is fill-bound, not geometry-bound (fogged tube, additive glows, a full-screen damage layer), so that is the lever that moves. `performance` caps it at 1. On the title beside difficulty, saved, same reload path (the pixel ratio is set at boot, before any card exists). save.js's two one-line setters collapsed into setOpt(key, value) now that there are two options and a third is obvious. Note on verification: the chip persists and boot reads it, confirmed live. The resolution change itself is not observable in the harness — that browser pane runs at devicePixelRatio 1, where min(1,1) and min(1,2) are the same number. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
111 lines
5.2 KiB
JavaScript
111 lines
5.2 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', quality: 'high', 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;
|
|
}
|
|
|
|
/** Set one option. Generic because there are two of them now and there will be three. */
|
|
export function setOpt(key, value) { const s = load(); s[key] = value; 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');
|
|
|
|
setOpt('diff', 'hard');
|
|
ok(load().diff === 'hard', 'difficulty persists');
|
|
ok(load().best.L1_mouth, 'setOpt does not clobber records');
|
|
setOpt('quality', 'low');
|
|
ok(load().quality === 'low' && load().diff === 'hard', 'options do not clobber each other');
|
|
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');
|
|
}
|