feat(03-03): add async-safe replay commands

- Preserve replay collection context through async decorator settlement
- Add stable command items with awaited movement and pathfinding
- Return explicit failures for invalid item and equipment actions
This commit is contained in:
unanmed 2026-09-10 16:43:30 +08:00
parent d909e973e7
commit e7714894a0
6 changed files with 652 additions and 45 deletions

View File

@ -15,14 +15,22 @@ interface IReplaySafetyDetailQueue {
readonly collection: IReplaySafetyCollection;
}
type ReplayDecorator = <
Return,
This,
Func extends (this: This, ...args: any[]) => Return
>(
method: Func,
context: ClassMethodDecoratorContext
) => (this: This, ...args: any[]) => Return;
interface IPromiseLike {
then(
onFulfilled?: (value: unknown) => unknown,
onRejected?: (reason: unknown) => unknown
): unknown;
}
type ReplayMethod<This, Args extends unknown[], Return> = (
this: This,
...args: Args
) => Return;
type ReplayDecorator = <This, Args extends unknown[], Return>(
method: ReplayMethod<This, Args, Return>,
context: ClassMethodDecoratorContext<This, ReplayMethod<This, Args, Return>>
) => ReplayMethod<This, Args, Return>;
/** 本次收集使用的录像系统 */
let replaySystem: IReplaySystem | null = null;
@ -46,6 +54,21 @@ let detailCode = 0;
/** 录像收集详细信息队列,保留 50 个以确保可以在控制台重复输出 */
const detailQueue: IReplaySafetyDetailQueue[] = [];
function isPromiseLike(value: unknown): value is IPromiseLike {
if (!value || (typeof value !== 'object' && typeof value !== 'function')) {
return false;
}
return typeof (value as { then?: unknown }).then === 'function';
}
function resetReplaySafetyCollection(): void {
replaySystem = null;
collecting = false;
beforeLength = 0;
shouldIgnore = false;
currentCollection = null;
}
/**
*
* @param system
@ -58,6 +81,7 @@ export function beginReplaySafetyCollection(system: IReplaySystem): void {
replaySystem = system;
collecting = true;
beforeLength = system.route.length;
shouldIgnore = false;
collection.messages.length = 0;
currentCollection = collection;
}
@ -70,30 +94,34 @@ export function endReplaySafetyCollection(): void {
logger.warn(160);
return;
}
if (shouldIgnore) return;
if (replaySystem.route.length > beforeLength) return;
if (collection.messages.length === 0) return;
try {
if (shouldIgnore) return;
if (replaySystem.route.length > beforeLength) return;
if (collection.messages.length === 0) return;
// 需要把收集内容输出,这里只输出一层,完整输出需要在控制台手动调用
const code = detailCode++;
const col: IReplaySafetyCollection = {
name: `detail#${code}`,
messages: collection.messages.slice()
};
const command = `Mota.require('@user/data-common').logReplaySafetyDetail(${code});`;
const simplified = [...collection.messages]
.sort((a, b) => b.messages.length - a.messages.length)
.map(v => v.name)
.join('\n');
logger.warn(161, command, simplified);
// 需要把收集内容输出,这里只输出一层,完整输出需要在控制台手动调用
const code = detailCode++;
const col: IReplaySafetyCollection = {
name: `detail#${code}`,
messages: collection.messages.slice()
};
const command = `Mota.require('@user/data-common').logReplaySafetyDetail(${code});`;
const simplified = [...collection.messages]
.sort((a, b) => b.messages.length - a.messages.length)
.map(v => v.name)
.join('\n');
logger.warn(161, command, simplified);
while (detailQueue.length > 50) {
detailQueue.shift();
while (detailQueue.length > 50) {
detailQueue.shift();
}
detailQueue.push({
code,
collection: col
});
} finally {
resetReplaySafetyCollection();
}
detailQueue.push({
code,
collection: col
});
}
/**
@ -133,16 +161,14 @@ export function logReplaySafetyDetail(code: number): void {
* @param message 使
*/
export function shouldReplay(message: string): ReplayDecorator {
return function <
Return,
This,
Args extends any[],
Func extends (this: This, ...args: Args) => Return
>(
return function <This, Args extends unknown[], Return>(
this: This,
method: Func,
context: ClassMethodDecoratorContext
): (this: This, ...args: Args) => Return {
method: ReplayMethod<This, Args, Return>,
context: ClassMethodDecoratorContext<
This,
ReplayMethod<This, Args, Return>
>
): ReplayMethod<This, Args, Return> {
return function (this: This, ...args: Args): Return {
const before = currentCollection;
if (!before) return method.apply(this, args);
@ -158,7 +184,18 @@ export function shouldReplay(message: string): ReplayDecorator {
currentCollection = newCollection;
const result = method.apply(this, args);
currentCollection = before;
if (isPromiseLike(result)) {
Promise.resolve(result).then(
() => {
currentCollection = before;
},
() => {
currentCollection = before;
}
);
} else {
currentCollection = before;
}
return result;
};
};
@ -169,12 +206,10 @@ export function shouldReplay(message: string): ReplayDecorator {
* 使
*/
export function ignoreReplay(): ReplayDecorator {
return function <
Return,
This,
Args extends any[],
Func extends (this: This, ...args: Args) => Return
>(this: This, method: Func): (this: This, ...args: Args) => Return {
return function <This, Args extends unknown[], Return>(
this: This,
method: ReplayMethod<This, Args, Return>
): ReplayMethod<This, Args, Return> {
return function (this: This, ...args: Args): Return {
shouldIgnore = true;
return method.apply(this, args);

View File

@ -1,3 +1,5 @@
export * from './array';
export * from './func';
export * from './sandbox';
export * from './system';
export * from './types';

View File

@ -0,0 +1,257 @@
import { logger } from '@motajs/common';
import {
FaceDirection,
IReplayStepHandler,
IMoverController,
beginReplaySafetyCollection,
endReplaySafetyCollection,
logReplaySafetyDetail,
shouldReplay
} from '@user/data-common';
import { EquipStatus } from '@user/data-base';
import { describe, expect, it, vi } from 'vitest';
import { createCoreState } from '../core';
import { ReplaySystem } from '../../../data-common/src/replay/system';
import {
createReplayCommandItems,
registerReplayCommandItems
} from './commands';
import {
IReplayCommandItem,
ReplayCommandCode,
REPLAY_COMMAND_ORDER
} from './types';
function step(
command: number,
params: IReplayStepHandler['params']
): IReplayStepHandler {
return { command, params, index: 0 };
}
function controller(onEnd: Promise<void>): Readonly<IMoverController> {
return {
done: false,
onEnd,
push: () => {},
insert: () => {},
stop: () => onEnd
};
}
describe('replay commands', () => {
// 验证默认 command item 只按稳定 enum 顺序提供八个实现
it('creates the approved command order without module-owned numbering', () => {
const state = createCoreState();
const items = createReplayCommandItems(state);
expect(items.map(item => item.code)).toEqual(REPLAY_COMMAND_ORDER);
expect(items).toHaveLength(8);
expect(items.map(item => item.command.execute)).toHaveLength(8);
});
// 验证顶层注册器按稳定顺序注册并拒绝重复 code
it('registers commands in order and rejects duplicate codes', () => {
const state = createCoreState();
const replay = new ReplaySystem();
const items = createReplayCommandItems(state);
registerReplayCommandItems(replay, items);
expect(
REPLAY_COMMAND_ORDER.every(code => replay.getCommand(code))
).toBe(true);
const duplicate: IReplayCommandItem[] = items.map((item, index) =>
index === 1 ? { ...item, code: ReplayCommandCode.Up } : item
);
expect(() =>
registerReplayCommandItems(new ReplaySystem(), duplicate)
).toThrow('Duplicate replay command code');
});
// 验证四向移动在 controller.onEnd 兑现前不会完成 command
it('awaits a directional movement controller', async () => {
const state = createCoreState();
const items = createReplayCommandItems(state);
const deferred = Promise.withResolvers<void>();
const mover = state.hero.location.mover;
const move = vi.spyOn(mover, 'step');
const start = vi
.spyOn(mover, 'start')
.mockReturnValue(controller(deferred.promise));
const result = items[ReplayCommandCode.Right].command.execute(
step(ReplayCommandCode.Right, [])
);
let settled = false;
void result.then(() => {
settled = true;
});
await Promise.resolve();
expect(settled).toBe(false);
expect(move).toHaveBeenCalledWith(FaceDirection.Right);
expect(start).toHaveBeenCalledTimes(1);
deferred.resolve();
await expect(result).resolves.toBe(true);
});
// 验证自动寻路等待 PathfindingSystem 返回的完整 controller
it('awaits the pathfinding controller and returns false when no path exists', async () => {
const state = createCoreState();
const items = createReplayCommandItems(state);
const deferred = Promise.withResolvers<void>();
const moveTo = vi.spyOn(state.pathfinding, 'moveTo');
moveTo.mockReturnValue({
controller: controller(deferred.promise),
path: []
});
const result = items[
ReplayCommandCode.AutoPathfindToPoint
].command.execute(step(ReplayCommandCode.AutoPathfindToPoint, [2, 3]));
await Promise.resolve();
expect(moveTo).toHaveBeenCalledWith({ x: 2, y: 3 });
deferred.resolve();
await expect(result).resolves.toBe(true);
moveTo.mockReturnValue(null);
await expect(
items[ReplayCommandCode.AutoPathfindToPoint].command.execute(
step(ReplayCommandCode.AutoPathfindToPoint, [2, 3])
)
).resolves.toBe(false);
});
// 验证道具和装备 command 使用既有状态 API 并把失败结果返回给 replay
it('returns the existing item and equipment action results', async () => {
const state = createCoreState();
const items = createReplayCommandItems(state);
const useItem = vi
.spyOn(state.hero.items, 'useItem')
.mockReturnValueOnce(true)
.mockReturnValueOnce(false);
await expect(
items[ReplayCommandCode.UseItem].command.execute(
step(ReplayCommandCode.UseItem, [12])
)
).resolves.toBe(true);
await expect(
items[ReplayCommandCode.UseItem].command.execute(
step(ReplayCommandCode.UseItem, ['unknown'])
)
).resolves.toBe(false);
expect(useItem).toHaveBeenNthCalledWith(1, 12);
const canEquipTo = vi
.spyOn(state.hero.equip, 'canEquipTo')
.mockReturnValue(EquipStatus.CanEquip);
const getEquipped = vi
.spyOn(state.hero.equip, 'getEquipped')
.mockReturnValueOnce(undefined)
.mockReturnValueOnce(99)
.mockReturnValueOnce(99)
.mockReturnValueOnce(undefined);
const equip = vi
.spyOn(state.hero.equip, 'equip')
.mockImplementation(() => undefined);
await expect(
items[ReplayCommandCode.Equip].command.execute(
step(ReplayCommandCode.Equip, [99, 0])
)
).resolves.toBe(true);
expect(canEquipTo).toHaveBeenCalledWith(99, 0);
expect(equip).toHaveBeenCalledWith(99, 0, undefined);
await expect(
items[ReplayCommandCode.Unequip].command.execute(
step(ReplayCommandCode.Unequip, [0])
)
).resolves.toBe(true);
expect(getEquipped).toHaveBeenCalled();
});
// 验证所有 command 对无效参数都以 false 结束而不推进状态
it('returns false for invalid command parameters', async () => {
const state = createCoreState();
const items = createReplayCommandItems(state);
const invalid = [
items[ReplayCommandCode.Up].command.execute(step(0, [1])),
items[ReplayCommandCode.AutoPathfindToPoint].command.execute(
step(4, ['x', 1])
),
items[ReplayCommandCode.UseItem].command.execute(step(5, [])),
items[ReplayCommandCode.Equip].command.execute(step(6, [1])),
items[ReplayCommandCode.Unequip].command.execute(step(7, ['slot']))
];
await expect(Promise.all(invalid)).resolves.toEqual([
false,
false,
false,
false,
false
]);
});
});
describe('replay safety decorators', () => {
// 验证异步 decorator 在 Promise 兑现前保留嵌套 collection 上下文
it('keeps collection context through deferred nested actions', async () => {
interface DecoratedFixture {
inner(): void;
outer(gate: Promise<void>): Promise<void>;
}
const inner = shouldReplay('inner')(
function (this: DecoratedFixture): void {},
{ name: 'inner' } as ClassMethodDecoratorContext<
DecoratedFixture,
(this: DecoratedFixture) => void
>
);
const outer = shouldReplay('outer')(
async function (
this: DecoratedFixture,
gate: Promise<void>
): Promise<void> {
await gate;
this.inner();
},
{ name: 'outer' } as ClassMethodDecoratorContext<
DecoratedFixture,
(this: DecoratedFixture, gate: Promise<void>) => Promise<void>
>
);
const fixture: DecoratedFixture = {
inner,
outer
};
const replay = new ReplaySystem();
const gate = Promise.withResolvers<void>();
const warning = vi.spyOn(logger, 'warn');
const group = vi.spyOn(console, 'group').mockImplementation(() => {});
beginReplaySafetyCollection(replay);
const action = fixture.outer(gate.promise);
gate.resolve();
await action;
endReplaySafetyCollection();
const detail = warning.mock.calls.find(call => call[0] === 161);
expect(detail).toBeDefined();
const command = String(detail![1]);
const match = command.match(/\((\d+)\)/);
expect(match).not.toBeNull();
const output = vi.spyOn(console, 'log').mockImplementation(() => {});
logReplaySafetyDetail(Number(match![1]));
expect(group).toHaveBeenCalled();
expect(output.mock.calls.flat().join(' ')).toContain('inner');
warning.mockRestore();
group.mockRestore();
output.mockRestore();
});
// 验证同步 collection 在结束后可重新开始且不会残留旧上下文
it('resets the collection lifecycle after completion', () => {
const replay = new ReplaySystem();
const warning = vi.spyOn(logger, 'warn');
beginReplaySafetyCollection(replay);
endReplaySafetyCollection();
beginReplaySafetyCollection(replay);
endReplaySafetyCollection();
expect(warning).not.toHaveBeenCalledWith(159);
warning.mockRestore();
});
});

View File

@ -0,0 +1,204 @@
import {
FaceDirection,
IReplayStepHandler,
IReplaySystem
} from '@user/data-common';
import { EquipStatus } from '@user/data-base';
import {
IReplayCommandItem,
IReplayCommandRegistry,
IReplayCommandState,
ReplayCommandCode,
REPLAY_COMMAND_ORDER
} from './types';
function isNumber(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value);
}
function isItem(value: unknown): value is number | string {
return isNumber(value) || typeof value === 'string';
}
function isBoolean(value: unknown): value is boolean {
return typeof value === 'boolean';
}
function isSlot(value: unknown): value is number | string {
return isNumber(value) || typeof value === 'string';
}
async function moveHero(
state: IReplayCommandState,
direction: FaceDirection,
step: IReplayStepHandler
): Promise<boolean> {
if (step.params.length !== 0) return false;
const mover = state.hero.location.mover;
if (mover.moving) return false;
mover.step(direction);
const controller = mover.start();
if (!controller) return false;
await controller.onEnd;
return true;
}
async function moveToPoint(
state: IReplayCommandState,
step: IReplayStepHandler
): Promise<boolean> {
if (step.params.length !== 2) return false;
const x = step.params[0];
const y = step.params[1];
if (!isNumber(x) || !isNumber(y)) return false;
const result = state.pathfinding.moveTo({ x, y });
if (!result) return false;
await result.controller.onEnd;
return true;
}
function useItem(
state: IReplayCommandState,
step: IReplayStepHandler
): Promise<boolean> {
if (step.params.length !== 1) return Promise.resolve(false);
const item = step.params[0];
if (!isItem(item)) return Promise.resolve(false);
return Promise.resolve(state.hero.items.useItem(item));
}
function resolveSlot(
state: IReplayCommandState,
slot: number | string
): number | null {
if (typeof slot === 'number') {
return Number.isInteger(slot) && slot >= 0 ? slot : null;
}
const index = state.hero.equip.slots.indexOf(slot);
return index < 0 ? null : index;
}
function equip(
state: IReplayCommandState,
step: IReplayStepHandler
): Promise<boolean> {
if (step.params.length < 2 || step.params.length > 3) {
return Promise.resolve(false);
}
const uid = step.params[0];
const slot = step.params[1];
const autoUnload = step.params[2];
if (!isNumber(uid) || !Number.isInteger(uid) || !isSlot(slot)) {
return Promise.resolve(false);
}
if (autoUnload !== undefined && !isBoolean(autoUnload)) {
return Promise.resolve(false);
}
const slotIndex = resolveSlot(state, slot);
if (slotIndex === null) return Promise.resolve(false);
if (state.hero.equip.getEquipped(slotIndex) === uid) {
return Promise.resolve(true);
}
if (state.hero.equip.canEquipTo(uid, slot) === EquipStatus.CannotEquip) {
return Promise.resolve(false);
}
state.hero.equip.equip(uid, slot, autoUnload);
return Promise.resolve(state.hero.equip.getEquipped(slotIndex) === uid);
}
function unequip(
state: IReplayCommandState,
step: IReplayStepHandler
): Promise<boolean> {
if (step.params.length !== 1) return Promise.resolve(false);
const slot = step.params[0];
if (!isNumber(slot) || !Number.isInteger(slot) || slot < 0) {
return Promise.resolve(false);
}
if (state.hero.equip.getEquipped(slot) === undefined) {
return Promise.resolve(false);
}
state.hero.equip.unequip(slot);
return Promise.resolve(state.hero.equip.getEquipped(slot) === undefined);
}
function createMoveCommand(
state: IReplayCommandState,
direction: FaceDirection
) {
return {
execute: (step: IReplayStepHandler): Promise<boolean> =>
moveHero(state, direction, step)
};
}
/** 创建按稳定 enum 顺序排列的默认 replay command items */
export function createReplayCommandItems(
state: IReplayCommandState
): ReadonlyArray<IReplayCommandItem> {
return [
{
code: ReplayCommandCode.Up,
command: createMoveCommand(state, FaceDirection.Up)
},
{
code: ReplayCommandCode.Right,
command: createMoveCommand(state, FaceDirection.Right)
},
{
code: ReplayCommandCode.Down,
command: createMoveCommand(state, FaceDirection.Down)
},
{
code: ReplayCommandCode.Left,
command: createMoveCommand(state, FaceDirection.Left)
},
{
code: ReplayCommandCode.AutoPathfindToPoint,
command: { execute: step => moveToPoint(state, step) }
},
{
code: ReplayCommandCode.UseItem,
command: { execute: step => useItem(state, step) }
},
{
code: ReplayCommandCode.Equip,
command: { execute: step => equip(state, step) }
},
{
code: ReplayCommandCode.Unequip,
command: { execute: step => unequip(state, step) }
}
];
}
/** 按 top-level stable code 注册 command并在注册前拒绝重复项 */
export function registerReplayCommandItems(
replay: IReplaySystem | IReplayCommandRegistry,
items: ReadonlyArray<IReplayCommandItem>
): void {
if (items.length !== REPLAY_COMMAND_ORDER.length) {
throw new Error(
'Replay command registry must contain exactly eight items'
);
}
const codes = new Set<number>();
for (let index = 0; index < items.length; index++) {
const item = items[index];
if (codes.has(item.code)) {
throw new Error(`Duplicate replay command code: ${item.code}`);
}
if (item.code !== REPLAY_COMMAND_ORDER[index]) {
throw new Error(`Replay command order mismatch at index ${index}`);
}
if (replay.getCommand(item.code)) {
throw new Error(
`Replay command code already registered: ${item.code}`
);
}
codes.add(item.code);
}
for (const item of items) {
replay.registerCommand(item.code, item.command);
}
}

View File

@ -0,0 +1,2 @@
export * from './commands';
export * from './types';

View File

@ -0,0 +1,107 @@
import { IReplayCommand, ReplayParamValue } from '@user/data-common';
import { EquipStatus, IHeroLocation, IHeroMover } from '@user/data-base';
import { IPathfindingSystem } from '@user/data-system';
/** 顶层拥有的稳定录像指令码,数值属于录像格式的一部分 */
export const enum ReplayCommandCode {
/** 向上移动一步 */
Up = 0,
/** 向右移动一步 */
Right = 1,
/** 向下移动一步 */
Down = 2,
/** 向左移动一步 */
Left = 3,
/** 自动寻路至目标点 */
AutoPathfindToPoint = 4,
/** 使用道具 */
UseItem = 5,
/** 装备物品 */
Equip = 6,
/** 卸下装备 */
Unequip = 7
}
/** replay command 使用的勇士道具访问边界 */
export interface IReplayHeroItems {
/** 使用指定道具 */
useItem(item: number | string): boolean;
}
/** replay command 使用的勇士装备访问边界 */
export interface IReplayHeroEquipment {
/** 判断装备是否可以放入目标槽位 */
canEquipTo(uid: number, slot: number | string): EquipStatus;
/** 将装备放入目标槽位 */
equip(
uid: number,
slot: number | string,
autoUnload?: boolean
): number | undefined;
/** 卸下指定槽位的装备 */
unequip(slot: number): number | undefined;
/** 获取槽位上的装备 uid */
getEquipped(slot: number): number | undefined;
/** 当前装备槽名称 */
readonly slots: readonly string[];
}
/** replay command 使用的勇士移动访问边界 */
export interface IReplayHeroLocation {
/** 勇士移动器 */
readonly mover: IHeroMover<IHeroLocation>;
}
/** replay command 使用的勇士访问边界 */
export interface IReplayHero {
/** 勇士位置 */
readonly location: IReplayHeroLocation;
/** 勇士道具 */
readonly items: IReplayHeroItems;
/** 勇士装备 */
readonly equip: IReplayHeroEquipment;
}
/** replay command 实现可访问的 CoreState 内部边界 */
export interface IReplayCommandState {
/** 勇士状态 */
readonly hero: IReplayHero;
/** 已绑定勇士移动器的寻路系统 */
readonly pathfinding: IPathfindingSystem;
}
/** 模块提供给顶层注册器的 command item */
export interface IReplayCommandItem {
/** 顶层稳定指令码 */
readonly code: ReplayCommandCode;
/** 指令实现 */
readonly command: IReplayCommand;
}
/** 供测试和顶层装配读取的稳定指令码顺序 */
export const REPLAY_COMMAND_ORDER: readonly ReplayCommandCode[] = [
ReplayCommandCode.Up,
ReplayCommandCode.Right,
ReplayCommandCode.Down,
ReplayCommandCode.Left,
ReplayCommandCode.AutoPathfindToPoint,
ReplayCommandCode.UseItem,
ReplayCommandCode.Equip,
ReplayCommandCode.Unequip
];
/** 顶层注册器使用的重放系统最小边界 */
export interface IReplayCommandRegistry {
/** 注册录像指令 */
registerCommand(code: number, command: IReplayCommand): void;
/** 查询录像指令 */
getCommand(code: number): IReplayCommand | null;
}
/** command 参数读取结果 */
export type ReplayCommandParam = ReplayParamValue;