// 测试录像完整播放与二次录制比对:小地图场景、逐条相等判定、176 与 error 2001-2008 import { afterEach, describe, expect, it, vi } from 'vitest'; import { logger } from '@motajs/common'; import { WebLoadStarter } from '@motajs/loader'; import { Dir4FaceHandler, FaceDirection, ReplayCode, ReplayCommandResult, SaveCompression, TileType, type IReplayArray, type IReplaySandbox, type IReplayStepHandler, type ReplayParamValue } from '@user/data-common'; import { CoreState } from '../src/core'; import { DefaultPassPredicateImpl } from '../../../src/data/hero/predicate'; import { ReplayEquip, ReplayMove, ReplayTeleport, ReplayUnequip, ReplayUseItem } from '../../../src/data/replay/commands'; vi.hoisted(() => { 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; }; }); afterEach(() => { vi.restoreAllMocks(); }); /** 按 08-08 约定的 CoreState 装配创建一个独立状态实例 */ function createCoreState(): CoreState { return new CoreState({ loadStarter: new WebLoadStarter(), coreURL: 'placeholder' }); } interface IRecordedParam { /** 参数运行时类型 */ readonly type: string; /** 参数值 */ readonly value: ReplayParamValue; } interface IRecordedStep { /** 录像指令码 */ readonly code: number; /** 本条录像步的参数 */ readonly params: readonly IRecordedParam[]; } /** * 在录像记录被禁用的状态下执行准备动作,避免准备步骤被误录 * @param state 顶层状态对象 * @param action 需要执行的准备动作 */ function withReplayDisabled(state: CoreState, action: () => T): T { state.replaySystem.disable(); try { return action(); } finally { state.replaySystem.revert(); } } /** * 用真实地图与勇士接口构造一个 3x3 小地图场景,并返回创建出的地图 * @param state 顶层状态对象 * @param wireFinder 是否向寻路 finder 手动注入地图状态、事件层与通行谓词 */ function createSmallMapScene(state: CoreState, wireFinder: boolean = true) { state.tileStore.addTile({ num: 1, id: 'floor', events: {}, type: TileType.Terrain, pass: { onlyEvents: false, outPass: 0b1111, inPass: 0b1111 }, eventPass: true }); const map = state.maps.fromRaw({ floorId: 'F1', width: 3, map: { 0: [1, 1, 1, 1, 1, 1, 1, 1, 1], 20: [1, 1, 1, 1, 1, 1, 1, 1, 1] }, layerAlias: { 0: 'bg', 20: 'event' }, events: { 0: {}, 20: {} } }); if (!map || !map.eventLayer) { throw new Error('small map scene was not created'); } state.maps.setMapActiveStatus('F1', true); if (wireFinder) { state.pathfinding.finder.useMapLayer(map.eventLayer); state.pathfinding.finder.useFaceHandler(new Dir4FaceHandler()); state.pathfinding.finder.usePassPredicate( new DefaultPassPredicateImpl(state) ); } resetHero(state); return map; } /** * 把勇士恢复到小地图起点并清空待执行的移动队列 * @param state 顶层状态对象 */ function resetHero(state: CoreState): void { state.hero.location.setFloor(state.maps.getMap('F1')); state.hero.location.setPos(0, 0); state.hero.location.mover.clear(); state.hero.location.mover.setFaceDir(FaceDirection.Right); } /** * 通过真实勇士移动器执行一个方向步并等待其完成,从而录制一步录像 * @param state 顶层状态对象 * @param direction 移动方向 */ async function runHeroStep( state: CoreState, direction: FaceDirection ): Promise { const controller = state.hero.location.mover.step(direction).start(); if (!controller) throw new Error('hero mover was already running'); await controller.onEnd; } /** * 有界等待录像沙箱播放结束,避免非终止录像挂起测试 * @param sandbox 录像沙箱 */ async function waitForEnded(sandbox: IReplaySandbox): Promise { for (let index = 0; index < 1000 && !sandbox.ended; index++) { await new Promise(resolve => setTimeout(resolve, 0)); } if (!sandbox.ended) throw new Error('replay sandbox did not end'); } /** * 播放当前录像并等待结束,播放期间禁用录制以免影响录像本身 * @param state 顶层状态对象 */ async function playRoute(state: CoreState): Promise { const replay = state.replaySystem; const sandbox = replay.createReplaySandbox({ route: replay.array, reseter: { reset: () => resetHero(state) } }); replay.disable(); try { sandbox.play(); await waitForEnded(sandbox); } finally { replay.revert(); replay.releaseSandbox(); } expect(sandbox.ended).toBe(true); } /** * 把录像数组快照为可逐条比较的纯数据(步数 + code + 各参数的 type/value) * @param route 录像数组 */ function snapshotRoute(route: IReplayArray): IRecordedStep[] { const result: IRecordedStep[] = []; for (let index = 0; index < route.length; index++) { const step = route.get(index); result.push({ code: step.command, params: step.params.map(value => ({ type: typeof value, value })) }); } return result; } /** * 逐条断言两份录像完全相等(步数 + 每步 code + 各参数的 type/value) * @param actual 二次录制的录像 * @param expected 首次录制的录像 */ function expectReplayEqual( actual: readonly IRecordedStep[], expected: readonly IRecordedStep[] ): void { expect(actual.length).toBe(expected.length); for (let index = 0; index < expected.length; index++) { expect(actual[index].code).toBe(expected[index].code); expect(actual[index].params.length).toBe(expected[index].params.length); for (let param = 0; param < expected[index].params.length; param++) { expect(actual[index].params[param].type).toBe( expected[index].params[param].type ); expect(actual[index].params[param].value).toBe( expected[index].params[param].value ); } } } /** * 组装一个手动触发使用的录像步信息对象 * @param command 指令码 * @param params 参数列表 */ function step( command: number, params: IReplayStepHandler['params'] ): IReplayStepHandler { return { command, params, index: 0 }; } describe('replay recording and route read-back', () => { // 验证禁用录像时任何记录都不生效,嵌套 revert 全部恢复后才重新记录 it('suppresses recording while disabled and resumes after all reverts', () => { const state = createCoreState(); const replay = state.replaySystem; replay.disable(); replay.record(ReplayCode.Right); expect(replay.array.length).toBe(0); replay.disable(); replay.revert(); replay.record(ReplayCode.Right); expect(replay.array.length).toBe(0); replay.revert(); replay.record(ReplayCode.Right); expect(replay.array.length).toBe(1); }); // 验证八个稳定指令码及其参数都能经录像数组逐条读回 it('reads back every stable command code and its params', () => { const state = createCoreState(); const replay = state.replaySystem; replay.record(ReplayCode.Up); replay.record(ReplayCode.Teleport, 3, 4); replay.record(ReplayCode.UseItem, 12); replay.record(ReplayCode.Equip, 99, 1, true); replay.record(ReplayCode.Unequip, 1); expect(replay.array.length).toBe(5); expect(replay.array.get(0)).toMatchObject({ command: ReplayCode.Up, params: [] }); expect(replay.array.get(1)).toMatchObject({ command: ReplayCode.Teleport, params: [3, 4] }); expect(replay.array.get(2)).toMatchObject({ command: ReplayCode.UseItem, params: [12] }); expect(replay.array.get(3)).toMatchObject({ command: ReplayCode.Equip, params: [99, 1, true] }); expect(replay.array.get(4)).toMatchObject({ command: ReplayCode.Unequip, params: [1] }); }); // 验证真实勇士移动器按方向记录对应移动指令,且准备步骤不会被误录 it('records hero moves through the real mover and ignores preparation', async () => { const state = createCoreState(); withReplayDisabled(state, () => createSmallMapScene(state)); expect(state.replaySystem.array.length).toBe(0); await runHeroStep(state, FaceDirection.Right); expect(state.replaySystem.array.length).toBe(1); expect(state.replaySystem.array.get(0)).toMatchObject({ command: ReplayCode.Right, params: [] }); }); // 验证勇士移动方向不属于上右下左时记录告警 176 it('warns 176 when the hero move direction is not orthogonal', async () => { const state = createCoreState(); withReplayDisabled(state, () => createSmallMapScene(state)); const warning = vi.spyOn(logger, 'warn'); const controller = state.hero.location.mover .step(FaceDirection.Unknown) .start(); if (controller) await controller.onEnd; expect(warning).toHaveBeenCalledWith(176); }); }); describe('small-map replay playback and second recording', () => { // 验证小地图场景录制后正常播放,并在重置录像后二次录制与原录像逐条完全相等 it('plays a recorded route and reproduces it exactly on a second recording', async () => { const state = createCoreState(); const replay = state.replaySystem; const emptyReplay = replay.saveState(SaveCompression.NoCompression); withReplayDisabled(state, () => createSmallMapScene(state)); await runHeroStep(state, FaceDirection.Right); await runHeroStep(state, FaceDirection.Right); replay.record(ReplayCode.Teleport, 1, 0); replay.record(ReplayCode.Up); const firstSteps = snapshotRoute(replay.array); expect(firstSteps.map(item => item.code)).toEqual([ ReplayCode.Right, ReplayCode.Right, ReplayCode.Teleport, ReplayCode.Up ]); expect(replay.array.get(2)).toMatchObject({ command: ReplayCode.Teleport, params: [1, 0] }); await playRoute(state); expect(state.hero.location.x).toBe(1); expect(state.hero.location.y).toBe(0); replay.loadState(emptyReplay, SaveCompression.NoCompression); expect(replay.array.length).toBe(0); withReplayDisabled(state, () => resetHero(state)); await runHeroStep(state, FaceDirection.Right); await runHeroStep(state, FaceDirection.Right); replay.record(ReplayCode.Teleport, 1, 0); replay.record(ReplayCode.Up); const secondSteps = snapshotRoute(replay.array); expectReplayEqual(secondSteps, firstSteps); await playRoute(state); expect(state.hero.location.x).toBe(1); expect(state.hero.location.y).toBe(0); }); // 顶层装配已提供 maps 与通行谓词,但事件层在切层后才设置, // 本用例在楼层激活后由测试侧绑定事件层,验证顶层录像瞬移仍可解析出路径并到达目标 it('plays a teleport step after the event layer is bound on floor activation', async () => { const state = createCoreState(); const replay = state.replaySystem; const map = withReplayDisabled(state, () => createSmallMapScene(state, false) ); state.pathfinding.finder.useMapLayer(map.eventLayer); state.pathfinding.finder.useFaceHandler(new Dir4FaceHandler()); await runHeroStep(state, FaceDirection.Right); replay.record(ReplayCode.Teleport, 2, 0); await playRoute(state); expect(state.hero.location.x).toBe(2); expect(state.hero.location.y).toBe(0); }); }); describe('replay playback error codes 2001-2008', () => { // 验证参数数量与参数类型不匹配分别记录错误码 2001 与 2002 it('warns 2001 and 2002 for parameter count and type mismatches', async () => { const state = createCoreState(); const error = vi.spyOn(logger, 'error'); const move = new ReplayMove(state, FaceDirection.Up); const teleport = new ReplayTeleport(state); await expect(move.execute(step(ReplayCode.Up, [1]))).resolves.toBe( ReplayCommandResult.Failed ); expect(error).toHaveBeenCalledWith(2001, 'move', '0', '1'); await expect( teleport.execute(step(ReplayCode.Teleport, ['x', 1])) ).resolves.toBe(ReplayCommandResult.Failed); expect(error).toHaveBeenCalledWith( 2002, 'teleport', '0', 'number', 'string' ); }); // 验证移动已在进行中与移动控制器缺失分别记录错误码 2003 与 2004 it('warns 2003 and 2004 for a moving hero and a missing controller', async () => { const state = createCoreState(); const error = vi.spyOn(logger, 'error'); const mover = state.hero.location.mover; const move = new ReplayMove(state, FaceDirection.Up); (mover as unknown as { moving: boolean }).moving = true; await expect(move.execute(step(ReplayCode.Up, []))).resolves.toBe( ReplayCommandResult.Failed ); expect(error).toHaveBeenCalledWith(2003); (mover as unknown as { moving: boolean }).moving = false; vi.spyOn(mover, 'start').mockReturnValueOnce(null); await expect(move.finalize()).resolves.toBe(ReplayCommandResult.Failed); expect(error).toHaveBeenCalledWith(2004); }); // 验证瞬移无路径时记录错误码 2005 it('warns 2005 when teleport finds no path', async () => { const state = createCoreState(); const error = vi.spyOn(logger, 'error'); vi.spyOn(state.pathfinding, 'teleportTo').mockReturnValueOnce(null); const teleport = new ReplayTeleport(state); await expect( teleport.execute(step(ReplayCode.Teleport, [4, 5])) ).resolves.toBe(ReplayCommandResult.Failed); expect(error).toHaveBeenCalledWith(2005, '4', '5'); }); // 验证使用物品失败时记录错误码 2006 it('warns 2006 when using an item fails', async () => { const state = createCoreState(); const error = vi.spyOn(logger, 'error'); vi.spyOn(state.hero.items, 'useItem').mockReturnValueOnce(false); const useItem = new ReplayUseItem(state); await expect( useItem.execute(step(ReplayCode.UseItem, [34])) ).resolves.toBe(ReplayCommandResult.Failed); expect(error).toHaveBeenCalledWith(2006, '34'); }); // 验证装备未落到指定槽位时记录错误码 2007 it('warns 2007 when equip fails to occupy the slot', async () => { const state = createCoreState(); const error = vi.spyOn(logger, 'error'); const equipment = state.hero.equip; vi.spyOn(equipment, 'equip').mockImplementation(() => undefined); vi.spyOn(equipment, 'getEquipped').mockReturnValueOnce(undefined); const equip = new ReplayEquip(state); await expect( equip.execute(step(ReplayCode.Equip, [99, 1, false])) ).resolves.toBe(ReplayCommandResult.Failed); expect(error).toHaveBeenCalledWith(2007, '99', '1'); }); // 验证卸下后槽位仍有装备时记录错误码 2008 it('warns 2008 when unequip leaves the slot occupied', async () => { const state = createCoreState(); const error = vi.spyOn(logger, 'error'); const equipment = state.hero.equip; vi.spyOn(equipment, 'unequip').mockImplementation(() => undefined); vi.spyOn(equipment, 'getEquipped').mockReturnValueOnce(88); const unequip = new ReplayUnequip(state); await expect( unequip.execute(step(ReplayCode.Unequip, [1])) ).resolves.toBe(ReplayCommandResult.Failed); expect(error).toHaveBeenCalledWith(2008, '1'); }); // 验证录像不可存档属主为 ReplaySystem,ReplayArray 不再提供 saveState/loadState it('keeps the saveable owner on ReplaySystem instead of ReplayArray', () => { const state = createCoreState(); const route = state.replaySystem.array; const save = state.replaySystem.saveState( SaveCompression.NoCompression ); expect(save).toMatchObject({ length: 0, commandWidth: route.commandWidth, commandArray: expect.any(ArrayBuffer), paramArray: expect.any(ArrayBuffer) }); expect( (route as unknown as Record).saveState ).toBeUndefined(); expect( (route as unknown as Record).loadState ).toBeUndefined(); }); });