M3: модуль сохранения

По ARCHITECTURE.md: реестр независимых секций, JSON с ключом на секцию.
Ключи — явные строковые константы, а не имена классов: релизная сборка
минифицирует имена и молча потеряла бы все сохранения.

- ISaveSection, SaveManager, IStorage + MemoryStorage
- CocosStorage: единственный файл модуля, знающий про движок;
  при отказе хранилища уходит в память, а не роняет игру
- StatsSection: сыграно/побед/поражений/ничьих, текущая и лучшая серия
- терпимая загрузка: битый JSON, чужая версия, сломанная секция
  или отвалившееся хранилище не ломают остальное
- проверка чистоты расширена на modules/ со списком адаптеров

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
ftc
2026-09-13 10:45:52 +03:00
co-authored by Claude Opus 5
parent c60fbcbb94
commit 51117a698c
17 changed files with 756 additions and 29 deletions
@@ -0,0 +1,64 @@
import { sys } from 'cc';
import { IStorage, MemoryStorage } from './IStorage';
/**
* The one file in the save module that touches the engine.
*
* `sys.localStorage` is backed by the browser store on web and by a native file
* on Android and iOS, so one implementation covers every target. It can still
* fail — private browsing, a full quota, a locked profile — so every call is
* guarded and falls back to an in-memory store, which keeps the session
* consistent even when nothing can be persisted.
*/
export class CocosStorage implements IStorage {
private readonly _fallback = new MemoryStorage();
private _usingFallback = false;
/** True once a storage call has failed and the session went in-memory. */
get degraded(): boolean {
return this._usingFallback;
}
getItem(key: string): string | null {
if (this._usingFallback) {
return this._fallback.getItem(key);
}
try {
return sys.localStorage.getItem(key);
} catch (error) {
this._degrade(error);
return this._fallback.getItem(key);
}
}
setItem(key: string, value: string): void {
if (!this._usingFallback) {
try {
sys.localStorage.setItem(key, value);
return;
} catch (error) {
this._degrade(error);
}
}
this._fallback.setItem(key, value);
}
removeItem(key: string): void {
if (!this._usingFallback) {
try {
sys.localStorage.removeItem(key);
return;
} catch (error) {
this._degrade(error);
}
}
this._fallback.removeItem(key);
}
private _degrade(error: unknown): void {
if (this._usingFallback) return;
this._usingFallback = true;
const message = error instanceof Error ? error.message : String(error);
console.warn(`[save] device storage is unavailable, progress will not persist: ${message}`);
}
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "c355de0c-e0e7-4e52-8805-6a4fdacd5bef",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,25 @@
/**
* One independently saved slice of player progress.
*
* ARCHITECTURE.md asks for a save built from several classes, each handling its
* own part, keyed by the class. The key here is an explicit string rather than
* `constructor.name` on purpose: release builds minify class names, and a
* renamed class would silently orphan every existing save.
*/
export interface ISaveSection {
/** Stable identifier. Never change it once a build has shipped. */
readonly key: string;
/** Current contents as a JSON-safe value. */
save(): unknown;
/**
* Restores from a previously saved value. Must tolerate anything — an older
* shape, a newer shape, or outright rubbish — by falling back to defaults
* rather than throwing.
*/
load(data: unknown): void;
/** Returns to the state of a player who has never played. */
reset(): void;
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "48cc13db-e29f-4fa9-955f-fab6c05f76b4",
"files": [],
"subMetas": {},
"userData": {}
}
+35
View File
@@ -0,0 +1,35 @@
/**
* The key-value store a save is written to.
*
* Kept behind an interface so the save logic itself has no engine dependency
* and can be tested headlessly. `CocosStorage` is the real implementation;
* `MemoryStorage` is the one tests use.
*/
export interface IStorage {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
}
/** An in-process store. Used by tests, and as a fallback when storage is unavailable. */
export class MemoryStorage implements IStorage {
private readonly _data = new Map<string, string>();
getItem(key: string): string | null {
const value = this._data.get(key);
return value === undefined ? null : value;
}
setItem(key: string, value: string): void {
this._data.set(key, value);
}
removeItem(key: string): void {
this._data.delete(key);
}
/** Test helper: everything currently stored. */
get entries(): ReadonlyMap<string, string> {
return this._data;
}
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "a51ebd26-39af-478f-ac08-a839af126032",
"files": [],
"subMetas": {},
"userData": {}
}
+149
View File
@@ -0,0 +1,149 @@
import { ISaveSection } from './ISaveSection';
import { IStorage } from './IStorage';
/** Bumped only when the envelope around the sections changes, not the sections themselves. */
export const SAVE_FORMAT_VERSION = 1;
const DEFAULT_SLOT = 'fool.save';
interface SaveEnvelope {
version: number;
sections: Record<string, unknown>;
}
export interface SaveManagerOptions {
/** Storage key the whole save lives under. */
slot?: string;
/** Where warnings go. Defaults to the console; tests pass their own. */
warn?: (message: string) => void;
}
/**
* Collects the registered sections into one JSON document and puts it back
* again on load, exactly as ARCHITECTURE.md describes.
*
* Loading is deliberately forgiving. A save that cannot be parsed, or a single
* section whose payload has gone bad, resets only what it has to and lets
* everything else through — a bad release should not be able to brick a player's
* progress, and a partial load beats no load at all.
*/
export class SaveManager {
private readonly _storage: IStorage;
private readonly _slot: string;
private readonly _warn: (message: string) => void;
private readonly _sections: ISaveSection[] = [];
constructor(storage: IStorage, options: SaveManagerOptions = {}) {
this._storage = storage;
this._slot = options.slot ?? DEFAULT_SLOT;
this._warn = options.warn ?? ((m) => console.warn(`[save] ${m}`));
}
/** Registers a section. Keys must be unique. */
register(section: ISaveSection): void {
if (this._sections.some((s) => s.key === section.key)) {
throw new Error(`A save section with key '${section.key}' is already registered`);
}
this._sections.push(section);
}
get sections(): readonly ISaveSection[] {
return this._sections;
}
/** Serialises every section and writes the result. */
save(): void {
const envelope: SaveEnvelope = { version: SAVE_FORMAT_VERSION, sections: {} };
for (const section of this._sections) {
try {
envelope.sections[section.key] = section.save();
} catch (error) {
this._warn(`section '${section.key}' failed to save: ${describe(error)}`);
}
}
try {
this._storage.setItem(this._slot, JSON.stringify(envelope));
} catch (error) {
// A full or disabled store must not take the game down with it.
this._warn(`could not write the save: ${describe(error)}`);
}
}
/**
* Reads the save and hands each section its own payload. Sections with no
* stored data — a new section in a new build, or a brand new player — are
* reset to their defaults.
*/
load(): void {
const envelope = this._read();
for (const section of this._sections) {
const data = envelope?.sections[section.key];
if (data === undefined) {
section.reset();
continue;
}
try {
section.load(data);
} catch (error) {
this._warn(`section '${section.key}' failed to load, resetting it: ${describe(error)}`);
section.reset();
}
}
}
/** Resets every section and removes the stored save. */
reset(): void {
for (const section of this._sections) {
section.reset();
}
try {
this._storage.removeItem(this._slot);
} catch (error) {
this._warn(`could not clear the save: ${describe(error)}`);
}
}
private _read(): SaveEnvelope | null {
let raw: string | null;
try {
raw = this._storage.getItem(this._slot);
} catch (error) {
this._warn(`could not read the save: ${describe(error)}`);
return null;
}
if (raw === null || raw === '') {
return null;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (error) {
this._warn(`the save is not valid JSON, starting fresh: ${describe(error)}`);
return null;
}
if (!isRecord(parsed) || !isRecord(parsed.sections)) {
this._warn('the save has an unrecognisable shape, starting fresh');
return null;
}
const version = typeof parsed.version === 'number' ? parsed.version : 0;
if (version > SAVE_FORMAT_VERSION) {
// Written by a newer build. Read what we can rather than wiping it.
this._warn(`the save is from a newer version (${version}), reading it cautiously`);
}
return { version, sections: parsed.sections };
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function describe(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "273eda8d-570a-4dee-8070-c03134c4568f",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,84 @@
import { ISaveSection } from './ISaveSection';
export type MatchOutcome = 'win' | 'loss' | 'draw';
interface StatsData {
played: number;
won: number;
lost: number;
drawn: number;
streak: number;
bestStreak: number;
}
function emptyStats(): StatsData {
return { played: 0, won: 0, lost: 0, drawn: 0, streak: 0, bestStreak: 0 };
}
/**
* How the player has been doing. The only section iteration 1 ships; currency
* and level progress will join it later without touching `SaveManager`.
*/
export class StatsSection implements ISaveSection {
static readonly KEY = 'stats';
readonly key = StatsSection.KEY;
private _data: StatsData = emptyStats();
get played(): number { return this._data.played; }
get won(): number { return this._data.won; }
get lost(): number { return this._data.lost; }
get drawn(): number { return this._data.drawn; }
/** Wins in a row right now. A loss resets it; a draw leaves it alone. */
get streak(): number { return this._data.streak; }
get bestStreak(): number { return this._data.bestStreak; }
recordResult(outcome: MatchOutcome): void {
this._data.played += 1;
switch (outcome) {
case 'win':
this._data.won += 1;
this._data.streak += 1;
this._data.bestStreak = Math.max(this._data.bestStreak, this._data.streak);
break;
case 'loss':
this._data.lost += 1;
this._data.streak = 0;
break;
case 'draw':
this._data.drawn += 1;
break;
}
}
save(): unknown {
return { ...this._data };
}
load(data: unknown): void {
const source = (typeof data === 'object' && data !== null ? data : {}) as Record<string, unknown>;
const defaults = emptyStats();
this._data = {
played: count(source.played, defaults.played),
won: count(source.won, defaults.won),
lost: count(source.lost, defaults.lost),
drawn: count(source.drawn, defaults.drawn),
streak: count(source.streak, defaults.streak),
bestStreak: count(source.bestStreak, defaults.bestStreak),
};
// A save edited by hand should not be able to show a nonsense best.
this._data.bestStreak = Math.max(this._data.bestStreak, this._data.streak);
}
reset(): void {
this._data = emptyStats();
}
}
/** Reads a non-negative whole number, or falls back. */
function count(value: unknown, fallback: number): number {
return typeof value === 'number' && Number.isFinite(value) && value >= 0
? Math.floor(value)
: fallback;
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "c9c5a957-f40b-4887-a88b-5697c3b37c0c",
"files": [],
"subMetas": {},
"userData": {}
}
+6 -6
View File
@@ -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
View File
@@ -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,
+48 -17
View File
@@ -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');
+277
View File
@@ -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
View File
@@ -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"]
}
+1
View File
@@ -5,6 +5,7 @@ export default defineConfig({
resolve: {
alias: {
'@core': resolve(__dirname, 'assets/scripts/core'),
'@modules': resolve(__dirname, 'assets/scripts/modules'),
},
},
test: {
+1
View File
@@ -7,6 +7,7 @@ export default defineConfig({
resolve: {
alias: {
'@core': resolve(__dirname, 'assets/scripts/core'),
'@modules': resolve(__dirname, 'assets/scripts/modules'),
},
},
test: {