diff --git a/tools/qa.sh b/tools/qa.sh index c9fad88..f6e68eb 100755 --- a/tools/qa.sh +++ b/tools/qa.sh @@ -42,6 +42,11 @@ if [ -f web/js/world/spline.js ]; then node web/js/world/spline.js --selfcheck >/dev/null 2>&1 && green "world selfcheck" || red "world selfcheck failed" fi +# 4c. save file (headless; a shimmed localStorage) +if [ -f web/js/core/save.js ]; then + node web/js/core/save.js --selfcheck >/dev/null 2>&1 && green "save selfcheck" || red "save selfcheck failed" +fi + # 5. Lane D manifest validation (when it exists) if [ -f pipeline/validate_manifest.py ]; then python3 pipeline/validate_manifest.py && green "manifest valid" || red "manifest errors" diff --git a/web/js/boot.js b/web/js/boot.js index 9de6f7b..8a8993a 100644 --- a/web/js/boot.js +++ b/web/js/boot.js @@ -6,6 +6,7 @@ import * as THREE from 'three'; import { parseFlags } from './core/flags.js'; import { createRng } from './core/rng.js'; import { createBus } from './core/bus.js'; +import * as store from './core/save.js'; import { createAssets } from './core/assets.js'; import { createWorld as createStubWorld, STUB_LEVEL } from './stub/world_stub.js'; import { createPlayer } from './flight/player.js'; @@ -56,13 +57,20 @@ renderer.domElement.addEventListener('webglcontextlost', (e) => { const assets = await createAssets({ flags, renderer }); +let campaign = []; // C's level order, captured at load so the title can offer a level select + async function loadLevel(id) { try { const mod = await import('./levels/index.js'); // Lane C's registry + campaign = mod.CAMPAIGN ?? []; // `await`, not a bare return: without it the promise is handed to the caller and a rejection // (a mistyped ?lvl=, a 404 on the JSON) escapes this catch entirely, kills top-level module // evaluation, and the player gets a black page with no DBG, no HUD and no message. - return await mod.getLevel(id); // default level lives there + // DIFFICULTY IS NOW REACHABLE. levels/difficulty.js has carried three fully specified tiers + // since round 2 and every caller in the codebase took the default, so `easy` and `hard` + // existed only as a table nobody could select. ?diff= wins over the save so a test URL is + // still absolute; the save is what an actual player set on the title. + return await mod.getLevel(id, { difficulty: flags.diff ?? store.load().diff }); } catch (e) { console.info('[boot] Lane C registry not available —', e.message); return null; @@ -110,7 +118,7 @@ if (player) { import('./ui/hud.js'), import('./ui/comms.js'), import('./ui/feedback.js'), import('./ui/cards.js'), import('./audio/engine.js'), ]); - const args = { bus, flags, assets, level: world.level, world }; + const args = { bus, flags, assets, level: world.level, world, campaign }; // Each module is constructed independently: one that throws must not cost us the rest. // A missing score readout is a blemish; a UI layer that fails to build in a game whose // only other feedback is the wall rushing past is an unplayable build. @@ -239,7 +247,15 @@ bus.on('level:event', (ev) => { // Flags ride along, so ?dbg=1 / ?seed= survive the hop. Unknown/absent `to` = campaign over: // stay on the card rather than dumping the player into the stub tube. bus.on('ui:continue', async (e) => { - if (!e?.to) return; + // THE CAMPAIGN HAS AN ENDING. C's anal_verge gate carries `to: null` — the only gate in the + // game that does, and its own note calls it "the end of the campaign". That arrived at a + // handler which returned early, so finishing GUTS left you parked on a medal card forever + // with no way back but the address bar. Now it is filed and you are returned to the title, + // which is where the clear count is displayed. + if (!e?.to) { + if (e?.from) { store.clearCampaign(); location.search = ''; } + return; + } const mod = await import('./levels/index.js').catch(() => null); if (!mod?.CAMPAIGN?.includes(e.to)) { console.info(`[boot] no level "${e.to}" yet — campaign ends here`); return; } const q = new URLSearchParams(location.search); @@ -299,7 +315,7 @@ window.DBG = { get draws() { return stats.draws; }, get tris() { return stats.tris; }, get fps() { return fps; }, - world, player, combat, assets, ui, + world, player, combat, assets, ui, bus, // bus: so a UI screen can be driven without playing to it shot(name = 'shot') { const a = document.createElement('a'); a.download = `${name}.png`; diff --git a/web/js/core/flags.js b/web/js/core/flags.js index b040330..87027fd 100644 --- a/web/js/core/flags.js +++ b/web/js/core/flags.js @@ -12,6 +12,7 @@ export function parseFlags(search = location.search) { fakebus: on('fakebus'), localassets: p.get('localassets') !== '0', // ?localassets=0 => empty-manifest boot lvl: p.get('lvl') || null, + diff: p.get('diff') || null, // overrides the saved difficulty; null = use the save seed: p.has('seed') ? (parseInt(p.get('seed'), 10) >>> 0) : null, raw: p, }; diff --git a/web/js/core/save.js b/web/js/core/save.js new file mode 100644 index 0000000..5976496 --- /dev/null +++ b/web/js/core/save.js @@ -0,0 +1,107 @@ +// 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'); +} diff --git a/web/js/ui/cards.js b/web/js/ui/cards.js index 2bad252..913db4b 100644 --- a/web/js/ui/cards.js +++ b/web/js/ui/cards.js @@ -32,6 +32,11 @@ // Never put pointer-events:auto on the full-screen root — that swallows the canvas mouse-look // and aim that flight/input.js binds to renderer.domElement, i.e. the whole game. +// The one import in this file. cards.js owns the grade a run is given, so it is also the +// honest place to file it — routing the record through the bus so boot could re-derive the +// same grade would be two copies of the medal table waiting to disagree. +import * as store from '../core/save.js'; + const C = { line: '#39e6ff', // ART_BIBLE emissive code — neutral/interactive cyan soft: '#7fdfff', @@ -171,6 +176,21 @@ const CSS = ` /* the title sits over the live view; a hairline top/bottom band frames it without hiding it */ .crd .title .band { width:min(520px, 76vw); height:1px; background:currentColor; opacity:.3; margin:0 auto; } +/* --- title menu: level select + difficulty. Real