/** * SaveManager — the localStorage bank of saved games. * * One blob per browser (data/save.json → storageKey): the whole bank is * a single JSON document * * { "format": 1, "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'; export const SAVE_FORMAT = 1; 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; } // ------------------------------------------------------------------ // Export — the "download a local copy of ALL saved games" button. // ------------------------------------------------------------------ /** * Every saved game, one pretty-printed JSON document: * * { "app":"orbit", "format":1, "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 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'; 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; } }