По ARCHITECTURE.md: реестр независимых секций, JSON с ключом на секцию. Ключи — явные строковые константы, а не имена классов: релизная сборка минифицирует имена и молча потеряла бы все сохранения. - ISaveSection, SaveManager, IStorage + MemoryStorage - CocosStorage: единственный файл модуля, знающий про движок; при отказе хранилища уходит в память, а не роняет игру - StatsSection: сыграно/побед/поражений/ничьих, текущая и лучшая серия - терпимая загрузка: битый JSON, чужая версия, сломанная секция или отвалившееся хранилище не ломают остальное - проверка чистоты расширена на modules/ со списком адаптеров Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
85 lines
2.7 KiB
TypeScript
85 lines
2.7 KiB
TypeScript
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;
|
|
}
|