test(06-07): add small-map replay playback and second-recording comparison

- play a recorded small-map route with bounded waiting and recording disabled
- assert a second recording equals the first step-by-step (code and param type/value)
- trigger warn 176 and errors 2001-2008 through the real replay commands
- append 06-07 code coverage map rows and record finding #06-07-1
This commit is contained in:
unanmed 2026-09-14 21:06:39 +08:00
parent edd4de34dc
commit 9f9dd11223
3 changed files with 545 additions and 0 deletions

View File

@ -85,6 +85,15 @@
| 124 | map/saveLoad.test.ts | `warns code 124 when the compression reference is missing` | 06-09 |
| 177 | data-state/test/saveablesRoundTrip.test.ts | `warns code 177 when the save data misses a saveable key` | 06-09 |
| 178 | data-state/test/saveablesRoundTrip.test.ts | `warns code 178 when the save data misses a saveable key`(文案相反的正确预期见 `it.skip`,`#06-09-5`) | 06-09 |
| 176 | hero/mover.ts | `warns 176 when the hero move direction is not orthogonal` | 06-07 |
| 2001 | data-state/src/replay/commands.ts | `warns 2001 and 2002 for parameter count and type mismatches` | 06-07 |
| 2002 | data-state/src/replay/commands.ts | `warns 2001 and 2002 for parameter count and type mismatches` | 06-07 |
| 2003 | data-state/src/replay/commands.ts | `warns 2003 and 2004 for a moving hero and a missing controller` | 06-07 |
| 2004 | data-state/src/replay/commands.ts | `warns 2003 and 2004 for a moving hero and a missing controller` | 06-07 |
| 2005 | data-state/src/replay/commands.ts | `warns 2005 when teleport finds no path` | 06-07 |
| 2006 | data-state/src/replay/commands.ts | `warns 2006 when using an item fails` | 06-07 |
| 2007 | data-state/src/replay/commands.ts | `warns 2007 when equip fails to occupy the slot` | 06-07 |
| 2008 | data-state/src/replay/commands.ts | `warns 2008 when unequip leaves the slot occupied` | 06-07 |
## 06-01 战斗系统(packages-user/data-system/src/combat)
@ -238,3 +247,24 @@ D-45:`@system/replay` 须已注册(执行前 Task 1 门禁确认);未注
执行结果:6 个测试文件 41 通过 / 6 跳过(skip 均为 `#06-09-1..5` 的正确预期疑似缺陷,经临时取消
skip 验证确为真实失败);本计划可达码 55/58/59/112/113/119/120/122/124/177/178 全部有触发断言。
`pnpm test:ci` 存在**先于本计划**的既有失败(`#06-09` 阻断项),本计划未新增失败。
## 06-07 顶层集成(伤害组合 + 录像完整播放 + 二次录制比对)
模块归属:176 → `data-base/src/hero/mover.ts`(`HeroMover.onStepStart` 方向步记录时非
上/右/下/左 的 `default` 分支);2001–2008 → `data-state/src/replay/commands.ts`
(`BaseReplayCommand.assertParameter` 参数数量/类型校验、`ReplayMoveCommand`、`ReplayTeleportCommand`、
`ReplayUseItemCommand`、`ReplayEquipCommand`、`ReplayUnequipCommand` 的失败分支)。
阶段 1(构件级)完成真实 API 下的单特殊属性伤害基线与单光环(`CommonAura`/`GuardAura`)基线;
阶段 2(组合/流水线)完成顶层多特殊属性伤害组合、系统层光环基础/特殊效果与常规/特殊查询组合、
final-effect 阶段顺序、同/跨优先级顺序(D-25),以及属性流水线结果经缓存伤害系统的
`markDirty`/`deleteEnemy`/`with(hero)` 联动(D-26);
阶段 3(完整/集成)完成真实小地图场景的录像录制与完整播放、重置录像后的**二次录制逐条比对**
(步数 + 每步 code + 各 param 的 type/value),并观测 176 与 2001–2008。
D-32:仅用 `ReplaySystem.saveState()`/`loadState()`(`IReplaySystemSave`)做录像重置的最小使用,
不测存读档本身;`ReplayArray` 不可存档(无 `saveState`/`loadState`),已由用例断言。
D-40:真实英雄操作经 `replay.route.add(...)`(mover 方向步)录制;播放期间经
`replaySystem.disable()`/`revert()` 抑制录制;准备步骤同样以 disable/revert 包裹。
一条疑似缺陷 `#06-07-1`(`CoreState` 未向寻路 `finder` 注入地图状态/事件层/通行谓词,
顶层录像瞬移恒返回 2005)按 D-05 以 `it.skip` 的正确预期用例登记,详见 `06-TEST-FINDINGS.md`。

View File

@ -149,3 +149,21 @@ D-45:公开 `CoreState.saveState(compression)` / `loadState(state, compression
**建议修复方向**:由用户在该提交归属的计划中补齐上述 4 个 `data-base` 测试的假 `replaySystem`
(或为生产侧 `this.state.replaySystem` 增加空值守卫),随后重跑 `pnpm test:ci` 应可全绿。
**严重度**:高(阻塞 06-09 的 D-44(c) 提交门禁与后续完整里程碑验证)。
## #06-07 顶层集成(伤害组合 + 录像完整播放 + 二次录制比对)
本计划按 D-43 三阶段(构件 → 组合/流水线 → 完整/集成)执行:阶段 1 真实 API 单特殊属性/单光环
基线;阶段 2 顶层多特殊属性伤害组合 + 系统层光环/效果组合(含 final-effect 阶段顺序、同/跨优先级
顺序与属性→伤害联动);阶段 3 真实小地图录像播放 + 二次录制逐条比对 + 176 + 2001–2008。
2 个测试文件共 **24 通过 / 1 跳过**(skip 为本计划新增 `#06-07-1`),`pnpm test:ci` 全绿
(66 文件 / 629 通过 / 20 跳过)。此前 `#06-09` 记录的既有 test:ci 回归已由后续提交
(`2ff422a` / `c08f3f8`)修复,D-44(c) 现可全绿。
发现 1 处疑似缺陷(`#06-07-1`),按 D-05 以正确预期的 `it.skip` 用例登记。
| 模块/接口 | 现象 | 最小复现 | 疑似原因 | 影响面 | 建议修复方向 | 关联 skip 用例 | 严重度 |
| --- | --- | --- | --- | --- | --- | --- | --- |
| `data-system/src/path/system.ts` `PathfindingSystem` / `PathfindingFinder` | 顶层 `CoreState` 上 `pathfinding.teleportTo` 恒返回 `null` 并告警 173,录像瞬移指令因此必发错误 2005 | `createCoreState()` → 构造小地图场景(不手工注入 finder)→ `state.pathfinding.teleportTo({ x: 1, y: 0 })` 返回 `null`;`logger` 先观测到 173,随后 `ReplayTeleportCommand` 发 2005,二次录像中的瞬移步无法播放 | `CoreState` 构造 `new PathfindingSystem(this)` 后仅调用 `useMover`,从未向 `finder` 注入 `useMapState`/`useMapLayer`/`usePassPredicate`;`PathfindingFinder.find` 在 `maps` 或 `layer` 为 `null` 时告警 173 并返回空数组 | 顶层录像中的瞬移指令无法播放(必失败);未手工注入 finder 时真实寻路入口不可用 | 在地图/事件层可用后(如 `initMapState` 或 `loaded` 钩子)为 `pathfinding.finder` 注入 `maps`、`eventLayer` 与 `DefaultPassPredicateImpl`;若设计上由客户端层注入,则应在契约中明确,避免录像瞬移静默失败 | `replayPlayback.test.ts` `plays a teleport step without manual finder wiring`(`it.skip`,修复后取消 skip) | 中 |
> 说明:本计划的录像播放用例为覆盖「真实寻路瞬移播放」路径,在**测试内**通过公开的
> `state.pathfinding.finder.useMapState/useMapLayer/usePassPredicate` 完成注入;生产 `CoreState`
> 目前不做该注入,故另立 `#06-07-1` 记录该接线缺口。

View File

@ -0,0 +1,497 @@
// 测试录像完整播放与二次录制比对:小地图场景、逐条相等判定、176 与 error 2001-2008
import { afterEach, describe, expect, it, vi } from 'vitest';
import { logger } from '@motajs/common';
import {
FaceDirection,
ReplayCommandCode,
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 {
ReplayEquipCommand,
ReplayMoveCommand,
ReplayTeleportCommand,
ReplayUnequipCommand,
ReplayUseItemCommand
} 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);
}
/**
* 把勇士恢复到小地图起点并清空待执行的移动队列
* @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.route,
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(ReplayCommandCode.Right);
expect(replay.route.length).toBe(0);
replay.disable();
replay.revert();
replay.record(ReplayCommandCode.Right);
expect(replay.route.length).toBe(0);
replay.revert();
replay.record(ReplayCommandCode.Right);
expect(replay.route.length).toBe(1);
});
// 验证八个稳定指令码及其参数都能经录像数组逐条读回
it('reads back every stable command code and its params', () => {
const state = createCoreState();
const replay = state.replaySystem;
replay.record(ReplayCommandCode.Up);
replay.record(ReplayCommandCode.Teleport, 3, 4);
replay.record(ReplayCommandCode.UseItem, 12);
replay.record(ReplayCommandCode.Equip, 99, 1, true);
replay.record(ReplayCommandCode.Unequip, 1);
expect(replay.route.length).toBe(5);
expect(replay.route.get(0)).toMatchObject({
command: ReplayCommandCode.Up,
params: []
});
expect(replay.route.get(1)).toMatchObject({
command: ReplayCommandCode.Teleport,
params: [3, 4]
});
expect(replay.route.get(2)).toMatchObject({
command: ReplayCommandCode.UseItem,
params: [12]
});
expect(replay.route.get(3)).toMatchObject({
command: ReplayCommandCode.Equip,
params: [99, 1, true]
});
expect(replay.route.get(4)).toMatchObject({
command: ReplayCommandCode.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.route.length).toBe(0);
await runHeroStep(state, FaceDirection.Right);
expect(state.replaySystem.route.length).toBe(1);
expect(state.replaySystem.route.get(0)).toMatchObject({
command: ReplayCommandCode.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(ReplayCommandCode.Teleport, 1, 0);
replay.record(ReplayCommandCode.Up);
const firstSteps = snapshotRoute(replay.route);
expect(firstSteps.map(item => item.code)).toEqual([
ReplayCommandCode.Right,
ReplayCommandCode.Right,
ReplayCommandCode.Teleport,
ReplayCommandCode.Up
]);
expect(replay.route.get(2)).toMatchObject({
command: ReplayCommandCode.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.route.length).toBe(0);
withReplayDisabled(state, () => resetHero(state));
await runHeroStep(state, FaceDirection.Right);
await runHeroStep(state, FaceDirection.Right);
replay.record(ReplayCommandCode.Teleport, 1, 0);
replay.record(ReplayCommandCode.Up);
const secondSteps = snapshotRoute(replay.route);
expectReplayEqual(secondSteps, firstSteps);
await playRoute(state);
expect(state.hero.location.x).toBe(1);
expect(state.hero.location.y).toBe(0);
});
// 疑似缺陷 #06-07-1:CoreState 未向寻路 finder 注入地图状态/事件层/通行谓词,
// 顶层录像瞬移恒返回 2005;本用例按正确预期编写,修复后取消 skip
it.skip('plays a teleport step without manual finder wiring', async () => {
const state = createCoreState();
const replay = state.replaySystem;
withReplayDisabled(state, () => createSmallMapScene(state, false));
await runHeroStep(state, FaceDirection.Right);
replay.record(ReplayCommandCode.Teleport, 1, 0);
await playRoute(state);
expect(state.hero.location.x).toBe(1);
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 ReplayMoveCommand(state, FaceDirection.Up);
const teleport = new ReplayTeleportCommand(state);
await expect(
move.execute(step(ReplayCommandCode.Up, [1]))
).resolves.toBe(false);
expect(error).toHaveBeenCalledWith(2001, 'move', '0', '1');
await expect(
teleport.execute(step(ReplayCommandCode.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 ReplayMoveCommand(state, FaceDirection.Up);
(mover as unknown as { moving: boolean }).moving = true;
await expect(
move.execute(step(ReplayCommandCode.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 ReplayTeleportCommand(state);
await expect(
teleport.execute(step(ReplayCommandCode.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 ReplayUseItemCommand(state);
await expect(
useItem.execute(step(ReplayCommandCode.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 ReplayEquipCommand(state);
await expect(
equip.execute(step(ReplayCommandCode.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 ReplayUnequipCommand(state);
await expect(
unequip.execute(step(ReplayCommandCode.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.route;
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();
});
});