180 lines
6.1 KiB
JavaScript
180 lines
6.1 KiB
JavaScript
/**
|
||
* 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<string,object>}} — 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;
|
||
}
|
||
}
|