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:
ftc
2026-09-13 10:45:52 +03:00
co-authored by Claude Opus 5
parent c60fbcbb94
commit 51117a698c
17 changed files with 756 additions and 29 deletions
@@ -0,0 +1,84 @@
import { ISaveSection } from './ISaveSection';
export type MatchOutcome = 'win' | 'loss' | 'draw';
interface StatsData {
played: number;
won: number;
lost: number;
drawn: number;
streak: number;
bestStreak: number;
}
function emptyStats(): StatsData {
return { played: 0, won: 0, lost: 0, drawn: 0, streak: 0, bestStreak: 0 };
}
/**
* How the player has been doing. The only section iteration 1 ships; currency
* and level progress will join it later without touching `SaveManager`.
*/
export class StatsSection implements ISaveSection {
static readonly KEY = 'stats';
readonly key = StatsSection.KEY;
private _data: StatsData = emptyStats();
get played(): number { return this._data.played; }
get won(): number { return this._data.won; }
get lost(): number { return this._data.lost; }
get drawn(): number { return this._data.drawn; }
/** Wins in a row right now. A loss resets it; a draw leaves it alone. */
get streak(): number { return this._data.streak; }
get bestStreak(): number { return this._data.bestStreak; }
recordResult(outcome: MatchOutcome): void {
this._data.played += 1;
switch (outcome) {
case 'win':
this._data.won += 1;
this._data.streak += 1;
this._data.bestStreak = Math.max(this._data.bestStreak, this._data.streak);
break;
case 'loss':
this._data.lost += 1;
this._data.streak = 0;
break;
case 'draw':
this._data.drawn += 1;
break;
}
}
save(): unknown {
return { ...this._data };
}
load(data: unknown): void {
const source = (typeof data === 'object' && data !== null ? data : {}) as Record<string, unknown>;
const defaults = emptyStats();
this._data = {
played: count(source.played, defaults.played),
won: count(source.won, defaults.won),
lost: count(source.lost, defaults.lost),
drawn: count(source.drawn, defaults.drawn),
streak: count(source.streak, defaults.streak),
bestStreak: count(source.bestStreak, defaults.bestStreak),
};
// A save edited by hand should not be able to show a nonsense best.
this._data.bestStreak = Math.max(this._data.bestStreak, this._data.streak);
}
reset(): void {
this._data = emptyStats();
}
}
/** Reads a non-negative whole number, or falls back. */
function count(value: unknown, fallback: number): number {
return typeof value === 'number' && Number.isFinite(value) && value >= 0
? Math.floor(value)
: fallback;
}