orbit/js/save/SaveManager.js

202 lines
6.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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;
}
/**
* 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":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;
}
}