mirror of
https://github.com/motajs/template.git
synced 2026-09-18 21:20:20 +08:00
test(06-09): cover enemy and hero save/load round trips
- add enemy/saveLoad.test.ts: Enemy/special/EnemyManager same-instance round trips across 3 compressions, codes 119/120 - add hero/saveLoad.test.ts: modifier/location/rendering/equipment/equipStore/items/follower/state round trips, codes 58/59 - add 06-SAVE-EXCLUSIONS.md: per-class excluded fields and rationale (D-42)
This commit is contained in:
parent
ccc5fc4f1f
commit
3e29548bfa
93
.planning/phases/06-unit-tests/06-SAVE-EXCLUSIONS.md
Normal file
93
.planning/phases/06-unit-tests/06-SAVE-EXCLUSIONS.md
Normal file
@ -0,0 +1,93 @@
|
||||
# 06-09 存读档排除清单(06-SAVE-EXCLUSIONS.md)
|
||||
|
||||
> 用途:作为 06-09 存读档单测与后续维护的**对照基准**。本清单逐类推导「**不**在断言范围内」的
|
||||
> 存档无关字段/派生字段/缓存/序列化容器结构,并说明依据(为什么与存档语义无关)。测试只对
|
||||
> 「关键状态字段」做显式断言(D-13),不做整体深度相等,也不比较序列化容器结构(D-13)。
|
||||
>
|
||||
> 判定口径:一个字段「存档相关」当且仅当它由 `saveState()` 写出、由 `loadState()` 读回,且是
|
||||
> 该对象对外语义的一部分。凡不满足者一律排除。
|
||||
|
||||
## 通用排除项(所有可存档类)
|
||||
|
||||
| 排除项 | 类别 | 依据 |
|
||||
| --- | --- | --- |
|
||||
| 序列化容器结构本身(`Map`/数组/对象的键序、副本容量、缓冲区字节布局) | 容器/元数据 | 只关心「值被恢复」,不关心存档用什么容器承载(D-13)。 |
|
||||
| 派生值(可由其它已断言字段算出,或 `loadState` 后延迟重算) | 派生 | 断言派生值会与实现的计算时机耦合;一旦实现改为惰性重算即误报。 |
|
||||
| 缓存(实例缓存、`expired` 标记、读取流集合) | 缓存 | 缓存只影响性能,不代表存档状态;`loadState` 允许清空或保留。 |
|
||||
| 钩子注册表、控制器、协作对象引用(`state`/`layer`/`owner`/`controller`) | 运行时引用 | 非存档内容,读档后由构造关系恢复。 |
|
||||
| 脏标记与基准快照(`dirty`/`refArray`/`referenceByCode`/`compared`) | 元数据 | 用于压缩对比与变更追踪,不是游戏状态;`loadState` 会按需重建。 |
|
||||
| 配置对象(扩容乘数、最大长度、槽位命名规则等) | 配置 | 由构造/装配决定,不进入存档。 |
|
||||
| 副作用输出(如录像 `route` 记录、事件派发) | 副作用 | 不是被存读档对象的状态。 |
|
||||
|
||||
## 逐类排除清单
|
||||
|
||||
### 怪物(data-base/src/enemy)
|
||||
|
||||
| 类 | 排除字段 | 依据 |
|
||||
| --- | --- | --- |
|
||||
| `Enemy` | `specialMap`/`specials` 的容器结构与迭代顺序 | 只断言「按 code 取回的特殊属性值」;容器是实现细节。 |
|
||||
| `Enemy` | 未在存档中的 special 的当前值变化 | 缺失 special 走警告 120,测试只断言「保留当前值 + 码 120」。 |
|
||||
| `EnemyManager` | `prefabByCode`/`prefabById`/`reuseByCode`/`reuseById`/`legacyIdToCode` 映射 | 模板注册表属装配状态,`saveState` 只写出脏模板(D-14)。 |
|
||||
| `EnemyManager` | `referenceByCode`、`hasReference`、`dirtySet` | 参考快照与脏集合是派生元数据;测试仅通过公开属性/公共 API 间接观察。 |
|
||||
| `EnemyManager` | `comparer` | 协作对象,非存档内容。 |
|
||||
| `EnemyManager` | `saveState` 返回的 `modified` 容器类型 | 只断言被序列化的脏 code 集合与其属性值。 |
|
||||
| `CommonSerializableSpecial` / `NonePropertySpecial` | `config` | 构造期配置,非存档内容。 |
|
||||
|
||||
### 勇士(data-base/src/hero)
|
||||
|
||||
| 类 | 排除字段 | 依据 |
|
||||
| --- | --- | --- |
|
||||
| `HeroAttribute` | `finalAttribute` | 派生值;无修饰器时本就不随基础属性刷新(另见 `#06-05-1`),不作为存读档断言对象。 |
|
||||
| `HeroAttribute` | `modifierName`/`modifierNosave` 的容器结构 | 只断言「修饰器值恢复」与「save=false 不入档」的语义。 |
|
||||
| `BaseHeroModifier`(`ValueModifier`/`PercentageModifier`) | `owner` 反向引用 | 运行时绑定关系,非存档内容。 |
|
||||
| `HeroLocation` | `state`、`mover` 的动画中间态 | 协作/运行时引用;只断言 x/y/floorId/direction。 |
|
||||
| `HeroRendering` | 钩子列表 | 运行时注册,非存档内容。 |
|
||||
| `HeroState` | `registry`(修饰器工厂表) | 装配配置;只断言读档后按类型重建的修饰器值。 |
|
||||
| `HeroState` | 跟随者对象的身份 | 只断言 follower 存档的 num/位置/渲染值恢复。 |
|
||||
| `HeroEquipment` | 录像 `route` 副作用、装备修饰器的对象身份 | 副作用与对象身份非存档内容;只断言 slots 与 equipped 映射。 |
|
||||
| `EquipmentState` | `modifiers` 数组身份与复用 | 读档会重建修饰器;只断言修饰器数值。 |
|
||||
| `EquipmentState` | `value`/`percentage` 的 Map 容器结构 | 只断言修饰器数值恢复(数值表读取缺陷另见 `#06-09-1`)。 |
|
||||
| `HeroEquipsStore` | `sorter`、`state` | 协作/配置对象。 |
|
||||
| `HeroEquipsStore` | `instanceMap` 的容器结构与实例身份 | 断言 uid 集合与自增计数续接。 |
|
||||
| `HeroItems` | `raw` 引用、`constants`/`consumables` 容器结构 | 只断言 num→count 的恢复;`raw` 由 itemStore 重新解析。 |
|
||||
| `HeroFollower` | `controller` 引用 | 运行时引用;只断言 num/位置/渲染。 |
|
||||
|
||||
### 地图(data-base/src/map)
|
||||
|
||||
| 类 | 排除字段 | 依据 |
|
||||
| --- | --- | --- |
|
||||
| `MapState` | `areaList`/`lastFloorId`/`autoActivitorEnabled`/`compared` | 分区与自动激活属运行时元数据,不进 `IMapStoreSave`。 |
|
||||
| `MapState` | `tileStore`/`state` | 协作引用。 |
|
||||
| `MapState` | 未激活楼层 | `saveState` 只写激活楼层,未激活楼层本就不入档。 |
|
||||
| `GameMap` | `layerAliasMap`/`aliasLayerMap`/`indexer`/`layerHookMap`、钩子 | 图层别名与索引属装配/运行时状态,不在 `IGameMapSave`。 |
|
||||
| `GameMap` | `selfDirty` | 脏标记是派生元数据。 |
|
||||
| `GameMap` | 空图层存档(`isEmptyLayerSave` 跳过的层) | 无有效内容的层会被 `saveState` 主动省略。 |
|
||||
| `MapLayer` | `refArray`、`layerDirty`、`mapData.expired` | 压缩基准与脏标记是派生元数据。 |
|
||||
| `MapLayer` | `staticTileCache`/`tilePosMap`/`posTileMap`、点事件缓存容器结构 | 实例缓存与索引,`loadState` 会重建。 |
|
||||
| `MapLayer` | 压缩档下的 `rows`/`fullMap`/`staticBlocks`/`dynamicBlocks`/`pointEvents` 容器形状 | 只断言块/点位事件值恢复(D-13)。 |
|
||||
| `StaticTile` | `layer`/`locator` | 运行时引用;只断言图块事件与(经图层矩阵的)图块数字。 |
|
||||
| `DynamicTile` | `mover`、`layer`、`locator` 缓存 | 运行时对象;只断言 num 与覆盖事件(num 恢复缺陷另见 `#06-09-3`)。 |
|
||||
|
||||
### 录像(data-common/src/replay)
|
||||
|
||||
| 类 | 排除字段 | 依据 |
|
||||
| --- | --- | --- |
|
||||
| `ReplayArray` | 缓冲区容量、扩容乘数、最大长度、`indexBuffer` 派生索引 | 配置/派生结构;只断言录像步数与 `get`/读流的关键指令与参数值。 |
|
||||
| `ReplayArray` | `readStreams` 集合 | 读取流缓存。 |
|
||||
| `ReplayArray` | `saveState` 返回的 `ArrayBuffer` 字节布局 | 只断言读回的命令/参数(含 boolean/int/bigint/string/数组)。 |
|
||||
| `ReplaySystem` | `commands` 注册表容器、`sandbox`、`replaying`、钩子 | 运行时装配/状态;注册表经 `getCommand(code)` 语义化断言。 |
|
||||
|
||||
### Flag(data-base/src/flag)
|
||||
|
||||
| 类 | 排除字段 | 依据 |
|
||||
| --- | --- | --- |
|
||||
| `FlagSystem` | `fieldMap` 容器结构与字段对象身份 | 只断言字段值(`getFieldValue`)与 `occupied`;`loadState` 会重建字段对象。 |
|
||||
| `FlagCommonField` | `system` 反向引用、`key` | 运行时绑定,非存档内容。 |
|
||||
|
||||
### 顶层(data-state/src/core)
|
||||
|
||||
| 类 | 排除字段 | 依据 |
|
||||
| --- | --- | --- |
|
||||
| `CoreState` | `saveables`/`addedSaveables`/`executors` 映射结构 | 装配状态;只断言 5 个注册 id 的快照键与逐 id 内容。 |
|
||||
| `CoreState` | `saveSystem`、`loading` 接线、各 Layer 协作对象 | 运行时装配,不属 `saveState`/`loadState` 范围(D-10)。 |
|
||||
| `CoreState` | 快照的 `Map` 容器结构与值对象的身份 | 只断言逐 id 关键字段与「快照与活对象分离」的可观察字段。 |
|
||||
255
packages-user/data-base/src/enemy/saveLoad.test.ts
Normal file
255
packages-user/data-base/src/enemy/saveLoad.test.ts
Normal file
@ -0,0 +1,255 @@
|
||||
// 测试怪物数据模型存读档:Enemy/special/EnemyManager 同实例往返、压缩档与码 119/120
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { SaveCompression } from '@user/data-common';
|
||||
import {
|
||||
type IEnemy,
|
||||
type IEnemyLegacyBridge,
|
||||
type IEnemyManager,
|
||||
type ISpecial
|
||||
} from './types';
|
||||
|
||||
vi.hoisted(() => {
|
||||
vi.stubGlobal('main', { replayChecking: true });
|
||||
vi.stubGlobal('location', { origin: 'http://localhost' });
|
||||
Map.prototype.getOrInsertComputed ??= function <K, V>(
|
||||
this: Map<K, V>,
|
||||
key: K,
|
||||
callback: (key: K) => V
|
||||
): V {
|
||||
const existing = this.get(key);
|
||||
if (existing !== undefined) return existing;
|
||||
const value = callback(key);
|
||||
this.set(key, value);
|
||||
return value;
|
||||
};
|
||||
});
|
||||
|
||||
interface TestModules {
|
||||
Enemy: typeof import('./enemy').Enemy;
|
||||
CommonSerializableSpecial: typeof import('./special').CommonSerializableSpecial;
|
||||
NonePropertySpecial: typeof import('./special').NonePropertySpecial;
|
||||
EnemyManager: typeof import('./manager').EnemyManager;
|
||||
logger: typeof import('@motajs/common').logger;
|
||||
}
|
||||
|
||||
let modules: TestModules;
|
||||
|
||||
beforeAll(async () => {
|
||||
vi.stubGlobal('main', { replayChecking: true });
|
||||
vi.stubGlobal('location', { origin: 'http://localhost' });
|
||||
const enemyModule = await import('./enemy');
|
||||
const specialModule = await import('./special');
|
||||
const managerModule = await import('./manager');
|
||||
const commonModule = await import('@motajs/common');
|
||||
modules = {
|
||||
Enemy: enemyModule.Enemy,
|
||||
CommonSerializableSpecial: specialModule.CommonSerializableSpecial,
|
||||
NonePropertySpecial: specialModule.NonePropertySpecial,
|
||||
EnemyManager: managerModule.EnemyManager,
|
||||
logger: commonModule.logger
|
||||
};
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
/** 存读档测试覆盖的三档压缩级别 */
|
||||
const SAVE_COMPRESSIONS = [
|
||||
SaveCompression.NoCompression,
|
||||
SaveCompression.LowCompression,
|
||||
SaveCompression.HighCompression
|
||||
] as const;
|
||||
|
||||
interface IEnemyTestAttr {
|
||||
hp: number;
|
||||
atk: number;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
/** 仅作为 EnemyManager 必需协作对象的内联假桥接,方法不会被本计划的存读档路径调用 */
|
||||
const bridge: IEnemyLegacyBridge<IEnemyTestAttr> = {
|
||||
fromLegacyEnemy: (_enemy, defaultValue) => ({
|
||||
hp: 10,
|
||||
atk: 2,
|
||||
tags: ['legacy'],
|
||||
...defaultValue
|
||||
})
|
||||
};
|
||||
|
||||
/** 构造一个带合成属性的怪物对象 */
|
||||
function createEnemy(
|
||||
id = 'enemy-1',
|
||||
code = 1,
|
||||
attributes: IEnemyTestAttr = { hp: 20, atk: 8, tags: ['base'] }
|
||||
): IEnemy<IEnemyTestAttr> {
|
||||
return new modules.Enemy<IEnemyTestAttr>(
|
||||
id,
|
||||
code,
|
||||
structuredClone(attributes)
|
||||
);
|
||||
}
|
||||
|
||||
/** 构造一个可序列化的数值特殊属性 */
|
||||
function createSpecial(code: number, value: number): ISpecial<number> {
|
||||
return new modules.CommonSerializableSpecial<number>(code, value, {
|
||||
getSpecialName: () => `special-${code}`,
|
||||
getDescription: () => `description-${code}`,
|
||||
fromLegacyEnemy: () => value
|
||||
});
|
||||
}
|
||||
|
||||
/** 构造一个独立的怪物管理器实例 */
|
||||
function createManager(): IEnemyManager<IEnemyTestAttr> {
|
||||
return new modules.EnemyManager<IEnemyTestAttr>(bridge);
|
||||
}
|
||||
|
||||
/** 构造一个只按生命值判定相等的内联怪物比较器 */
|
||||
function createHpComparer() {
|
||||
return {
|
||||
compare: (
|
||||
enemyA: { getAttribute(key: 'hp'): number },
|
||||
enemyB: { getAttribute(key: 'hp'): number }
|
||||
) => enemyA.getAttribute('hp') === enemyB.getAttribute('hp')
|
||||
};
|
||||
}
|
||||
|
||||
describe('Enemy save and load round trips', () => {
|
||||
// 验证怪物属性与特殊属性值在三个压缩档下均能同实例恢复
|
||||
it('restores attributes and special values on the same instance across all compressions', () => {
|
||||
for (const compression of SAVE_COMPRESSIONS) {
|
||||
const enemy = createEnemy();
|
||||
const special = createSpecial(6, 3);
|
||||
enemy.addSpecial(special);
|
||||
enemy.setAttribute('hp', 31);
|
||||
|
||||
const saved = enemy.saveState(compression);
|
||||
enemy.setAttribute('hp', 2);
|
||||
special.setValue(99);
|
||||
enemy.loadState(saved, compression);
|
||||
|
||||
expect(enemy.getAttribute('hp')).toBe(31);
|
||||
expect(enemy.getAttribute('atk')).toBe(8);
|
||||
expect(enemy.getSpecial<number>(6)!.getValue()).toBe(3);
|
||||
}
|
||||
});
|
||||
|
||||
// 验证 saveState 返回的属性为分离克隆,修改存档不会影响实例
|
||||
it('returns separated attribute clones from saveState', () => {
|
||||
const enemy = createEnemy();
|
||||
|
||||
const saved = enemy.saveState(SaveCompression.NoCompression);
|
||||
saved.attrs.hp = 999;
|
||||
saved.attrs.tags.push('mutated');
|
||||
|
||||
expect(enemy.getAttribute('hp')).toBe(20);
|
||||
expect(enemy.getAttribute('tags')).toEqual(['base']);
|
||||
});
|
||||
|
||||
// 验证实例上存在但存档缺失的特殊属性经 logger.catch 观测到警告码 120
|
||||
it('warns code 120 when a special is missing during loadState', () => {
|
||||
const enemy = createEnemy();
|
||||
const special = createSpecial(6, 3);
|
||||
enemy.addSpecial(special);
|
||||
const other = createEnemy('other', 2);
|
||||
|
||||
const saved = other.saveState(SaveCompression.NoCompression);
|
||||
const result = modules.logger.catch(() =>
|
||||
enemy.loadState(saved, SaveCompression.NoCompression)
|
||||
);
|
||||
|
||||
expect(result.info.map(info => info.code)).toContain(120);
|
||||
expect(enemy.getSpecial<number>(6)!.getValue()).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Serializable special save and load round trips', () => {
|
||||
// 验证 CommonSerializableSpecial 的数值在三个压缩档下均能同实例恢复
|
||||
it('restores a common serializable special value across all compressions', () => {
|
||||
for (const compression of SAVE_COMPRESSIONS) {
|
||||
const special = createSpecial(6, 3);
|
||||
|
||||
const saved = special.saveState(compression);
|
||||
special.setValue(9);
|
||||
special.loadState(saved, compression);
|
||||
|
||||
expect(special.getValue()).toBe(3);
|
||||
}
|
||||
});
|
||||
|
||||
// 验证 NonePropertySpecial 的存读档不改变无属性语义
|
||||
it('round-trips a none-property special without changing its value', () => {
|
||||
const config = {
|
||||
getSpecialName: () => 'none',
|
||||
getDescription: () => 'none',
|
||||
fromLegacyEnemy: () => {}
|
||||
};
|
||||
const special = new modules.NonePropertySpecial(7, config);
|
||||
|
||||
for (const compression of SAVE_COMPRESSIONS) {
|
||||
const saved = special.saveState(compression);
|
||||
special.loadState(saved, compression);
|
||||
|
||||
expect(special.getValue()).toBeUndefined();
|
||||
expect(
|
||||
special.deepEqualsTo(new modules.NonePropertySpecial(7, config))
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('EnemyManager save and load round trips', () => {
|
||||
// 验证三个压缩档下只序列化脏模板且其属性可同实例恢复
|
||||
it('restores dirty prefab attributes on the same manager across all compressions', () => {
|
||||
for (const compression of SAVE_COMPRESSIONS) {
|
||||
const manager = createManager();
|
||||
manager.attachEnemyComparer(createHpComparer());
|
||||
manager.addPrefab(
|
||||
createEnemy('slime', 1, { hp: 20, atk: 8, tags: [] })
|
||||
);
|
||||
manager.addPrefab(
|
||||
createEnemy('bat', 2, { hp: 30, atk: 9, tags: [] })
|
||||
);
|
||||
manager.compareWith(
|
||||
new Map([
|
||||
[1, createEnemy('slime', 1, { hp: 20, atk: 8, tags: [] })],
|
||||
[2, createEnemy('bat', 2, { hp: 30, atk: 9, tags: [] })]
|
||||
])
|
||||
);
|
||||
manager.modifyPrefabAttribute(1, prefab => {
|
||||
prefab.setAttribute('hp', 50);
|
||||
return prefab;
|
||||
});
|
||||
|
||||
const saved = manager.saveState(compression);
|
||||
expect([...saved.modified.keys()]).toEqual([1]);
|
||||
|
||||
manager.modifyPrefabAttribute(1, prefab => {
|
||||
prefab.setAttribute('hp', 1);
|
||||
return prefab;
|
||||
});
|
||||
manager.loadState(saved, compression);
|
||||
|
||||
expect(manager.getPrefab(1)!.getAttribute('hp')).toBe(50);
|
||||
expect(manager.getPrefab(2)!.getAttribute('hp')).toBe(30);
|
||||
}
|
||||
});
|
||||
|
||||
// 验证存档中缺失模板 code 时经 logger.catch 观测到警告码 119
|
||||
it('warns code 119 when the prefab is missing during loadState', () => {
|
||||
const manager = createManager();
|
||||
manager.addPrefab(createEnemy('slime', 1));
|
||||
const missing = createEnemy('ghost', 9);
|
||||
|
||||
const saved = {
|
||||
modified: new Map([
|
||||
[9, missing.saveState(SaveCompression.NoCompression)]
|
||||
])
|
||||
};
|
||||
const result = modules.logger.catch(() =>
|
||||
manager.loadState(saved, SaveCompression.NoCompression)
|
||||
);
|
||||
|
||||
expect(result.info.map(info => info.code)).toContain(119);
|
||||
});
|
||||
});
|
||||
523
packages-user/data-base/src/hero/saveLoad.test.ts
Normal file
523
packages-user/data-base/src/hero/saveLoad.test.ts
Normal file
@ -0,0 +1,523 @@
|
||||
// 测试勇士各子系统存读档:同实例往返、压缩档与码 58/59
|
||||
import { afterAll, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
type IDataCommon,
|
||||
type IHeroAttr,
|
||||
type IItemRawData,
|
||||
Dir8FaceHandler,
|
||||
FaceDirection,
|
||||
ItemCategory,
|
||||
ItemStore,
|
||||
SaveCompression,
|
||||
TileStore,
|
||||
TileType
|
||||
} from '@user/data-common';
|
||||
import { logger } from '@motajs/common';
|
||||
import { HeroAttribute } from './attribute';
|
||||
import { PercentageModifier, ValueModifier } from './modifier';
|
||||
import { HeroLocation } from './location';
|
||||
import { HeroRendering } from './rendering';
|
||||
import { HeroState } from './state';
|
||||
import { HeroEquipment } from './equipment';
|
||||
import { EquipmentState, HeroEquipsStore } from './equipStore';
|
||||
import { HeroItems } from './items';
|
||||
import { HeroFollowersController } from './follower';
|
||||
import { type IHeroState } from './types';
|
||||
|
||||
vi.hoisted(() => {
|
||||
vi.stubGlobal('main', { replayChecking: true });
|
||||
vi.stubGlobal('location', { origin: 'http://localhost' });
|
||||
Map.prototype.getOrInsert ??= function <K, V>(
|
||||
this: Map<K, V>,
|
||||
key: K,
|
||||
value: V
|
||||
): V {
|
||||
const existing = this.get(key);
|
||||
if (existing !== undefined) return existing;
|
||||
this.set(key, value);
|
||||
return value;
|
||||
};
|
||||
Map.prototype.getOrInsertComputed ??= function <K, V>(
|
||||
this: Map<K, V>,
|
||||
key: K,
|
||||
callback: (key: K) => V
|
||||
): V {
|
||||
const existing = this.get(key);
|
||||
if (existing !== undefined) return existing;
|
||||
const value = callback(key);
|
||||
this.set(key, value);
|
||||
return value;
|
||||
};
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
/** 存读档测试覆盖的三档压缩级别 */
|
||||
const SAVE_COMPRESSIONS = [
|
||||
SaveCompression.NoCompression,
|
||||
SaveCompression.LowCompression,
|
||||
SaveCompression.HighCompression
|
||||
] as const;
|
||||
|
||||
type HeroKey = SelectKey<IHeroAttr, number>;
|
||||
|
||||
interface EquipEnv {
|
||||
state: IDataCommon;
|
||||
tileStore: IDataCommon['tileStore'];
|
||||
itemStore: IDataCommon['itemStore'];
|
||||
store: HeroEquipsStore<IHeroAttr>;
|
||||
equipment: HeroEquipment<IHeroAttr>;
|
||||
}
|
||||
|
||||
/** 构造一个含图块、道具存储与假录像系统的公共层假对象 */
|
||||
function createState(): IDataCommon {
|
||||
const tileStore = new TileStore();
|
||||
const itemStore = new ItemStore<IHeroAttr, unknown>();
|
||||
const route = { add: vi.fn() };
|
||||
return { tileStore, itemStore, replaySystem: { route } } as never;
|
||||
}
|
||||
|
||||
/** 构造一份合成的勇士基础属性 */
|
||||
function createBaseAttr(): IHeroAttr {
|
||||
return {
|
||||
name: 'hero',
|
||||
hp: 100,
|
||||
hpmax: 100,
|
||||
atk: 10,
|
||||
def: 5,
|
||||
mdef: 0,
|
||||
mana: 0,
|
||||
manamax: 0,
|
||||
money: 0,
|
||||
exp: 0
|
||||
};
|
||||
}
|
||||
|
||||
/** 构造一个带合成装备属性的道具定义 */
|
||||
function createEquipItem(
|
||||
num: number,
|
||||
id: string,
|
||||
slots: (number | string)[] = [0],
|
||||
value: [HeroKey, number][] = [],
|
||||
percentage: [HeroKey, number][] = []
|
||||
): IItemRawData<IHeroAttr> {
|
||||
return {
|
||||
num,
|
||||
id,
|
||||
category: ItemCategory.Equipment,
|
||||
name: id,
|
||||
text: id,
|
||||
hideInToolbox: false,
|
||||
effect: { useEvent: null, useEffect: () => {}, canUse: () => false },
|
||||
equip: {
|
||||
slots,
|
||||
animate: 'sword',
|
||||
value: new Map(value),
|
||||
percentage: new Map(percentage),
|
||||
loadEvent: null,
|
||||
unloadEvent: null
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** 构造一个非装备类道具定义,用于覆盖背包分表存读档 */
|
||||
function createPlainItem(
|
||||
num: number,
|
||||
id: string,
|
||||
category: ItemCategory
|
||||
): IItemRawData<IHeroAttr> {
|
||||
return {
|
||||
num,
|
||||
id,
|
||||
category,
|
||||
name: id,
|
||||
text: id,
|
||||
hideInToolbox: false,
|
||||
effect: { useEvent: null, useEffect: () => {}, canUse: () => false },
|
||||
equip: {
|
||||
slots: [0],
|
||||
animate: 'sword',
|
||||
value: new Map(),
|
||||
percentage: new Map(),
|
||||
loadEvent: null,
|
||||
unloadEvent: null
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** 向图块与道具存储注册一个道具定义 */
|
||||
function registerItem(env: EquipEnv, item: IItemRawData<IHeroAttr>): void {
|
||||
env.tileStore.addTile({
|
||||
num: item.num,
|
||||
id: item.id,
|
||||
events: {},
|
||||
type: TileType.Item,
|
||||
pass: { onlyEvents: false, inPass: 15, outPass: 15 },
|
||||
eventPass: true
|
||||
});
|
||||
env.itemStore.addItem(item);
|
||||
}
|
||||
|
||||
/** 构造一个装配装备实例存储与勇士装备对象的测试环境 */
|
||||
function createEquipEnv(): EquipEnv {
|
||||
const state = createState();
|
||||
const store = new HeroEquipsStore<IHeroAttr>(state);
|
||||
const equipment = new HeroEquipment<IHeroAttr>(
|
||||
store,
|
||||
new HeroAttribute<IHeroAttr>(createBaseAttr())
|
||||
);
|
||||
return {
|
||||
state,
|
||||
tileStore: state.tileStore,
|
||||
itemStore: state.itemStore,
|
||||
store,
|
||||
equipment
|
||||
};
|
||||
}
|
||||
|
||||
/** 构造一个注册了跟随者图块的公共层假对象 */
|
||||
function createFollowerState(): IDataCommon {
|
||||
const state = createState();
|
||||
state.tileStore.addTile({
|
||||
num: 100,
|
||||
id: 'ghost',
|
||||
events: {},
|
||||
type: TileType.Npc,
|
||||
pass: { onlyEvents: false, inPass: 15, outPass: 15 },
|
||||
eventPass: true
|
||||
});
|
||||
return state;
|
||||
}
|
||||
|
||||
/** 构造一个装配完成的勇士状态对象 */
|
||||
function createHeroState(): IHeroState<IHeroAttr> {
|
||||
return new HeroState<IHeroAttr>(
|
||||
createState(),
|
||||
new Dir8FaceHandler(),
|
||||
new HeroAttribute<IHeroAttr>(createBaseAttr())
|
||||
);
|
||||
}
|
||||
|
||||
describe('Hero modifier save and load round trips', () => {
|
||||
// 验证 ValueModifier 的数值在三个压缩档下均能同实例恢复
|
||||
it('restores a value modifier across all compressions', () => {
|
||||
for (const compression of SAVE_COMPRESSIONS) {
|
||||
const modifier = new ValueModifier(5);
|
||||
|
||||
const saved = modifier.saveState(compression);
|
||||
modifier.setValue(9);
|
||||
modifier.loadState(saved, compression);
|
||||
|
||||
expect(modifier.getValue()).toBe(5);
|
||||
}
|
||||
});
|
||||
|
||||
// 验证 PercentageModifier 的数值在三个压缩档下均能同实例恢复
|
||||
it('restores a percentage modifier across all compressions', () => {
|
||||
for (const compression of SAVE_COMPRESSIONS) {
|
||||
const modifier = new PercentageModifier(0.25);
|
||||
|
||||
const saved = modifier.saveState(compression);
|
||||
modifier.setValue(0.9);
|
||||
modifier.loadState(saved, compression);
|
||||
|
||||
expect(modifier.getValue()).toBe(0.25);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('HeroLocation save and load round trips', () => {
|
||||
// 验证坐标、楼层与朝向在同实例上完整恢复
|
||||
it('restores position, floor and direction on the same instance', () => {
|
||||
const location = new HeroLocation(
|
||||
createState(),
|
||||
{ x: 3, y: 4, direction: FaceDirection.Right },
|
||||
new Dir8FaceHandler()
|
||||
);
|
||||
location.setPos(1, 2);
|
||||
location.setFloor('F2');
|
||||
location.mover.setFaceDir(FaceDirection.Up);
|
||||
|
||||
const saved = location.saveState();
|
||||
location.setPos(9, 9);
|
||||
location.setFloor('F9');
|
||||
location.mover.setFaceDir(FaceDirection.Down);
|
||||
location.loadState(saved);
|
||||
|
||||
expect(location.x).toBe(1);
|
||||
expect(location.y).toBe(2);
|
||||
expect(location.floorId).toBe('F2');
|
||||
expect(location.getCurrentFaceDirection()).toBe(FaceDirection.Up);
|
||||
});
|
||||
});
|
||||
|
||||
describe('HeroRendering save and load round trips', () => {
|
||||
// 验证不透明度在同实例上恢复
|
||||
it('restores alpha on the same instance', () => {
|
||||
const rendering = new HeroRendering(createState());
|
||||
rendering.setAlpha(0.25);
|
||||
|
||||
const saved = rendering.saveState();
|
||||
rendering.setAlpha(1);
|
||||
rendering.loadState(saved);
|
||||
|
||||
expect(rendering.alpha).toBe(0.25);
|
||||
});
|
||||
});
|
||||
|
||||
describe('HeroEquipment save and load round trips', () => {
|
||||
// 验证装备槽与已装备映射在同实例上恢复(存盘经结构化克隆模拟真实序列化)
|
||||
it('restores slots and equipped mapping on the same instance', () => {
|
||||
const env = createEquipEnv();
|
||||
registerItem(env, createEquipItem(10, 'sword', [0], [['atk', 5]]));
|
||||
env.equipment.setSlots(['weapon']);
|
||||
const uid = env.store.add(10);
|
||||
env.equipment.equip(uid, 0);
|
||||
|
||||
const saved = structuredClone(env.equipment.saveState());
|
||||
env.equipment.unequip(0);
|
||||
expect(env.equipment.getEquipped(0)).toBeUndefined();
|
||||
|
||||
env.equipment.loadState(saved);
|
||||
|
||||
expect(env.equipment.getEquipped(0)).toBe(uid);
|
||||
expect(env.equipment.slots).toEqual(['weapon']);
|
||||
});
|
||||
|
||||
// 疑似 bug:saveState 直接返回内部 equipped/slots 引用而非深拷贝,详见 06-TEST-FINDINGS.md #06-09-2
|
||||
it.skip('returns an equipment snapshot independent from the live state', () => {
|
||||
const env = createEquipEnv();
|
||||
registerItem(env, createEquipItem(10, 'sword', [0], [['atk', 5]]));
|
||||
env.equipment.setSlots(['weapon']);
|
||||
const uid = env.store.add(10);
|
||||
env.equipment.equip(uid, 0);
|
||||
|
||||
const saved = env.equipment.saveState();
|
||||
env.equipment.unequip(0);
|
||||
env.equipment.loadState(saved);
|
||||
|
||||
expect(env.equipment.getEquipped(0)).toBe(uid);
|
||||
expect(env.equipment.slots).toEqual(['weapon']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('EquipmentState save and load round trips', () => {
|
||||
// 验证百分比加成的数值在同实例上恢复(无压缩档)
|
||||
it('restores a percentage modifier on the same instance', () => {
|
||||
const env = createEquipEnv();
|
||||
const item = createEquipItem(10, 'sword', [0], [], [['hp', 0.5]]);
|
||||
registerItem(env, item);
|
||||
const state = new EquipmentState<IHeroAttr>(0, item);
|
||||
const modifier = [...state.getModifiers()][0][1];
|
||||
modifier.setValue(0.9);
|
||||
|
||||
const saved = state.saveState(SaveCompression.NoCompression);
|
||||
state.loadState(saved, SaveCompression.NoCompression);
|
||||
|
||||
const restored = [...state.getModifiers()].find(
|
||||
([, current]) => current instanceof PercentageModifier
|
||||
);
|
||||
expect(restored?.[1].getValue()).toBe(0.5);
|
||||
});
|
||||
|
||||
// 疑似 bug:loadNoCompression 误从存档百分比表读取数值表,详见 06-TEST-FINDINGS.md #06-09-1
|
||||
it.skip('restores a value modifier on the same instance', () => {
|
||||
const env = createEquipEnv();
|
||||
const item = createEquipItem(10, 'sword', [0], [['atk', 5]]);
|
||||
registerItem(env, item);
|
||||
const state = new EquipmentState<IHeroAttr>(0, item);
|
||||
const modifier = [...state.getModifiers()][0][1];
|
||||
modifier.setValue(99);
|
||||
|
||||
const saved = state.saveState(SaveCompression.NoCompression);
|
||||
state.loadState(saved, SaveCompression.NoCompression);
|
||||
|
||||
const restored = [...state.getModifiers()].find(
|
||||
([name]) => name === 'atk'
|
||||
);
|
||||
expect(restored?.[1].getValue()).toBe(5);
|
||||
});
|
||||
|
||||
// 疑似 bug:loadDiff 清空后未回退到装备原始定义,未修改的加成丢失,详见 06-TEST-FINDINGS.md #06-09-1
|
||||
it.skip('keeps unchanged value modifiers for compressed snapshots', () => {
|
||||
const env = createEquipEnv();
|
||||
const item = createEquipItem(10, 'sword', [0], [['atk', 5]]);
|
||||
registerItem(env, item);
|
||||
const state = new EquipmentState<IHeroAttr>(0, item);
|
||||
|
||||
const saved = state.saveState(SaveCompression.LowCompression);
|
||||
state.loadState(saved, SaveCompression.LowCompression);
|
||||
|
||||
expect([...state.getModifiers()].map(([name]) => name)).toEqual([
|
||||
'atk'
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('HeroEquipsStore save and load round trips', () => {
|
||||
// 验证装备实例在三个压缩档下同实例恢复并续接自增 uid
|
||||
it('restores equipment instances and the uid counter across all compressions', () => {
|
||||
for (const compression of SAVE_COMPRESSIONS) {
|
||||
const env = createEquipEnv();
|
||||
registerItem(env, createEquipItem(10, 'sword', [0], [['atk', 5]]));
|
||||
const first = env.store.add(10);
|
||||
const second = env.store.add(10);
|
||||
|
||||
const saved = env.store.saveState(compression);
|
||||
env.store.delete(first);
|
||||
env.store.loadState(saved, compression);
|
||||
|
||||
expect(env.store.get(first)?.uid).toBe(first);
|
||||
expect(env.store.get(second)?.uid).toBe(second);
|
||||
expect(env.store.add(10)).toBe(second + 1);
|
||||
}
|
||||
});
|
||||
|
||||
// 验证存档缺失道具原始数据时经 logger.catch 观测到错误码 59
|
||||
it('warns code 59 when the item raw data is missing', () => {
|
||||
const env = createEquipEnv();
|
||||
const saved = {
|
||||
equipments: [
|
||||
{
|
||||
uid: 0,
|
||||
num: 999,
|
||||
value: new Map<HeroKey, number>(),
|
||||
percentage: new Map<HeroKey, number>()
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const result = logger.catch(() =>
|
||||
env.store.loadState(saved, SaveCompression.NoCompression)
|
||||
);
|
||||
|
||||
expect(result.info.map(info => info.code)).toContain(59);
|
||||
});
|
||||
|
||||
// 验证存档不含任何装备实例时经 logger.catch 观测到错误码 58
|
||||
it('warns code 58 when the max equipment uid cannot be found', () => {
|
||||
const env = createEquipEnv();
|
||||
|
||||
const result = logger.catch(() =>
|
||||
env.store.loadState(
|
||||
{ equipments: [] },
|
||||
SaveCompression.NoCompression
|
||||
)
|
||||
);
|
||||
|
||||
expect(result.info.map(info => info.code)).toContain(58);
|
||||
});
|
||||
});
|
||||
|
||||
describe('HeroItems save and load round trips', () => {
|
||||
// 验证永久与消耗道具分表在同实例上恢复
|
||||
it('restores constant and consumable item tables on the same instance', () => {
|
||||
const env = createEquipEnv();
|
||||
registerItem(env, createPlainItem(20, 'key', ItemCategory.Constant));
|
||||
registerItem(
|
||||
env,
|
||||
createPlainItem(21, 'potion', ItemCategory.Consumable)
|
||||
);
|
||||
const items = new HeroItems<IHeroAttr>(env.state);
|
||||
items.addItem(20, 3);
|
||||
items.addItem(21, 2);
|
||||
|
||||
const saved = items.saveState(SaveCompression.NoCompression);
|
||||
items.addItem(20, -3);
|
||||
items.addItem(21, -2);
|
||||
items.loadState(saved, SaveCompression.NoCompression);
|
||||
|
||||
expect(items.itemCount(20)).toBe(3);
|
||||
expect(items.itemCount(21)).toBe(2);
|
||||
expect(items.getItemState(20)?.id).toBe('key');
|
||||
});
|
||||
});
|
||||
|
||||
describe('HeroFollower save and load round trips', () => {
|
||||
// 验证跟随者位置、楼层与渲染在同实例上恢复
|
||||
it('restores follower location and rendering on the same instance', () => {
|
||||
const state = createFollowerState();
|
||||
const faceHandler = new Dir8FaceHandler();
|
||||
const heroLocation = new HeroLocation(
|
||||
state,
|
||||
{ x: 0, y: 0, direction: FaceDirection.Down },
|
||||
faceHandler
|
||||
);
|
||||
const controller = new HeroFollowersController(
|
||||
state,
|
||||
heroLocation,
|
||||
faceHandler
|
||||
);
|
||||
const follower = controller.addFollower(100);
|
||||
follower.location.setPos(2, 3);
|
||||
follower.location.setFloor('F1');
|
||||
follower.location.mover.setFaceDir(FaceDirection.Up);
|
||||
follower.rendering.setAlpha(0.5);
|
||||
|
||||
for (const compression of SAVE_COMPRESSIONS) {
|
||||
const saved = follower.saveState(compression);
|
||||
follower.location.setPos(9, 9);
|
||||
follower.location.setFloor('F9');
|
||||
follower.location.mover.setFaceDir(FaceDirection.Down);
|
||||
follower.rendering.setAlpha(1);
|
||||
follower.loadState(saved, compression);
|
||||
|
||||
expect(follower.location.x).toBe(2);
|
||||
expect(follower.location.y).toBe(3);
|
||||
expect(follower.location.floorId).toBe('F1');
|
||||
expect(follower.location.getCurrentFaceDirection()).toBe(
|
||||
FaceDirection.Up
|
||||
);
|
||||
expect(follower.rendering.alpha).toBe(0.5);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('HeroState save and load round trips', () => {
|
||||
// 验证属性、修饰器与位置在同实例上恢复并重建属性对象
|
||||
it('restores attributes, modifiers and location on the same instance', () => {
|
||||
const hero = createHeroState();
|
||||
hero.registerModifier('@system/value', () => new ValueModifier(5));
|
||||
hero.createAndInsertModifier('@system/value', 'atk');
|
||||
hero.getModifiableAttribute().set('hp', 88);
|
||||
hero.location.setPos(1, 1);
|
||||
hero.location.setFloor('F1');
|
||||
hero.location.mover.setFaceDir(FaceDirection.Up);
|
||||
|
||||
const saved = hero.saveState(SaveCompression.NoCompression);
|
||||
hero.getModifiableAttribute().set('hp', 1);
|
||||
hero.location.setPos(9, 9);
|
||||
hero.loadState(saved, SaveCompression.NoCompression);
|
||||
|
||||
expect(hero.getModifiableAttribute().getBaseAttribute('hp')).toBe(88);
|
||||
expect(hero.getModifiableAttribute().getFinalAttribute('atk')).toBe(15);
|
||||
expect(hero.getLocation()).toEqual({
|
||||
x: 1,
|
||||
y: 1,
|
||||
direction: FaceDirection.Up
|
||||
});
|
||||
expect(hero.location.floorId).toBe('F1');
|
||||
});
|
||||
|
||||
// 验证存盘禁用(save=false)的修饰器不进入存档且读档后不再存在
|
||||
it('excludes save-disabled modifiers from the snapshot', () => {
|
||||
const hero = createHeroState();
|
||||
hero.registerModifier('@system/value', () => new ValueModifier(5));
|
||||
hero.createAndInsertModifier('@system/value', 'atk');
|
||||
const disabled = hero.createAndInsertModifier('@system/value', 'atk')!;
|
||||
hero.getModifiableAttribute().setModifierSaveEnabled(disabled, false);
|
||||
|
||||
const saved = hero.saveState(SaveCompression.NoCompression);
|
||||
expect(saved.modifiers).toHaveLength(1);
|
||||
|
||||
hero.loadState(saved, SaveCompression.NoCompression);
|
||||
|
||||
const values = [
|
||||
...hero.getModifiableAttribute().getModifiers('atk')
|
||||
].map(modifier => modifier.getValue());
|
||||
expect(values).toEqual([5]);
|
||||
expect(hero.getModifiableAttribute().getFinalAttribute('atk')).toBe(15);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user