fix(07-12): loadState 用 disable/revert 包裹重新装备(WR-06)

- HeroEquipment.loadState 在重新装备循环外包一层录像禁用窗口,try/finally 保证恢复
- 录像桩补禁用计数语义,读档期间写入的指令被丢弃
- 回归覆盖:读档不产生录像指令、禁用与恢复配对平衡、读档后 equip/unequip 记录仍保留
This commit is contained in:
unanmed 2026-09-17 16:01:32 +08:00
parent ab18aa3ec7
commit d8fb6f088c
2 changed files with 113 additions and 8 deletions

View File

@ -6,6 +6,7 @@ import {
type IItemRawData,
ItemCategory,
ItemStore,
ReplayCode,
TileStore,
TileType
} from '@user/data-common';
@ -54,6 +55,46 @@ interface TestEnv {
store: HeroEquipsStore<IHeroAttr>;
equipment: HeroEquipment<IHeroAttr>;
attribute: HeroAttribute<IHeroAttr>;
replaySystem: ReplaySystemStub;
}
/** 录像路由桩,只保留真正进入录像的指令,禁用窗口内的写入被丢弃 */
interface ReplayRouteStub {
/**
*
* @param code
* @param params
*/
add(code: ReplayCode, params: unknown[]): void;
/** 未被禁用窗口丢弃、真正进入录像的指令,按写入顺序排列 */
readonly commands: [ReplayCode, unknown[]][];
}
/** 录像系统桩,用禁用计数复现录像数组在禁用窗口内丢弃指令的行为 */
interface ReplaySystemStub {
route: ReplayRouteStub;
disable: ReturnType<typeof vi.fn>;
revert: ReturnType<typeof vi.fn>;
}
/** 构造一个用禁用计数复现录像数组丢弃行为的录像系统桩 */
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<IHeroAttr, unknown>();
// 录像系统桩,用于满足装备/卸下时的 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<IHeroAttr>(createBaseAttr());
const store = new HeroEquipsStore<IHeroAttr>(state);
const equipment = new HeroEquipment<IHeroAttr>(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', () => {

View File

@ -341,8 +341,16 @@ export class HeroEquipment<THero> implements IHeroEquipment<THero> {
});
// 由于装备修饰器不进存档,所以此时的勇士处于没有任何装备修饰器的状态,故可以安全清除
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();
}
}
}