Add a soft save record so Continue resumes the last launch

- SaveManager gains softGet/softPut/softClear and an optional `soft` field in the bank blob, invisible to listSlots/filledCount but counted by hasAny/latest/exportAll.
- latest() now considers the soft record as a Continue target, winning only on a strictly newer timestamp (a same-moment manual slot stays the pick); a soft winner reports slot null.
- SurfaceScene auto-snapshots via captureState on launch and calls softPut in a try/catch so a save failure never blocks take-off.
- MenuScene logs "auto" instead of a numeric slot when resuming the soft record.
- Tests cover soft round-trip, Continue eligibility, latest tie-breaking vs manual slots, persistence across managers, softClear, and exportAll inclusion.
This commit is contained in:
Brian Fertig 2026-09-27 17:24:50 -06:00
parent d3341296ae
commit fce8a19202
4 changed files with 134 additions and 14 deletions

View File

@ -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);

View File

@ -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<string,object>}} — 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);
}

View File

@ -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);

View File

@ -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');
}