import { ISaveSection } from './ISaveSection'; import { IStorage } from './IStorage'; /** Bumped only when the envelope around the sections changes, not the sections themselves. */ export const SAVE_FORMAT_VERSION = 1; const DEFAULT_SLOT = 'fool.save'; interface SaveEnvelope { version: number; sections: Record; } export interface SaveManagerOptions { /** Storage key the whole save lives under. */ slot?: string; /** Where warnings go. Defaults to the console; tests pass their own. */ warn?: (message: string) => void; } /** * Collects the registered sections into one JSON document and puts it back * again on load, exactly as ARCHITECTURE.md describes. * * Loading is deliberately forgiving. A save that cannot be parsed, or a single * section whose payload has gone bad, resets only what it has to and lets * everything else through — a bad release should not be able to brick a player's * progress, and a partial load beats no load at all. */ export class SaveManager { private readonly _storage: IStorage; private readonly _slot: string; private readonly _warn: (message: string) => void; private readonly _sections: ISaveSection[] = []; constructor(storage: IStorage, options: SaveManagerOptions = {}) { this._storage = storage; this._slot = options.slot ?? DEFAULT_SLOT; this._warn = options.warn ?? ((m) => console.warn(`[save] ${m}`)); } /** Registers a section. Keys must be unique. */ register(section: ISaveSection): void { if (this._sections.some((s) => s.key === section.key)) { throw new Error(`A save section with key '${section.key}' is already registered`); } this._sections.push(section); } get sections(): readonly ISaveSection[] { return this._sections; } /** Serialises every section and writes the result. */ save(): void { const envelope: SaveEnvelope = { version: SAVE_FORMAT_VERSION, sections: {} }; for (const section of this._sections) { try { envelope.sections[section.key] = section.save(); } catch (error) { this._warn(`section '${section.key}' failed to save: ${describe(error)}`); } } try { this._storage.setItem(this._slot, JSON.stringify(envelope)); } catch (error) { // A full or disabled store must not take the game down with it. this._warn(`could not write the save: ${describe(error)}`); } } /** * Reads the save and hands each section its own payload. Sections with no * stored data — a new section in a new build, or a brand new player — are * reset to their defaults. */ load(): void { const envelope = this._read(); for (const section of this._sections) { const data = envelope?.sections[section.key]; if (data === undefined) { section.reset(); continue; } try { section.load(data); } catch (error) { this._warn(`section '${section.key}' failed to load, resetting it: ${describe(error)}`); section.reset(); } } } /** Resets every section and removes the stored save. */ reset(): void { for (const section of this._sections) { section.reset(); } try { this._storage.removeItem(this._slot); } catch (error) { this._warn(`could not clear the save: ${describe(error)}`); } } private _read(): SaveEnvelope | null { let raw: string | null; try { raw = this._storage.getItem(this._slot); } catch (error) { this._warn(`could not read the save: ${describe(error)}`); return null; } if (raw === null || raw === '') { return null; } let parsed: unknown; try { parsed = JSON.parse(raw); } catch (error) { this._warn(`the save is not valid JSON, starting fresh: ${describe(error)}`); return null; } if (!isRecord(parsed) || !isRecord(parsed.sections)) { this._warn('the save has an unrecognisable shape, starting fresh'); return null; } const version = typeof parsed.version === 'number' ? parsed.version : 0; if (version > SAVE_FORMAT_VERSION) { // Written by a newer build. Read what we can rather than wiping it. this._warn(`the save is from a newer version (${version}), reading it cautiously`); } return { version, sections: parsed.sections }; } } function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } function describe(error: unknown): string { return error instanceof Error ? error.message : String(error); }