diff --git a/dev/saves.test.mjs b/dev/saves.test.mjs index dd251e8..44d3f3e 100644 --- a/dev/saves.test.mjs +++ b/dev/saves.test.mjs @@ -188,6 +188,43 @@ const makeStorage = (fail = false) => { check('latest(): missing-timestamp tie breaks to the higher slot', m.latest()?.slot === 8); } + // soft record — the last launch's auto-snapshot (the launch's "soft save"): + // invisible to the slot pop-up, but a Continue target via hasAny/latest. + { + const s = makeStorage(); + const m = new SaveManager(s); + const older = makeRec({ savedAt: '2026-07-01T00:00:00.000Z' }); + const launch = makeRec({ savedAt: '2026-07-05T00:00:00.000Z' }); + check('soft: fresh bank has no soft record', m.softGet() === null); + m.softPut(launch); + check('softPut → softGet round-trips', JSON.stringify(m.softGet()) === JSON.stringify(launch)); + check('soft record is NOT a slot (listSlots unaffected)', m.listSlots().every((x) => x.record === null) && m.filledCount() === 0); + check('soft record alone lights hasAny() (Continue is enabled)', m.hasAny() === true); + check('latest(): soft alone → that record, slot null', m.latest()?.slot === null && JSON.stringify(m.latest()?.record) === JSON.stringify(launch)); + + m.put(4, older); // a manual slot OLDER than the launch + check('latest(): a newer soft record wins over an older manual slot', m.latest()?.slot === null && JSON.stringify(m.latest()?.record) === JSON.stringify(launch)); + + const newer = makeRec({ savedAt: '2026-07-06T00:00:00.000Z' }); + m.put(2, newer); // a manual slot NEWER than the launch + check('latest(): a newer manual slot beats the soft record', m.latest()?.slot === 2); + + m.clear(2); // back to soft (07-05) vs slot 4 (07-01) + m.put(6, makeRec({ savedAt: launch.savedAt })); // slot 6 TIES the soft's moment + check('latest(): a same-moment manual slot wins the tie (the deliberate choice)', m.latest()?.slot === 6); + + // A second manager on the same storage sees the soft record too. + const m2 = new SaveManager(s); + check('soft record persists in storage (new manager sees it)', m2.softGet() !== null && m2.softGet().savedAt === launch.savedAt); + m2.softClear(); + check('softClear() drops the soft record, slots untouched', m2.softGet() === null && m2.get(4) !== null && m2.get(6) !== null); + + // exportAll carries the soft record alongside the slots. + m.softPut(launch); + const parsed = JSON.parse(m.exportAll()); + check('exportAll(): the soft record rides the backup', parsed?.soft?.savedAt === launch.savedAt && parsed.slots['4'] !== undefined); + } + // validateRecord — the shape contract. check('validateRecord: null rejected', SaveManager.validateRecord(null) !== null); check('validateRecord: no seed rejected', SaveManager.validateRecord(makeRec({ seed: null })) !== null); diff --git a/js/save/SaveManager.js b/js/save/SaveManager.js index d7817d4..197d96b 100644 --- a/js/save/SaveManager.js +++ b/js/save/SaveManager.js @@ -4,9 +4,14 @@ * One blob per browser (data/save.json → storageKey): the whole bank is * a single JSON document * - * { "format": 2, "slots": { "3": { …save record… }, "7": { … } } } + * { "format": 2, "slots": { "3": { …save record… } }, "soft": { … } } * * keyed 1..`save.slots` (10 by default) — exactly what the pop-up shows. + * In addition to the numbered slots the bank carries an OPTIONAL `soft` + * record: the last launch's auto-snapshot (softPut). It is invisible to + * the slot pop-up (listSlots/filledCount) but counts for hasAny/latest, + * so the menu's Continue can resume it even when the player never hit + * Save. * Everything about the RECORD's shape is owned by js/save/SaveData.js * (captureState/prepareLoad); this class only owns the BLOB: read, * write, clear, list, export (the "download all" button). @@ -59,23 +64,26 @@ export class SaveManager { /** @returns {{format:number, slots:Record}} — empty bank on any failure */ readBank() { - if (!this.storage) return { format: SAVE_FORMAT, slots: {} }; + if (!this.storage) return { format: SAVE_FORMAT, slots: {}, soft: null }; let raw = null; try { raw = this.storage.getItem(this.key); } catch { - return { format: SAVE_FORMAT, slots: {} }; + return { format: SAVE_FORMAT, slots: {}, soft: null }; } - if (!raw) return { format: SAVE_FORMAT, slots: {} }; + if (!raw) return { format: SAVE_FORMAT, slots: {}, soft: null }; try { const data = JSON.parse(raw); const slots = (data && data.slots && typeof data.slots === 'object' && !Array.isArray(data.slots)) ? data.slots : {}; - return { format: typeof data?.format === 'number' ? data.format : SAVE_FORMAT, slots }; + const soft = (data && data.soft && typeof data.soft === 'object' && !Array.isArray(data.soft)) + ? data.soft + : null; + return { format: typeof data?.format === 'number' ? data.format : SAVE_FORMAT, slots, soft }; } catch { // Corrupt blob — treat as empty (the UI offers a fresh write). - return { format: SAVE_FORMAT, slots: {} }; + return { format: SAVE_FORMAT, slots: {}, soft: null }; } } @@ -93,9 +101,10 @@ export class SaveManager { return Math.max(1, Math.min(50, Math.round(config.get('save.slots', 10)))); } - /** @returns {boolean} true if at least one slot holds a save */ + /** @returns {boolean} true if any slot holds a save or the soft record stands */ hasAny() { const bank = this.readBank(); + if (bank.soft) return true; for (const v of Object.values(bank.slots)) if (v) return true; return false; } @@ -134,19 +143,62 @@ export class SaveManager { this.writeBank(bank); } - /** How many slots are filled. */ + /** How many slots are filled. (The soft record is not a slot — excluded.) */ filledCount() { return this.listSlots().filter((s) => s.record !== null).length; } + // ------------------------------------------------------------------ + // Soft record — the launch auto-snapshot + // ------------------------------------------------------------------ + + /** + * The soft record: the snapshot of the LAST launch off a world + * (SurfaceScene's take-off seam calls softPut on the captured state). + * Not a numbered slot — the pop-up never lists or edits it — but it + * IS a Continue target (latest/hasAny): if the player dies and never + * saved manually since, they resume the last launch, not a stale slot. + * + * @returns {object|null} the record, or null when no soft save stands + */ + softGet() { + const bank = this.readBank(); + return bank.soft && typeof bank.soft === 'object' ? bank.soft : null; + } + + /** + * Replace the soft record with `record` (same record shape as put — + * captureState's output). Overwrites the previous one; throws on + * storage failure like put (the caller toasts/warns, never crashes). + * + * @param {object} record a save record (js/save/SaveData.captureState) + */ + softPut(record) { + if (!record || typeof record !== 'object') throw new Error('invalid soft save record'); + const bank = this.readBank(); + bank.soft = record; + bank.format = SAVE_FORMAT; + this.writeBank(bank); + } + + /** Drop the soft record (manual slots untouched). */ + softClear() { + const bank = this.readBank(); + bank.soft = null; + this.writeBank(bank); + } + /** * The most recent save in the bank — the "Continue" target (the menu's - * Continue button resumes it): the filled slot with the NEWEST `savedAt` - * timestamp, regardless of slot number. A record without a parseable - * timestamp counts as oldest, and a timestamp tie breaks to the higher - * slot number (the later write). + * Continue button resumes it): the newest `savedAt` among the filled + * slots AND the soft record, regardless of slot number. A record + * without a parseable timestamp counts as oldest, and a timestamp tie + * among slots breaks to the higher slot number (the later write); the + * soft record wins only on a strictly newer timestamp (a same-moment + * manual slot save is the more deliberate choice). A soft winner is + * reported with `slot: null` (it belongs to no numbered slot). * - * @returns {{slot:number, record:object}|null} null when the bank is empty + * @returns {{slot:number|null, record:object}|null} null when the bank is empty */ latest() { let best = null; // { slot, record, ts } @@ -158,6 +210,15 @@ export class SaveManager { best = { slot, record, ts }; } } + // The soft record (the last launch's auto-snapshot): Continue it when + // it is strictly newer than the newest manual slot — the launch is + // the fresher state, and a same-moment manual save stays the pick. + const soft = this.softGet(); + if (soft) { + const parsed = Date.parse(soft.savedAt); + const ts = Number.isFinite(parsed) ? parsed : 0; + if (!best || ts > best.ts) best = { slot: null, record: soft, ts }; + } return best ? { slot: best.slot, record: best.record } : null; } @@ -182,6 +243,8 @@ export class SaveManager { for (const { slot, record } of this.listSlots()) { if (record) out.slots[String(slot)] = record; } + const soft = this.softGet(); + if (soft) out.soft = soft; // the last launch's auto-snapshot rides the backup return JSON.stringify(out, null, 2); } diff --git a/js/scenes/MenuScene.js b/js/scenes/MenuScene.js index 5a3311e..85fdff1 100644 --- a/js/scenes/MenuScene.js +++ b/js/scenes/MenuScene.js @@ -626,7 +626,9 @@ export class MenuScene extends Phaser.Scene { // Keep the seed panel in step with the galaxy we just staged, so the // next menu visit shows the seed Continue loaded. this.saveSeed(String(latest.record.seed).trim()); - console.info(`orbit — resuming ${latest.record.galaxyName ?? 'the galaxy'} (slot ${latest.slot}, seed ${latest.record.seed})`); + // A soft winner (the last launch's auto-snapshot) has no numbered + // slot — latest() reports it as slot null. + console.info(`orbit — resuming ${latest.record.galaxyName ?? 'the galaxy'} (slot ${latest.slot ?? 'auto'}, seed ${latest.record.seed})`); // The same glitch-out cut as New Game. this.overlay.trigger(240, 1); diff --git a/js/scenes/SurfaceScene.js b/js/scenes/SurfaceScene.js index 577e6ad..418d870 100644 --- a/js/scenes/SurfaceScene.js +++ b/js/scenes/SurfaceScene.js @@ -11,6 +11,7 @@ import { VignetteHub } from '../vignettes/VignetteHub.js'; import { resolveItems, worldContext } from '../ui/ExploreBar.js'; import { SavePanel } from '../ui/SavePanel.js'; import { SaveManager } from '../save/SaveManager.js'; +import { captureState } from '../save/SaveData.js'; import { BuildWindow } from '../ui/BuildWindow.js'; import { JobBoardWindow } from '../ui/JobBoardWindow.js'; import { QuestWindow } from '../ui/QuestWindow.js'; @@ -1859,6 +1860,23 @@ export class SurfaceScene extends Phaser.Scene { if (home && this.gameScene.buildState?.isBuilt(home, 'tether-l2')) { this.gameScene.comms?.trigger('tetherL2Launch'); } + // SOFT SAVE — every launch off a planet/station auto-snapshots the + // run into the bank's soft record (SaveManager.softPut, captured on + // the paused GameScene — the same captureState the Save panel uses). + // Invisible to the slot pop-up, but a Continue target: the menu's + // latest() resumes the NEWEST of the soft record and the manual + // slots, so a player who dies having forgotten to Save since their + // last launch still resumes the launch, not a stale slot (or + // nowhere at all). Runs AFTER the departure story seam + comms beat + // above so the record carries them; fails SOFT — the save is a + // convenience, never a blocker on take-off. + try { + if (this.gameScene && this.saveManager) { + this.saveManager.softPut(captureState(this.gameScene, this.time?.now)); + } + } catch (err) { + console.warn('orbit — soft save on launch failed', err); + } this.scene.stop('SurfaceScene'); this.scene.wake('GameScene'); }