From d8fb6f088c45636909e6aa20c604bcbb3cd7251f Mon Sep 17 00:00:00 2001 From: unanmed <1319491857@qq.com> Date: Thu, 17 Sep 2026 16:01:32 +0800 Subject: [PATCH] =?UTF-8?q?fix(07-12):=20loadState=20=E7=94=A8=20disable/r?= =?UTF-8?q?evert=20=E5=8C=85=E8=A3=B9=E9=87=8D=E6=96=B0=E8=A3=85=E5=A4=87?= =?UTF-8?q?=EF=BC=88WR-06=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HeroEquipment.loadState 在重新装备循环外包一层录像禁用窗口,try/finally 保证恢复 - 录像桩补禁用计数语义,读档期间写入的指令被丢弃 - 回归覆盖:读档不产生录像指令、禁用与恢复配对平衡、读档后 equip/unequip 记录仍保留 --- .../data-base/src/hero/equipment.test.ts | 109 +++++++++++++++++- packages-user/data-base/src/hero/equipment.ts | 12 +- 2 files changed, 113 insertions(+), 8 deletions(-) diff --git a/packages-user/data-base/src/hero/equipment.test.ts b/packages-user/data-base/src/hero/equipment.test.ts index 3a34431..1268e31 100644 --- a/packages-user/data-base/src/hero/equipment.test.ts +++ b/packages-user/data-base/src/hero/equipment.test.ts @@ -6,6 +6,7 @@ import { type IItemRawData, ItemCategory, ItemStore, + ReplayCode, TileStore, TileType } from '@user/data-common'; @@ -54,6 +55,46 @@ interface TestEnv { store: HeroEquipsStore; equipment: HeroEquipment; attribute: HeroAttribute; + replaySystem: ReplaySystemStub; +} + +/** 录像路由桩,只保留真正进入录像的指令,禁用窗口内的写入被丢弃 */ +interface ReplayRouteStub { + /** + * 写入一条录像指令 + * @param code 指令码 + * @param params 指令参数 + */ + add(code: ReplayCode, params: unknown[]): void; + /** 未被禁用窗口丢弃、真正进入录像的指令,按写入顺序排列 */ + readonly commands: [ReplayCode, unknown[]][]; +} + +/** 录像系统桩,用禁用计数复现录像数组在禁用窗口内丢弃指令的行为 */ +interface ReplaySystemStub { + route: ReplayRouteStub; + disable: ReturnType; + revert: ReturnType; +} + +/** 构造一个用禁用计数复现录像数组丢弃行为的录像系统桩 */ +function createReplaySystem(): ReplaySystemStub { + let disabled = 0; + const commands: [ReplayCode, unknown[]][] = []; + const route: ReplayRouteStub = { + add(code, params) { + if (disabled > 0) return; + commands.push([code, params]); + }, + commands + }; + const disable = vi.fn(() => { + disabled++; + }); + const revert = vi.fn(() => { + if (disabled > 0) disabled--; + }); + return { route, disable, revert }; } /** 构造一份合成的勇士基础属性 */ @@ -104,16 +145,20 @@ function createEnv(): TestEnv { const tileStore = new TileStore(); const itemStore = new ItemStore(); // 录像系统桩,用于满足装备/卸下时的 route.add 记录与临时禁用录像 - const replaySystem = { - route: { add: vi.fn() }, - disable: vi.fn(), - revert: vi.fn() - }; + const replaySystem = createReplaySystem(); const state = { tileStore, itemStore, replaySystem } as never; const attribute = new HeroAttribute(createBaseAttr()); const store = new HeroEquipsStore(state); const equipment = new HeroEquipment(store, attribute); - return { state, tileStore, itemStore, store, equipment, attribute }; + return { + state, + tileStore, + itemStore, + store, + equipment, + attribute, + replaySystem + }; } /** 向图块与道具存储注册一个装备道具定义 */ @@ -315,6 +360,58 @@ describe('HeroEquipment equip and unequip', () => { }); }); +describe('HeroEquipment replay isolation on load', () => { + // 验证读档重新装备期间禁用录像且不产生任何录像指令,禁用与恢复次数配对平衡 + it('suppresses replay recording while loading the equipment state', () => { + const env = createEnv(); + registerItem(env, createItem(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(); + expect(env.attribute.getFinalAttribute('atk')).toBe(15); + + env.equipment.unequip(0); + env.replaySystem.route.commands.length = 0; + env.replaySystem.disable.mockClear(); + env.replaySystem.revert.mockClear(); + + env.equipment.loadState(saved); + + const disableCount = env.replaySystem.disable.mock.calls.length; + const revertCount = env.replaySystem.revert.mock.calls.length; + expect(env.replaySystem.route.commands).toEqual([]); + expect(disableCount).toBeGreaterThan(0); + expect(revertCount).toBe(disableCount); + expect(env.equipment.getEquipped(0)).toBe(uid); + expect(env.attribute.getFinalAttribute('atk')).toBe(15); + }); + + // 验证读档包裹窗口结束后装备与卸下的录像记录能力仍保留 + it('keeps recording equip and unequip after loading', () => { + const env = createEnv(); + registerItem(env, createItem(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); + + env.replaySystem.route.commands.length = 0; + env.equipment.unequip(0); + expect(env.replaySystem.route.commands).toEqual([ + [ReplayCode.Unequip, [0]] + ]); + + env.equipment.equip(uid, 0); + expect(env.replaySystem.route.commands).toEqual([ + [ReplayCode.Unequip, [0]], + [ReplayCode.Equip, [uid]] + ]); + }); +}); + describe('HeroEquipment compare and guards', () => { // 验证 compareEquip 逐属性输出两个装备的最终属性差 it('diffs the final attributes of two equipment instances', () => { diff --git a/packages-user/data-base/src/hero/equipment.ts b/packages-user/data-base/src/hero/equipment.ts index cbf6976..ec49826 100644 --- a/packages-user/data-base/src/hero/equipment.ts +++ b/packages-user/data-base/src/hero/equipment.ts @@ -341,8 +341,16 @@ export class HeroEquipment implements IHeroEquipment { }); // 由于装备修饰器不进存档,所以此时的勇士处于没有任何装备修饰器的状态,故可以安全清除 this.equips.clear(); - for (const [index, uid] of state.equipped) { - this.equip(uid, index); + // 读档期间经 equip 恢复槽位会产生真实录像指令,故暂时禁用录像记录 + const replay = this.state.replaySystem; + replay.disable(); + try { + for (const [index, uid] of state.equipped) { + this.equip(uid, index); + } + } finally { + // 必须保证所有路径都恢复录像记录,否则会永久吞掉后续录像 + replay.revert(); } } }