From 3e29548bfa60679ea7847552dc2bc1b73539b6e3 Mon Sep 17 00:00:00 2001 From: unanmed <1319491857@qq.com> Date: Mon, 14 Sep 2026 18:57:42 +0800 Subject: [PATCH] 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) --- .../06-unit-tests/06-SAVE-EXCLUSIONS.md | 93 ++++ .../data-base/src/enemy/saveLoad.test.ts | 255 +++++++++ .../data-base/src/hero/saveLoad.test.ts | 523 ++++++++++++++++++ 3 files changed, 871 insertions(+) create mode 100644 .planning/phases/06-unit-tests/06-SAVE-EXCLUSIONS.md create mode 100644 packages-user/data-base/src/enemy/saveLoad.test.ts create mode 100644 packages-user/data-base/src/hero/saveLoad.test.ts diff --git a/.planning/phases/06-unit-tests/06-SAVE-EXCLUSIONS.md b/.planning/phases/06-unit-tests/06-SAVE-EXCLUSIONS.md new file mode 100644 index 0000000..42d1457 --- /dev/null +++ b/.planning/phases/06-unit-tests/06-SAVE-EXCLUSIONS.md @@ -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 关键字段与「快照与活对象分离」的可观察字段。 | diff --git a/packages-user/data-base/src/enemy/saveLoad.test.ts b/packages-user/data-base/src/enemy/saveLoad.test.ts new file mode 100644 index 0000000..8f22aed --- /dev/null +++ b/packages-user/data-base/src/enemy/saveLoad.test.ts @@ -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 ( + this: Map, + 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 = { + 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 { + return new modules.Enemy( + id, + code, + structuredClone(attributes) + ); +} + +/** 构造一个可序列化的数值特殊属性 */ +function createSpecial(code: number, value: number): ISpecial { + return new modules.CommonSerializableSpecial(code, value, { + getSpecialName: () => `special-${code}`, + getDescription: () => `description-${code}`, + fromLegacyEnemy: () => value + }); +} + +/** 构造一个独立的怪物管理器实例 */ +function createManager(): IEnemyManager { + return new modules.EnemyManager(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(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(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); + }); +}); diff --git a/packages-user/data-base/src/hero/saveLoad.test.ts b/packages-user/data-base/src/hero/saveLoad.test.ts new file mode 100644 index 0000000..68c2b13 --- /dev/null +++ b/packages-user/data-base/src/hero/saveLoad.test.ts @@ -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 ( + this: Map, + 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 ( + this: Map, + 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; + +interface EquipEnv { + state: IDataCommon; + tileStore: IDataCommon['tileStore']; + itemStore: IDataCommon['itemStore']; + store: HeroEquipsStore; + equipment: HeroEquipment; +} + +/** 构造一个含图块、道具存储与假录像系统的公共层假对象 */ +function createState(): IDataCommon { + const tileStore = new TileStore(); + const itemStore = new ItemStore(); + 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 { + 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 { + 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): 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(state); + const equipment = new HeroEquipment( + store, + new HeroAttribute(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 { + return new HeroState( + createState(), + new Dir8FaceHandler(), + new HeroAttribute(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(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(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(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(), + percentage: new Map() + } + ] + }; + + 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(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); + }); +});