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:
unanmed 2026-09-14 18:57:42 +08:00
parent ccc5fc4f1f
commit 3e29548bfa
3 changed files with 871 additions and 0 deletions

View 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)` 语义化断言。 |
### Flagdata-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 关键字段与「快照与活对象分离」的可观察字段。 |

View 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);
});
});

View 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']);
});
// 疑似 bugsaveState 直接返回内部 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);
});
// 疑似 bugloadNoCompression 误从存档百分比表读取数值表,详见 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);
});
// 疑似 bugloadDiff 清空后未回退到装备原始定义,未修改的加成丢失,详见 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);
});
});