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,64 @@
import { sys } from 'cc';
import { IStorage, MemoryStorage } from './IStorage';
/**
* The one file in the save module that touches the engine.
*
* `sys.localStorage` is backed by the browser store on web and by a native file
* on Android and iOS, so one implementation covers every target. It can still
* fail — private browsing, a full quota, a locked profile — so every call is
* guarded and falls back to an in-memory store, which keeps the session
* consistent even when nothing can be persisted.
*/
export class CocosStorage implements IStorage {
private readonly _fallback = new MemoryStorage();
private _usingFallback = false;
/** True once a storage call has failed and the session went in-memory. */
get degraded(): boolean {
return this._usingFallback;
}
getItem(key: string): string | null {
if (this._usingFallback) {
return this._fallback.getItem(key);
}
try {
return sys.localStorage.getItem(key);
} catch (error) {
this._degrade(error);
return this._fallback.getItem(key);
}
}
setItem(key: string, value: string): void {
if (!this._usingFallback) {
try {
sys.localStorage.setItem(key, value);
return;
} catch (error) {
this._degrade(error);
}
}
this._fallback.setItem(key, value);
}
removeItem(key: string): void {
if (!this._usingFallback) {
try {
sys.localStorage.removeItem(key);
return;
} catch (error) {
this._degrade(error);
}
}
this._fallback.removeItem(key);
}
private _degrade(error: unknown): void {
if (this._usingFallback) return;
this._usingFallback = true;
const message = error instanceof Error ? error.message : String(error);
console.warn(`[save] device storage is unavailable, progress will not persist: ${message}`);
}
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "c355de0c-e0e7-4e52-8805-6a4fdacd5bef",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,25 @@
/**
* One independently saved slice of player progress.
*
* ARCHITECTURE.md asks for a save built from several classes, each handling its
* own part, keyed by the class. The key here is an explicit string rather than
* `constructor.name` on purpose: release builds minify class names, and a
* renamed class would silently orphan every existing save.
*/
export interface ISaveSection {
/** Stable identifier. Never change it once a build has shipped. */
readonly key: string;
/** Current contents as a JSON-safe value. */
save(): unknown;
/**
* Restores from a previously saved value. Must tolerate anything — an older
* shape, a newer shape, or outright rubbish — by falling back to defaults
* rather than throwing.
*/
load(data: unknown): void;
/** Returns to the state of a player who has never played. */
reset(): void;
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "48cc13db-e29f-4fa9-955f-fab6c05f76b4",
"files": [],
"subMetas": {},
"userData": {}
}
+35
View File
@@ -0,0 +1,35 @@
/**
* The key-value store a save is written to.
*
* Kept behind an interface so the save logic itself has no engine dependency
* and can be tested headlessly. `CocosStorage` is the real implementation;
* `MemoryStorage` is the one tests use.
*/
export interface IStorage {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
}
/** An in-process store. Used by tests, and as a fallback when storage is unavailable. */
export class MemoryStorage implements IStorage {
private readonly _data = new Map<string, string>();
getItem(key: string): string | null {
const value = this._data.get(key);
return value === undefined ? null : value;
}
setItem(key: string, value: string): void {
this._data.set(key, value);
}
removeItem(key: string): void {
this._data.delete(key);
}
/** Test helper: everything currently stored. */
get entries(): ReadonlyMap<string, string> {
return this._data;
}
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "a51ebd26-39af-478f-ac08-a839af126032",
"files": [],
"subMetas": {},
"userData": {}
}
+149
View File
@@ -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);
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "273eda8d-570a-4dee-8070-c03134c4568f",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -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;
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "c9c5a957-f40b-4887-a88b-5697c3b37c0c",
"files": [],
"subMetas": {},
"userData": {}
}