По ARCHITECTURE.md: реестр независимых секций, JSON с ключом на секцию. Ключи — явные строковые константы, а не имена классов: релизная сборка минифицирует имена и молча потеряла бы все сохранения. - ISaveSection, SaveManager, IStorage + MemoryStorage - CocosStorage: единственный файл модуля, знающий про движок; при отказе хранилища уходит в память, а не роняет игру - StatsSection: сыграно/побед/поражений/ничьих, текущая и лучшая серия - терпимая загрузка: битый JSON, чужая версия, сломанная секция или отвалившееся хранилище не ломают остальное - проверка чистоты расширена на modules/ со списком адаптеров Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
150 lines
4.9 KiB
TypeScript
150 lines
4.9 KiB
TypeScript
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<string, unknown>;
|
|
}
|
|
|
|
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<string, unknown> {
|
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function describe(error: unknown): string {
|
|
return error instanceof Error ? error.message : String(error);
|
|
}
|