refactor(replay): add command base class and rewrite move/teleport

- BaseReplayCommand with name/paramTypes and assertParameter guard
- add optional IReplayCommand.notExecuted for trailing-step finalization
- sandbox invokes previous command notExecuted when the next command differs
- rewrite ReplayMoveCommand (batched step + notExecuted start/await) and ReplayTeleportCommand
- add replay logger error codes 2001-2005 and warn 175

NOTE: command tests still reference the pre-rewrite class names; updated in follow-up.
This commit is contained in:
unanmed 2026-09-13 15:16:51 +08:00
parent 8f1e973886
commit 37bfb77301
5 changed files with 186 additions and 65 deletions

View File

@ -31,6 +31,9 @@ export class ReplaySandbox
/** 录像的流式读取器 */
private reader: Readonly<IReplayReadStream>;
/** 上一步播放的指令 */
private last: number = -1;
constructor(
readonly route: IReplayArray,
readonly system: IReplaySystem,
@ -126,6 +129,23 @@ export class ReplaySandbox
this.ending = true;
return false;
}
// notExecuted
if (this.last !== -1) {
const last = this.system.getCommand(this.last);
if (!last) {
logger.warn(157, this.last.toString());
return false;
}
const success = (await last.notExecuted?.()) ?? true;
if (!success) {
logger.warn(175, this.last.toString());
return false;
}
}
this.last = next.command;
// execute
const command = this.system.getCommand(next.command);
if (!command) {
logger.warn(157, next.command.toString());
@ -140,7 +160,10 @@ export class ReplaySandbox
);
return false;
}
// hook
await Promise.all(this.forEachHook(hook => hook.onStep?.(next)));
return true;
}
}

View File

@ -18,6 +18,12 @@ export interface IReplayCommand {
* @returns 当此录像步执行完毕时兑现的 `Promise`,兑现值表示此录像步是否播放成功
*/
execute(step: IReplayStepHandler): Promise<boolean>;
/**
* 若上一步是当前录像指令,而下一步不是,触发此函数,一般用于连续步骤的后处理
* @returns 当此后处理执行完毕时兑现的 `Promise`,兑现值表示是否执行成功
*/
notExecuted?(): Promise<boolean>;
}
export interface IReplaySandboxHooks extends IHookBase {

View File

@ -1,4 +1,5 @@
import { ICoreState, ISaveableExecutor } from './types';
import { DirectionMapper, IDirectionMapper, logger } from '@motajs/common';
import { ILoadProgressTotal, LoadProgressTotal } from '@motajs/loader';
import {
IRoleFaceBinder,
IFaceManager,
@ -50,6 +51,7 @@ import {
IPathfindingSystem,
PathfindingSystem
} from '@user/data-system';
import { ICoreState, ISaveableExecutor } from './types';
import {
CommonAuraConverter,
EnemyLegacyBridge,
@ -77,9 +79,7 @@ import {
LegacyTileData,
TileLegacyBridge
} from './legacy';
import { ILoadProgressTotal, LoadProgressTotal } from '@motajs/loader';
import { isNil } from 'lodash-es';
import { DirectionMapper, IDirectionMapper, logger } from '@motajs/common';
import { DefaultHeroMoveTopImpl } from './hero';
import { createEventBuiltinRegistrations } from './event/registrations';
import { createReplayCommandItems, registerReplayCommandItems } from './replay';
@ -347,6 +347,11 @@ export class CoreState implements ICoreState {
manager.compareWith(reference);
}
/**
* 初始化地图状态
* @param floors 楼层列表
* @param data 每个楼层对应的旧版地图信息
*/
private initMapState(
floors: FloorIds[],
data: Record<FloorIds, ResolvedFloor>

View File

@ -2,7 +2,8 @@ import {
FaceDirection,
IReplayStepHandler,
IReplaySystem,
IReplayCommand
IReplayCommand,
ReplayParamValue
} from '@user/data-common';
import { EquipStatus } from '@user/data-base';
import {
@ -12,6 +13,8 @@ import {
ReplayCommandCode,
REPLAY_COMMAND_ORDER
} from './types';
import { IStateSystem } from '@user/data-system';
import { logger } from '@motajs/common';
/**
* 判断未知值是否为有限数值
@ -55,69 +58,137 @@ function resolveSlot(
return index < 0 ? null : index;
}
class ReplayDirectionCommand implements IReplayCommand {
//#region 指令基类
export abstract class BaseReplayCommand implements IReplayCommand {
/** 当前的状态对象 */
protected readonly state: IStateSystem;
/** 此录像步的字符串名称 */
protected abstract readonly name: string;
/** 预期的参数类型列表 */
protected abstract readonly paramTypes: readonly string[];
constructor(state: IStateSystem) {
this.state = state;
}
/**
* 判断指令参数是否符合预期
* @param command 指令的名称
* @param parameter 指令读取到的参数列表
* @param expect 指令的预期参数列表
*/
protected assertParameter(
command: string,
parameter: readonly ReplayParamValue[],
expect: readonly string[]
) {
if (parameter.length !== expect.length) {
const e = expect.length.toString();
const p = parameter.length.toString();
logger.error(2001, command, e, p);
return false;
}
return parameter.every((v, i) => {
const type = typeof v;
if (type === expect[i]) {
return true;
} else {
logger.error(2002, command, i.toString(), expect[i], type);
return false;
}
});
}
/**
* 执行录像步,已进行必要的参数校验,内部仅包含录像逻辑,不必包含参数校验
* @param step 当前录像步信息
*/
abstract wrappedExecute(step: IReplayStepHandler): Promise<boolean>;
execute(step: IReplayStepHandler): Promise<boolean> {
if (!this.assertParameter(this.name, step.params, this.paramTypes)) {
return Promise.resolve(false);
} else {
return this.wrappedExecute(step);
}
}
}
//#endregion
//#region 移动指令
export class ReplayMoveCommand
extends BaseReplayCommand
implements IReplayCommand
{
protected readonly name: string = 'move';
protected readonly paramTypes: readonly string[] = [];
constructor(
private readonly state: IReplayCommandState,
state: IStateSystem,
private readonly direction: FaceDirection
) {}
/**
* 按构造方向启动一次勇士移动并等待移动结束
*/
private async moveHero(): Promise<boolean> {
try {
const mover = this.state.hero.location.mover;
if (mover.moving) return false;
mover.step(this.direction);
const controller = mover.start();
if (!controller) return false;
await controller.onEnd;
return true;
} catch {
return false;
}
) {
super(state);
}
/**
* 校验录像步参数并执行一次方向移动
*/
execute(step: IReplayStepHandler): Promise<boolean> {
if (step.params.length !== 0) return Promise.resolve(false);
return this.moveHero();
async wrappedExecute(): Promise<boolean> {
// Parameter: []
const mover = this.state.hero.location.mover;
if (mover.moving) {
logger.error(2003);
return false;
}
mover.step(this.direction);
return true;
}
async notExecuted(): Promise<boolean> {
const mover = this.state.hero.location.mover;
const controller = mover.start();
if (!controller) {
logger.error(2004);
return false;
}
await controller.onEnd;
return true;
}
}
class ReplayAutoPathfindCommand implements IReplayCommand {
constructor(private readonly state: IReplayCommandState) {}
//#endregion
/**
* 自动寻路到目标坐标并等待寻路结束
*/
private async moveToPoint(x: number, y: number): Promise<boolean> {
try {
const result = this.state.pathfinding.moveTo({ x, y });
if (!result) return false;
await result.controller.onEnd;
return true;
} catch {
//#region 瞬移指令
export class ReplayTeleportCommand
extends BaseReplayCommand
implements IReplayCommand
{
protected readonly name: string = 'teleport';
protected readonly paramTypes: readonly string[] = ['number', 'number'];
async wrappedExecute(step: IReplayStepHandler): Promise<boolean> {
// Parameter: [int16 x, int16 y]
const [x, y] = step.params as [number, number];
const result = this.state.pathfinding.teleportTo({ x, y });
if (!result) {
logger.error(2005, x.toString(), y.toString());
return false;
}
}
/**
* 校验录像步参数并执行一次自动寻路
*/
execute(step: IReplayStepHandler): Promise<boolean> {
if (step.params.length !== 2) return Promise.resolve(false);
const x = step.params[0];
const y = step.params[1];
if (!isNumber(x) || !isNumber(y)) return Promise.resolve(false);
return this.moveToPoint(x, y);
await result.controller.onEnd;
return true;
}
}
class ReplayUseItemCommand implements IReplayCommand {
constructor(private readonly state: IReplayCommandState) {}
//#endregion
//#region 使用物品指令
export class ReplayUseItemCommand implements IReplayCommand {
constructor(private readonly state: IStateSystem) {}
/**
* 使用指定道具并返回现有状态接口的结果
@ -137,8 +208,12 @@ class ReplayUseItemCommand implements IReplayCommand {
}
}
class ReplayEquipCommand implements IReplayCommand {
constructor(private readonly state: IReplayCommandState) {}
//#endregion
//#region 装备指令
export class ReplayEquipCommand implements IReplayCommand {
constructor(private readonly state: IStateSystem) {}
/**
* 将指定装备穿到目标槽位并返回是否穿装成功
@ -181,8 +256,12 @@ class ReplayEquipCommand implements IReplayCommand {
}
}
class ReplayUnequipCommand implements IReplayCommand {
constructor(private readonly state: IReplayCommandState) {}
//#endregion
//#region 卸下装备指令
export class ReplayUnequipCommand implements IReplayCommand {
constructor(private readonly state: IStateSystem) {}
/**
* 卸下指定槽位的装备并返回是否卸下成功
@ -207,32 +286,34 @@ class ReplayUnequipCommand implements IReplayCommand {
}
}
//#endregion
/**
* 创建按稳定 enum 顺序排列的默认 replay command items
*/
export function createReplayCommandItems(
state: IReplayCommandState
state: IStateSystem
): ReadonlyArray<IReplayCommandItem> {
return [
{
code: ReplayCommandCode.Up,
command: new ReplayDirectionCommand(state, FaceDirection.Up)
command: new ReplayMoveCommand(state, FaceDirection.Up)
},
{
code: ReplayCommandCode.Right,
command: new ReplayDirectionCommand(state, FaceDirection.Right)
command: new ReplayMoveCommand(state, FaceDirection.Right)
},
{
code: ReplayCommandCode.Down,
command: new ReplayDirectionCommand(state, FaceDirection.Down)
command: new ReplayMoveCommand(state, FaceDirection.Down)
},
{
code: ReplayCommandCode.Left,
command: new ReplayDirectionCommand(state, FaceDirection.Left)
command: new ReplayMoveCommand(state, FaceDirection.Left)
},
{
code: ReplayCommandCode.AutoPathfindToPoint,
command: new ReplayAutoPathfindCommand(state)
command: new ReplayTeleportCommand(state)
},
{
code: ReplayCommandCode.UseItem,

View File

@ -64,7 +64,12 @@
"62": "Cannot add $1 from raw data for game map '$2' since Expected a number for the key of $3, but got $4.",
"63": "Cannot create game map '$2' from raw data since the '$1' container is missing, null, or not an object.",
"64": "Cannot create game map '$2' from raw data since the '$1' value has an invalid type or range.",
"65": "Cannot start pathfinding movement because the mover is already active."
"65": "Cannot start pathfinding movement because the mover is already active.",
"2001": "Replay($1): Replay parameter count mismatch: expected $2, got $3.",
"2002": "Replay($1): Replay parameter type mismatch: index $2, expected $3, got $4.",
"2003": "Replay(move): Expected hero to be stopped before execute moving step.",
"2004": "Replay(move): Unexpected move controller missing while executing move behavior.",
"2005": "Replay(teleport): Cannot find a way to target position: $1,$2."
},
"warn": {
"1": "Resource with type of 'none' is loaded.",
@ -241,6 +246,7 @@
"172": "Event returned non-boolean value '$1' during reduction. JavaScript short-circuit semantics will be used.",
"173": "Pathfinding input is invalid or a required binding (map state, map layer) is missing. An empty result will be returned.",
"174": "Pathfinding cost function returned a negative or NaN value. Default cost 1 will be used instead.",
"175": "Replay error: Replay not executed execution failed. Command code: $1.",
"1001": "Event(setBlock): Unknown tile '$1' when setting block."
}
}