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] `MatchRunner` driving a full headless match
|
||||||
- [x] Soak test: thousands of seeded self-play matches finish with no illegal state
|
- [x] Soak test: thousands of seeded self-play matches finish with no illegal state
|
||||||
|
|
||||||
### M3 — Savegame module `[ ]`
|
### M3 — Savegame module `[x]`
|
||||||
- [ ] `ISaveSection` contract, `SaveManager` registry, JSON keyed by section name
|
- [x] `ISaveSection` contract, `SaveManager` registry, JSON keyed by section name
|
||||||
- [ ] Storage backend over `sys.localStorage` (works on web and native)
|
- [x] Storage backend over `sys.localStorage` (works on web and native)
|
||||||
- [ ] `StatsSection`: games played / won / lost, current and best win streak
|
- [x] `StatsSection`: games played / won / lost, current and best win streak
|
||||||
- [ ] Versioning + tolerant load (unknown or missing sections do not break a load)
|
- [x] Versioning + tolerant load (unknown or missing sections do not break a load)
|
||||||
- [ ] Tests for save/load round-trip and corrupted-payload recovery
|
- [x] Tests for save/load round-trip and corrupted-payload recovery
|
||||||
|
|
||||||
### M4 — Art pipeline `[ ]`
|
### M4 — Art pipeline `[ ]`
|
||||||
- [ ] Pick the 36 needed faces from `local/`, downscale to the 1080-wide budget
|
- [ ] 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
|
core/ pure rules and bots — no `cc` import anywhere
|
||||||
```
|
```
|
||||||
|
|
||||||
The rule enforced by review and by the test harness: **nothing under
|
The rule, enforced by `npm run check:core-purity`: **nothing under
|
||||||
`assets/scripts/core/` may import from `cc`.** It is plain TypeScript, runnable
|
`assets/scripts/core/` may import from `cc`**, and under
|
||||||
under Node, and that is what makes the rules testable without the engine.
|
`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
|
## Folder map
|
||||||
|
|
||||||
@@ -49,7 +51,8 @@ assets/
|
|||||||
match/ Match (stateful shell), runMatch (headless driver)
|
match/ Match (stateful shell), runMatch (headless driver)
|
||||||
util/ seeded RNG, shuffle
|
util/ seeded RNG, shuffle
|
||||||
modules/
|
modules/
|
||||||
save/ SaveManager, ISaveSection, StatsSection, storage
|
save/ SaveManager, ISaveSection, StatsSection,
|
||||||
|
IStorage + MemoryStorage, CocosStorage (engine)
|
||||||
app/ Bootstrap, ServiceLocator, SceneRouter
|
app/ Bootstrap, ServiceLocator, SceneRouter
|
||||||
ui/
|
ui/
|
||||||
lobby/ lobby screen controller
|
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.
|
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
|
## Conventions
|
||||||
|
|
||||||
- TypeScript, 4-space indent, `PascalCase` for types, `camelCase` for members,
|
- 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:
|
// Guards the one architectural rule the type system cannot express: the game
|
||||||
// assets/scripts/core must never import the Cocos engine. See docs/TECHNICAL.md.
|
// 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 { 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 = [];
|
const offenders = [];
|
||||||
|
|
||||||
function walk(dir) {
|
function walk(dir, onFile) {
|
||||||
for (const entry of readdirSync(dir)) {
|
for (const entry of readdirSync(dir)) {
|
||||||
const p = join(dir, entry);
|
const path = join(dir, entry);
|
||||||
if (statSync(p).isDirectory()) walk(p);
|
if (statSync(path).isDirectory()) walk(path, onFile);
|
||||||
else if (p.endsWith('.ts')) {
|
else if (path.endsWith('.ts')) onFile(path);
|
||||||
const src = readFileSync(p, 'utf8');
|
|
||||||
src.split('\n').forEach((line, i) => {
|
|
||||||
if (FORBIDDEN.test(line)) offenders.push(`${p}:${i + 1} ${line.trim()}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
walk(ROOT);
|
function engineImports(path) {
|
||||||
if (offenders.length) {
|
return readFileSync(path, 'utf8')
|
||||||
console.error('core/ must not import the engine:\n' + offenders.join('\n'));
|
.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);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('core purity OK');
|
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"],
|
"types": ["vitest/globals"],
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"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: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@core': resolve(__dirname, 'assets/scripts/core'),
|
'@core': resolve(__dirname, 'assets/scripts/core'),
|
||||||
|
'@modules': resolve(__dirname, 'assets/scripts/modules'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export default defineConfig({
|
|||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@core': resolve(__dirname, 'assets/scripts/core'),
|
'@core': resolve(__dirname, 'assets/scripts/core'),
|
||||||
|
'@modules': resolve(__dirname, 'assets/scripts/modules'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
|
|||||||
Reference in New Issue
Block a user