M3: модуль сохранения
По ARCHITECTURE.md: реестр независимых секций, JSON с ключом на секцию. Ключи — явные строковые константы, а не имена классов: релизная сборка минифицирует имена и молча потеряла бы все сохранения. - ISaveSection, SaveManager, IStorage + MemoryStorage - CocosStorage: единственный файл модуля, знающий про движок; при отказе хранилища уходит в память, а не роняет игру - StatsSection: сыграно/побед/поражений/ничьих, текущая и лучшая серия - терпимая загрузка: битый JSON, чужая версия, сломанная секция или отвалившееся хранилище не ломают остальное - проверка чистоты расширена на modules/ со списком адаптеров Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user