Files
fool-cardgame/assets/scripts/modules/save/SaveManager.ts
T
ftcandClaude Opus 5 51117a698c M3: модуль сохранения
По ARCHITECTURE.md: реестр независимых секций, JSON с ключом на секцию.
Ключи — явные строковые константы, а не имена классов: релизная сборка
минифицирует имена и молча потеряла бы все сохранения.

- ISaveSection, SaveManager, IStorage + MemoryStorage
- CocosStorage: единственный файл модуля, знающий про движок;
  при отказе хранилища уходит в память, а не роняет игру
- StatsSection: сыграно/побед/поражений/ничьих, текущая и лучшая серия
- терпимая загрузка: битый JSON, чужая версия, сломанная секция
  или отвалившееся хранилище не ломают остальное
- проверка чистоты расширена на modules/ со списком адаптеров

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 10:45:52 +03:00

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);
}