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:
parent
d3341296ae
commit
fce8a19202
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue