/** * SaveManager — the localStorage bank of saved games. * * One blob per browser (data/save.json → storageKey): the whole bank is * a single JSON document * * { "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). * * Pure (no Phaser, no DOM): the storage is injected (defaults to * window.localStorage), so it's Node-testable (dev/saves.test.mjs) and * trivially swappable for IndexedDB later. * * const sm = new SaveManager(); * sm.hasAny(); // → is Load Game enabled? * sm.listSlots(); // → [{slot:1, record:null}, …] ×10 * sm.put(3, record); sm.get(3); sm.clear(3); * sm.exportAll(); // → pretty JSON of every slot (download) * * Errors: storage that throws (private mode / quota) propagates — the * UI layer (SavePanel) catches and toasts it; nothing here crashes the * game. */ import { config } from '../config/Config.js'; const DEFAULT_KEY = 'orbit.saves.v1'; // FORMAT — bumped 1 → 2 with the galaxy redesign (60-system spanning-tree // galaxy, no rendered stars, planet-only gate anchors): format-1 saves // reference a galaxy that no longer exists and are REJECTED on load // (validateRecord) — they stay listed in the panel but fail with a clear // toast. New games write format 2. export const SAVE_FORMAT = 2; export class SaveManager { /** * @param {Storage|null} [storage] a localStorage-like ({getItem,setItem}) * — defaults to globalThis.localStorage when present. * @param {string} [key] the storage key — defaults to save.storageKey. */ constructor(storage = null, key = null) { this.storage = storage !== null ? storage : (typeof globalThis.localStorage !== 'undefined' ? globalThis.localStorage : null); this.key = key ?? config.get('save.storageKey', DEFAULT_KEY); } /** Is there any storage to live in at all (headless dev = no)? */ get available() { return !!this.storage; } // ------------------------------------------------------------------ // The blob // ------------------------------------------------------------------ /** @returns {{format:number, slots:Record}} — empty bank on any failure */ readBank() { 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: {}, soft: null }; } 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 : {}; 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: {}, soft: null }; } } writeBank(bank) { if (!this.storage) throw new Error('no storage available'); this.storage.setItem(this.key, JSON.stringify(bank)); } // ------------------------------------------------------------------ // Slots // ------------------------------------------------------------------ /** How many slots the game has (data/save.json → slots). */ slotCount() { return Math.max(1, Math.min(50, Math.round(config.get('save.slots', 10)))); } /** @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; } /** @returns {Array<{slot:number, record:object|null}>} all slots, in order */ listSlots() { const bank = this.readBank(); const out = []; for (let i = 1; i <= this.slotCount(); i++) { const record = bank.slots[String(i)]; out.push({ slot: i, record: record && typeof record === 'object' ? record : null }); } return out; } /** @returns {object|null} the save record in `slot` (1-based) */ get(slot) { const bank = this.readBank(); const rec = bank.slots[String(slot)]; return rec && typeof rec === 'object' ? rec : null; } /** Write `record` to `slot` (1-based). Throws on storage failure. */ put(slot, record) { if (!Number.isInteger(slot) || slot < 1) throw new Error(`invalid slot ${slot}`); const bank = this.readBank(); bank.slots[String(slot)] = record; bank.format = SAVE_FORMAT; this.writeBank(bank); } /** Empty `slot` (1-based). */ clear(slot) { const bank = this.readBank(); delete bank.slots[String(slot)]; this.writeBank(bank); } /** 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 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|null, record:object}|null} null when the bank is empty */ latest() { let best = null; // { slot, record, ts } for (const { slot, record } of this.listSlots()) { if (!record) continue; const parsed = Date.parse(record.savedAt); const ts = Number.isFinite(parsed) ? parsed : 0; if (!best || ts > best.ts || (ts === best.ts && slot > best.slot)) { 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; } // ------------------------------------------------------------------ // Export — the "download a local copy of ALL saved games" button. // ------------------------------------------------------------------ /** * Every saved game, one pretty-printed JSON document: * * { "app":"orbit", "format":2, "exportedAt":"…", "slots":{ "1":{…} } } * * @returns {string} JSON (empty slots are omitted) */ exportAll() { const out = { app: 'orbit', format: SAVE_FORMAT, exportedAt: new Date().toISOString(), slots: {}, }; 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); } // ------------------------------------------------------------------ // Records // ------------------------------------------------------------------ /** * Sanity-check a save record before it's trusted (write or load): * it must be an object of the CURRENT format with a seed and a ship * position. * * @returns {string|null} an error message, or null when the record passes */ static validateRecord(rec) { if (!rec || typeof rec !== 'object') return 'corrupt save data'; // Format gate — old builds' saves (format 1) predate the galaxy // redesign (the star was rendered, the gate network had loops and // shortcuts); their world no longer exists, so the save is a memory, // not a state: reject with a message the UI can toast. if (Number(rec.format) !== SAVE_FORMAT) { return `save is from an older orbit build (format ${rec.format ?? '?'}, this build needs ${SAVE_FORMAT}) — start a new game`; } if (!String(rec.seed ?? '').trim()) return 'save is missing its galaxy seed'; if (!rec.ship || typeof rec.ship.x !== 'number' || typeof rec.ship.y !== 'number') { return 'save is missing its ship position'; } return null; } }