/** * 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… }, "7": { … } } } * * keyed 1..`save.slots` (10 by default) — exactly what the pop-up shows. * 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: {} }; let raw = null; try { raw = this.storage.getItem(this.key); } catch { return { format: SAVE_FORMAT, slots: {} }; } if (!raw) return { format: SAVE_FORMAT, slots: {} }; 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 }; } catch { // Corrupt blob — treat as empty (the UI offers a fresh write). return { format: SAVE_FORMAT, slots: {} }; } } 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 at least one slot holds a save */ hasAny() { const bank = this.readBank(); 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. */ filledCount() { return this.listSlots().filter((s) => s.record !== null).length; } /** * 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). * * @returns {{slot:number, 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 }; } } 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; } 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; } }