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,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": {}
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
+6
-6
@@ -50,12 +50,12 @@ progress. The Cocos layer only renders state and turns taps into actions.
|
||||
- [x] `MatchRunner` driving a full headless match
|
||||
- [x] Soak test: thousands of seeded self-play matches finish with no illegal state
|
||||
|
||||
### M3 — Savegame module `[ ]`
|
||||
- [ ] `ISaveSection` contract, `SaveManager` registry, JSON keyed by section name
|
||||
- [ ] Storage backend over `sys.localStorage` (works on web and native)
|
||||
- [ ] `StatsSection`: games played / won / lost, current and best win streak
|
||||
- [ ] Versioning + tolerant load (unknown or missing sections do not break a load)
|
||||
- [ ] Tests for save/load round-trip and corrupted-payload recovery
|
||||
### M3 — Savegame module `[x]`
|
||||
- [x] `ISaveSection` contract, `SaveManager` registry, JSON keyed by section name
|
||||
- [x] Storage backend over `sys.localStorage` (works on web and native)
|
||||
- [x] `StatsSection`: games played / won / lost, current and best win streak
|
||||
- [x] Versioning + tolerant load (unknown or missing sections do not break a load)
|
||||
- [x] Tests for save/load round-trip and corrupted-payload recovery
|
||||
|
||||
### M4 — Art pipeline `[ ]`
|
||||
- [ ] Pick the 36 needed faces from `local/`, downscale to the 1080-wide budget
|
||||
|
||||
+13
-4
@@ -24,9 +24,11 @@ from the layers below it and never from the ones above.
|
||||
core/ pure rules and bots — no `cc` import anywhere
|
||||
```
|
||||
|
||||
The rule enforced by review and by the test harness: **nothing under
|
||||
`assets/scripts/core/` may import from `cc`.** It is plain TypeScript, runnable
|
||||
under Node, and that is what makes the rules testable without the engine.
|
||||
The rule, enforced by `npm run check:core-purity`: **nothing under
|
||||
`assets/scripts/core/` may import from `cc`**, and under
|
||||
`assets/scripts/modules/` only the adapters explicitly listed in that script
|
||||
may. Everything else is plain TypeScript, runnable under Node, which is what
|
||||
makes the rules and the save format testable without the engine.
|
||||
|
||||
## Folder map
|
||||
|
||||
@@ -49,7 +51,8 @@ assets/
|
||||
match/ Match (stateful shell), runMatch (headless driver)
|
||||
util/ seeded RNG, shuffle
|
||||
modules/
|
||||
save/ SaveManager, ISaveSection, StatsSection, storage
|
||||
save/ SaveManager, ISaveSection, StatsSection,
|
||||
IStorage + MemoryStorage, CocosStorage (engine)
|
||||
app/ Bootstrap, ServiceLocator, SceneRouter
|
||||
ui/
|
||||
lobby/ lobby screen controller
|
||||
@@ -108,6 +111,12 @@ warning rather than failing the whole load, so a bad release cannot brick a save
|
||||
|
||||
Keys are explicit string constants, not class names — release builds minify.
|
||||
|
||||
The document written is `{ version, sections: { <key>: <payload> } }`. Storage
|
||||
sits behind `IStorage`, so all of the save logic is testable headlessly;
|
||||
`CocosStorage` is the only file in the module that imports the engine, and it
|
||||
falls back to an in-memory store when the device refuses to persist (private
|
||||
browsing, a full quota) rather than taking the game down.
|
||||
|
||||
## Conventions
|
||||
|
||||
- TypeScript, 4-space indent, `PascalCase` for types, `camelCase` for members,
|
||||
|
||||
@@ -1,28 +1,59 @@
|
||||
// Guards the one architectural rule that the type system cannot express:
|
||||
// assets/scripts/core must never import the Cocos engine. See docs/TECHNICAL.md.
|
||||
// Guards the one architectural rule the type system cannot express: the game
|
||||
// logic must not depend on the engine. See docs/TECHNICAL.md.
|
||||
//
|
||||
// core/ never imports `cc`, at all.
|
||||
// modules/ is engine-aware, but only through the adapters listed below —
|
||||
// everything else in it stays testable headlessly.
|
||||
//
|
||||
// A new file that needs `cc` under modules/ is a deliberate decision, so it has
|
||||
// to be added here on purpose rather than slipping in unnoticed.
|
||||
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { join, relative, sep } from 'node:path';
|
||||
|
||||
const PURE_ROOT = 'assets/scripts/core';
|
||||
const MODULES_ROOT = 'assets/scripts/modules';
|
||||
const ENGINE_ADAPTERS = new Set([
|
||||
'save/CocosStorage.ts',
|
||||
]);
|
||||
|
||||
const ENGINE_IMPORT = /\bfrom\s+['"](cc|cc\/env)['"]/;
|
||||
|
||||
const ROOT = 'assets/scripts/core';
|
||||
const FORBIDDEN = /from\s+['"](cc|cc\/env|db:\/\/)/;
|
||||
const offenders = [];
|
||||
|
||||
function walk(dir) {
|
||||
function walk(dir, onFile) {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const p = join(dir, entry);
|
||||
if (statSync(p).isDirectory()) walk(p);
|
||||
else if (p.endsWith('.ts')) {
|
||||
const src = readFileSync(p, 'utf8');
|
||||
src.split('\n').forEach((line, i) => {
|
||||
if (FORBIDDEN.test(line)) offenders.push(`${p}:${i + 1} ${line.trim()}`);
|
||||
});
|
||||
}
|
||||
const path = join(dir, entry);
|
||||
if (statSync(path).isDirectory()) walk(path, onFile);
|
||||
else if (path.endsWith('.ts')) onFile(path);
|
||||
}
|
||||
}
|
||||
|
||||
walk(ROOT);
|
||||
if (offenders.length) {
|
||||
console.error('core/ must not import the engine:\n' + offenders.join('\n'));
|
||||
function engineImports(path) {
|
||||
return readFileSync(path, 'utf8')
|
||||
.split('\n')
|
||||
.map((line, i) => ({ line: line.trim(), number: i + 1 }))
|
||||
.filter((entry) => ENGINE_IMPORT.test(entry.line));
|
||||
}
|
||||
|
||||
walk(PURE_ROOT, (path) => {
|
||||
for (const hit of engineImports(path)) {
|
||||
offenders.push(`${path}:${hit.number} ${hit.line} (core/ must never import the engine)`);
|
||||
}
|
||||
});
|
||||
|
||||
walk(MODULES_ROOT, (path) => {
|
||||
const key = relative(MODULES_ROOT, path).split(sep).join('/');
|
||||
if (ENGINE_ADAPTERS.has(key)) return;
|
||||
for (const hit of engineImports(path)) {
|
||||
offenders.push(
|
||||
`${path}:${hit.number} ${hit.line} (add '${key}' to ENGINE_ADAPTERS if this is intended)`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
if (offenders.length > 0) {
|
||||
console.error(`Engine dependency in code that must stay pure:\n${offenders.join('\n')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('core purity OK');
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ISaveSection } from '@modules/save/ISaveSection';
|
||||
import { IStorage, MemoryStorage } from '@modules/save/IStorage';
|
||||
import { SAVE_FORMAT_VERSION, SaveManager } from '@modules/save/SaveManager';
|
||||
import { StatsSection } from '@modules/save/StatsSection';
|
||||
|
||||
const SLOT = 'test.save';
|
||||
|
||||
/** A minimal section, so manager behaviour can be tested without StatsSection. */
|
||||
class CounterSection implements ISaveSection {
|
||||
readonly key: string;
|
||||
value = 0;
|
||||
|
||||
constructor(key = 'counter') {
|
||||
this.key = key;
|
||||
}
|
||||
|
||||
save(): unknown {
|
||||
return { value: this.value };
|
||||
}
|
||||
|
||||
load(data: unknown): void {
|
||||
const record = data as { value?: unknown };
|
||||
if (typeof record?.value !== 'number') {
|
||||
throw new Error('not a counter');
|
||||
}
|
||||
this.value = record.value;
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.value = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function makeManager(storage: IStorage = new MemoryStorage()) {
|
||||
const warnings: string[] = [];
|
||||
const manager = new SaveManager(storage, { slot: SLOT, warn: (m) => warnings.push(m) });
|
||||
return { manager, storage, warnings };
|
||||
}
|
||||
|
||||
describe('SaveManager', () => {
|
||||
it('writes one document keyed by section', () => {
|
||||
const { manager, storage } = makeManager();
|
||||
const counter = new CounterSection();
|
||||
manager.register(counter);
|
||||
counter.value = 7;
|
||||
|
||||
manager.save();
|
||||
|
||||
const raw = JSON.parse(storage.getItem(SLOT) as string);
|
||||
expect(raw).toEqual({ version: SAVE_FORMAT_VERSION, sections: { counter: { value: 7 } } });
|
||||
});
|
||||
|
||||
it('round-trips through a fresh manager', () => {
|
||||
const storage = new MemoryStorage();
|
||||
const first = makeManager(storage);
|
||||
const a = new CounterSection();
|
||||
first.manager.register(a);
|
||||
a.value = 42;
|
||||
first.manager.save();
|
||||
|
||||
const second = makeManager(storage);
|
||||
const b = new CounterSection();
|
||||
second.manager.register(b);
|
||||
second.manager.load();
|
||||
|
||||
expect(b.value).toBe(42);
|
||||
});
|
||||
|
||||
it('resets a section that has never been saved', () => {
|
||||
const { manager } = makeManager();
|
||||
const counter = new CounterSection();
|
||||
counter.value = 99;
|
||||
manager.register(counter);
|
||||
|
||||
manager.load();
|
||||
|
||||
expect(counter.value).toBe(0);
|
||||
});
|
||||
|
||||
it('rejects two sections with the same key', () => {
|
||||
const { manager } = makeManager();
|
||||
manager.register(new CounterSection('dup'));
|
||||
expect(() => manager.register(new CounterSection('dup'))).toThrow(/already registered/);
|
||||
});
|
||||
|
||||
it('clears storage and every section on reset', () => {
|
||||
const { manager, storage } = makeManager();
|
||||
const counter = new CounterSection();
|
||||
manager.register(counter);
|
||||
counter.value = 5;
|
||||
manager.save();
|
||||
|
||||
manager.reset();
|
||||
|
||||
expect(counter.value).toBe(0);
|
||||
expect(storage.getItem(SLOT)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SaveManager recovering from bad data', () => {
|
||||
it('starts fresh when the save is not JSON', () => {
|
||||
const storage = new MemoryStorage();
|
||||
storage.setItem(SLOT, 'not json at all');
|
||||
const { manager, warnings } = makeManager(storage);
|
||||
const counter = new CounterSection();
|
||||
counter.value = 3;
|
||||
manager.register(counter);
|
||||
|
||||
manager.load();
|
||||
|
||||
expect(counter.value).toBe(0);
|
||||
expect(warnings.join(' ')).toMatch(/not valid JSON/);
|
||||
});
|
||||
|
||||
it('starts fresh when the save has the wrong shape', () => {
|
||||
const storage = new MemoryStorage();
|
||||
storage.setItem(SLOT, JSON.stringify([1, 2, 3]));
|
||||
const { manager, warnings } = makeManager(storage);
|
||||
manager.register(new CounterSection());
|
||||
|
||||
manager.load();
|
||||
|
||||
expect(warnings.join(' ')).toMatch(/unrecognisable/);
|
||||
});
|
||||
|
||||
it('resets only the section whose payload is broken', () => {
|
||||
const storage = new MemoryStorage();
|
||||
storage.setItem(SLOT, JSON.stringify({
|
||||
version: SAVE_FORMAT_VERSION,
|
||||
sections: { good: { value: 11 }, bad: 'rubbish' },
|
||||
}));
|
||||
const { manager, warnings } = makeManager(storage);
|
||||
const good = new CounterSection('good');
|
||||
const bad = new CounterSection('bad');
|
||||
bad.value = 8;
|
||||
manager.register(good);
|
||||
manager.register(bad);
|
||||
|
||||
manager.load();
|
||||
|
||||
expect(good.value).toBe(11);
|
||||
expect(bad.value).toBe(0);
|
||||
expect(warnings.join(' ')).toMatch(/'bad' failed to load/);
|
||||
});
|
||||
|
||||
it('reads what it can from a save written by a newer build', () => {
|
||||
const storage = new MemoryStorage();
|
||||
storage.setItem(SLOT, JSON.stringify({
|
||||
version: SAVE_FORMAT_VERSION + 5,
|
||||
sections: { counter: { value: 4 }, unknownFutureThing: { x: 1 } },
|
||||
}));
|
||||
const { manager, warnings } = makeManager(storage);
|
||||
const counter = new CounterSection();
|
||||
manager.register(counter);
|
||||
|
||||
manager.load();
|
||||
|
||||
expect(counter.value).toBe(4);
|
||||
expect(warnings.join(' ')).toMatch(/newer version/);
|
||||
});
|
||||
|
||||
it('survives a storage device that throws', () => {
|
||||
const broken: IStorage = {
|
||||
getItem: () => { throw new Error('quota'); },
|
||||
setItem: () => { throw new Error('quota'); },
|
||||
removeItem: () => { throw new Error('quota'); },
|
||||
};
|
||||
const { manager, warnings } = makeManager(broken);
|
||||
manager.register(new CounterSection());
|
||||
|
||||
expect(() => { manager.load(); manager.save(); manager.reset(); }).not.toThrow();
|
||||
expect(warnings.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('survives a section that throws while saving', () => {
|
||||
const { manager, storage, warnings } = makeManager();
|
||||
manager.register({
|
||||
key: 'explodes',
|
||||
save() { throw new Error('boom'); },
|
||||
load() { /* never reached */ },
|
||||
reset() { /* nothing */ },
|
||||
});
|
||||
const counter = new CounterSection();
|
||||
manager.register(counter);
|
||||
counter.value = 2;
|
||||
|
||||
manager.save();
|
||||
|
||||
const raw = JSON.parse(storage.getItem(SLOT) as string);
|
||||
expect(raw.sections.counter).toEqual({ value: 2 });
|
||||
expect(warnings.join(' ')).toMatch(/'explodes' failed to save/);
|
||||
});
|
||||
|
||||
it('warns through the console when no handler is supplied', () => {
|
||||
const spy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
const storage = new MemoryStorage();
|
||||
storage.setItem('fool.save', '{');
|
||||
new SaveManager(storage).load();
|
||||
expect(spy).toHaveBeenCalled();
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('StatsSection', () => {
|
||||
let stats: StatsSection;
|
||||
|
||||
beforeEach(() => {
|
||||
stats = new StatsSection();
|
||||
});
|
||||
|
||||
it('starts empty', () => {
|
||||
expect(stats.played).toBe(0);
|
||||
expect(stats.won).toBe(0);
|
||||
expect(stats.bestStreak).toBe(0);
|
||||
});
|
||||
|
||||
it('counts each outcome', () => {
|
||||
stats.recordResult('win');
|
||||
stats.recordResult('loss');
|
||||
stats.recordResult('draw');
|
||||
|
||||
expect(stats.played).toBe(3);
|
||||
expect(stats.won).toBe(1);
|
||||
expect(stats.lost).toBe(1);
|
||||
expect(stats.drawn).toBe(1);
|
||||
});
|
||||
|
||||
it('tracks the current streak and the best one', () => {
|
||||
stats.recordResult('win');
|
||||
stats.recordResult('win');
|
||||
stats.recordResult('win');
|
||||
expect(stats.streak).toBe(3);
|
||||
expect(stats.bestStreak).toBe(3);
|
||||
|
||||
stats.recordResult('loss');
|
||||
expect(stats.streak).toBe(0);
|
||||
expect(stats.bestStreak).toBe(3);
|
||||
|
||||
stats.recordResult('win');
|
||||
expect(stats.streak).toBe(1);
|
||||
expect(stats.bestStreak).toBe(3);
|
||||
});
|
||||
|
||||
it('does not let a draw break a streak', () => {
|
||||
stats.recordResult('win');
|
||||
stats.recordResult('draw');
|
||||
expect(stats.streak).toBe(1);
|
||||
});
|
||||
|
||||
it('round-trips through a save', () => {
|
||||
stats.recordResult('win');
|
||||
stats.recordResult('win');
|
||||
stats.recordResult('loss');
|
||||
|
||||
const restored = new StatsSection();
|
||||
restored.load(JSON.parse(JSON.stringify(stats.save())));
|
||||
|
||||
expect(restored.save()).toEqual(stats.save());
|
||||
});
|
||||
|
||||
it('repairs a hand-edited save instead of trusting it', () => {
|
||||
stats.load({ played: 'lots', won: -4, lost: 2.7, streak: 9, bestStreak: 1 });
|
||||
|
||||
expect(stats.played).toBe(0);
|
||||
expect(stats.won).toBe(0);
|
||||
expect(stats.lost).toBe(2);
|
||||
expect(stats.streak).toBe(9);
|
||||
expect(stats.bestStreak).toBe(9);
|
||||
});
|
||||
|
||||
it('falls back to defaults for a payload of the wrong type', () => {
|
||||
stats.recordResult('win');
|
||||
stats.load(null);
|
||||
expect(stats.played).toBe(0);
|
||||
});
|
||||
});
|
||||
+8
-2
@@ -10,8 +10,14 @@
|
||||
"types": ["vitest/globals"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@core/*": ["assets/scripts/core/*"]
|
||||
"@core/*": ["assets/scripts/core/*"],
|
||||
"@modules/*": ["assets/scripts/modules/*"]
|
||||
}
|
||||
},
|
||||
"include": ["assets/scripts/core/**/*.ts", "tests/**/*.ts"]
|
||||
"include": [
|
||||
"assets/scripts/core/**/*.ts",
|
||||
"assets/scripts/modules/**/*.ts",
|
||||
"tests/**/*.ts"
|
||||
],
|
||||
"exclude": ["assets/scripts/modules/save/CocosStorage.ts"]
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@core': resolve(__dirname, 'assets/scripts/core'),
|
||||
'@modules': resolve(__dirname, 'assets/scripts/modules'),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
|
||||
@@ -7,6 +7,7 @@ export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@core': resolve(__dirname, 'assets/scripts/core'),
|
||||
'@modules': resolve(__dirname, 'assets/scripts/modules'),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
|
||||
Reference in New Issue
Block a user