mirror of
https://github.com/motajs/template.git
synced 2026-09-26 04:30:16 +08:00
502 lines
17 KiB
TypeScript
502 lines
17 KiB
TypeScript
// 测试录像完整播放与二次录制比对:小地图场景、逐条相等判定、176 与 error 2001-2008
|
||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||
import { logger } from '@motajs/common';
|
||
import {
|
||
FaceDirection,
|
||
ReplayCode,
|
||
SaveCompression,
|
||
TileType,
|
||
type IReplayArray,
|
||
type IReplaySandbox,
|
||
type IReplayStepHandler,
|
||
type ReplayParamValue
|
||
} from '@user/data-common';
|
||
import { type CoreState, createCoreState } from '../src/core';
|
||
import { DefaultPassPredicateImpl } from '../src/hero/predicate';
|
||
import {
|
||
ReplayEquip,
|
||
ReplayMove,
|
||
ReplayTeleport,
|
||
ReplayUnequip,
|
||
ReplayUseItem
|
||
} from '../src/replay/commands';
|
||
|
||
vi.hoisted(() => {
|
||
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;
|
||
};
|
||
});
|
||
|
||
afterEach(() => {
|
||
vi.restoreAllMocks();
|
||
});
|
||
|
||
interface IRecordedParam {
|
||
/** 参数运行时类型 */
|
||
readonly type: string;
|
||
/** 参数值 */
|
||
readonly value: ReplayParamValue;
|
||
}
|
||
|
||
interface IRecordedStep {
|
||
/** 录像指令码 */
|
||
readonly code: number;
|
||
/** 本条录像步的参数 */
|
||
readonly params: readonly IRecordedParam[];
|
||
}
|
||
|
||
/**
|
||
* 在录像记录被禁用的状态下执行准备动作,避免准备步骤被误录
|
||
* @param state 顶层状态对象
|
||
* @param action 需要执行的准备动作
|
||
*/
|
||
function withReplayDisabled<T>(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.useMapState(state.maps);
|
||
state.pathfinding.finder.useMapLayer(map.eventLayer);
|
||
state.pathfinding.finder.usePassPredicate(
|
||
new DefaultPassPredicateImpl(state.maps)
|
||
);
|
||
}
|
||
resetHero(state);
|
||
return map;
|
||
}
|
||
|
||
/**
|
||
* 把勇士恢复到小地图起点并清空待执行的移动队列
|
||
* @param state 顶层状态对象
|
||
*/
|
||
function resetHero(state: CoreState): void {
|
||
state.hero.location.setFloor('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<void> {
|
||
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<void> {
|
||
for (let index = 0; index < 1000 && !sandbox.ended; index++) {
|
||
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
||
}
|
||
if (!sandbox.ended) throw new Error('replay sandbox did not end');
|
||
}
|
||
|
||
/**
|
||
* 播放当前录像并等待结束,播放期间禁用录制以免影响录像本身
|
||
* @param state 顶层状态对象
|
||
*/
|
||
async function playRoute(state: CoreState): Promise<void> {
|
||
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);
|
||
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(
|
||
false
|
||
);
|
||
expect(error).toHaveBeenCalledWith(2001, 'move', '0', '1');
|
||
|
||
await expect(
|
||
teleport.execute(step(ReplayCode.Teleport, ['x', 1]))
|
||
).resolves.toBe(false);
|
||
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(
|
||
false
|
||
);
|
||
expect(error).toHaveBeenCalledWith(2003);
|
||
|
||
(mover as unknown as { moving: boolean }).moving = false;
|
||
vi.spyOn(mover, 'start').mockReturnValueOnce(null);
|
||
await expect(move.notExecuted()).resolves.toBe(false);
|
||
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(false);
|
||
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(false);
|
||
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(false);
|
||
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(false);
|
||
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<string, unknown>).saveState
|
||
).toBeUndefined();
|
||
expect(
|
||
(route as unknown as Record<string, unknown>).loadState
|
||
).toBeUndefined();
|
||
});
|
||
});
|