diff --git a/assets/scripts/modules/save/CocosStorage.ts b/assets/scripts/modules/save/CocosStorage.ts new file mode 100644 index 0000000..c527472 --- /dev/null +++ b/assets/scripts/modules/save/CocosStorage.ts @@ -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}`); + } +} diff --git a/assets/scripts/modules/save/CocosStorage.ts.meta b/assets/scripts/modules/save/CocosStorage.ts.meta new file mode 100644 index 0000000..ea1639d --- /dev/null +++ b/assets/scripts/modules/save/CocosStorage.ts.meta @@ -0,0 +1,9 @@ +{ + "ver": "4.0.24", + "importer": "typescript", + "imported": true, + "uuid": "c355de0c-e0e7-4e52-8805-6a4fdacd5bef", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/scripts/modules/save/ISaveSection.ts b/assets/scripts/modules/save/ISaveSection.ts new file mode 100644 index 0000000..f34f1a1 --- /dev/null +++ b/assets/scripts/modules/save/ISaveSection.ts @@ -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; +} diff --git a/assets/scripts/modules/save/ISaveSection.ts.meta b/assets/scripts/modules/save/ISaveSection.ts.meta new file mode 100644 index 0000000..0fd0646 --- /dev/null +++ b/assets/scripts/modules/save/ISaveSection.ts.meta @@ -0,0 +1,9 @@ +{ + "ver": "4.0.24", + "importer": "typescript", + "imported": true, + "uuid": "48cc13db-e29f-4fa9-955f-fab6c05f76b4", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/scripts/modules/save/IStorage.ts b/assets/scripts/modules/save/IStorage.ts new file mode 100644 index 0000000..a346d53 --- /dev/null +++ b/assets/scripts/modules/save/IStorage.ts @@ -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(); + + 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 { + return this._data; + } +} diff --git a/assets/scripts/modules/save/IStorage.ts.meta b/assets/scripts/modules/save/IStorage.ts.meta new file mode 100644 index 0000000..f5b7a22 --- /dev/null +++ b/assets/scripts/modules/save/IStorage.ts.meta @@ -0,0 +1,9 @@ +{ + "ver": "4.0.24", + "importer": "typescript", + "imported": true, + "uuid": "a51ebd26-39af-478f-ac08-a839af126032", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/scripts/modules/save/SaveManager.ts b/assets/scripts/modules/save/SaveManager.ts new file mode 100644 index 0000000..e4966c2 --- /dev/null +++ b/assets/scripts/modules/save/SaveManager.ts @@ -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; +} + +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 { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function describe(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/assets/scripts/modules/save/SaveManager.ts.meta b/assets/scripts/modules/save/SaveManager.ts.meta new file mode 100644 index 0000000..e840717 --- /dev/null +++ b/assets/scripts/modules/save/SaveManager.ts.meta @@ -0,0 +1,9 @@ +{ + "ver": "4.0.24", + "importer": "typescript", + "imported": true, + "uuid": "273eda8d-570a-4dee-8070-c03134c4568f", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/scripts/modules/save/StatsSection.ts b/assets/scripts/modules/save/StatsSection.ts new file mode 100644 index 0000000..041f380 --- /dev/null +++ b/assets/scripts/modules/save/StatsSection.ts @@ -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; + 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; +} diff --git a/assets/scripts/modules/save/StatsSection.ts.meta b/assets/scripts/modules/save/StatsSection.ts.meta new file mode 100644 index 0000000..c03b819 --- /dev/null +++ b/assets/scripts/modules/save/StatsSection.ts.meta @@ -0,0 +1,9 @@ +{ + "ver": "4.0.24", + "importer": "typescript", + "imported": true, + "uuid": "c9c5a957-f40b-4887-a88b-5697c3b37c0c", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/docs/PLAN.md b/docs/PLAN.md index da102cf..a5f65d8 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -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 diff --git a/docs/TECHNICAL.md b/docs/TECHNICAL.md index 080bd81..d6ddb0a 100644 --- a/docs/TECHNICAL.md +++ b/docs/TECHNICAL.md @@ -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: { : } }`. 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, diff --git a/scripts/check-core-purity.mjs b/scripts/check-core-purity.mjs index 95de5a9..61a993c 100644 --- a/scripts/check-core-purity.mjs +++ b/scripts/check-core-purity.mjs @@ -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'); diff --git a/tests/modules/save/SaveManager.spec.ts b/tests/modules/save/SaveManager.spec.ts new file mode 100644 index 0000000..b0028cd --- /dev/null +++ b/tests/modules/save/SaveManager.spec.ts @@ -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); + }); +}); diff --git a/tsconfig.test.json b/tsconfig.test.json index 7049d09..f24895e 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -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"] } diff --git a/vitest.config.mts b/vitest.config.mts index 829263e..e2f3a79 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -5,6 +5,7 @@ export default defineConfig({ resolve: { alias: { '@core': resolve(__dirname, 'assets/scripts/core'), + '@modules': resolve(__dirname, 'assets/scripts/modules'), }, }, test: { diff --git a/vitest.stats.mts b/vitest.stats.mts index 9951334..e395162 100644 --- a/vitest.stats.mts +++ b/vitest.stats.mts @@ -7,6 +7,7 @@ export default defineConfig({ resolve: { alias: { '@core': resolve(__dirname, 'assets/scripts/core'), + '@modules': resolve(__dirname, 'assets/scripts/modules'), }, }, test: {