From 37bfb77301c9241bc183f49a75485acc19aed0a8 Mon Sep 17 00:00:00 2001 From: unanmed <1319491857@qq.com> Date: Sun, 13 Sep 2026 15:16:51 +0800 Subject: [PATCH] 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. --- .../data-common/src/replay/sandbox.ts | 23 ++ packages-user/data-common/src/replay/types.ts | 6 + packages-user/data-state/src/core.ts | 11 +- .../data-state/src/replay/commands.ts | 203 ++++++++++++------ packages/common/src/logger.json | 8 +- 5 files changed, 186 insertions(+), 65 deletions(-) diff --git a/packages-user/data-common/src/replay/sandbox.ts b/packages-user/data-common/src/replay/sandbox.ts index 376eca1..37b8335 100644 --- a/packages-user/data-common/src/replay/sandbox.ts +++ b/packages-user/data-common/src/replay/sandbox.ts @@ -31,6 +31,9 @@ export class ReplaySandbox /** 录像的流式读取器 */ private reader: Readonly; + /** 上一步播放的指令 */ + 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; } } diff --git a/packages-user/data-common/src/replay/types.ts b/packages-user/data-common/src/replay/types.ts index c28e14d..38247d3 100644 --- a/packages-user/data-common/src/replay/types.ts +++ b/packages-user/data-common/src/replay/types.ts @@ -18,6 +18,12 @@ export interface IReplayCommand { * @returns 当此录像步执行完毕时兑现的 `Promise`,兑现值表示此录像步是否播放成功 */ execute(step: IReplayStepHandler): Promise; + + /** + * 若上一步是当前录像指令,而下一步不是,触发此函数,一般用于连续步骤的后处理 + * @returns 当此后处理执行完毕时兑现的 `Promise`,兑现值表示是否执行成功 + */ + notExecuted?(): Promise; } export interface IReplaySandboxHooks extends IHookBase { diff --git a/packages-user/data-state/src/core.ts b/packages-user/data-state/src/core.ts index 2e12cb5..f1abb8f 100644 --- a/packages-user/data-state/src/core.ts +++ b/packages-user/data-state/src/core.ts @@ -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 diff --git a/packages-user/data-state/src/replay/commands.ts b/packages-user/data-state/src/replay/commands.ts index f93637c..0f33dd9 100644 --- a/packages-user/data-state/src/replay/commands.ts +++ b/packages-user/data-state/src/replay/commands.ts @@ -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; + + execute(step: IReplayStepHandler): Promise { + 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 { - 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 { - if (step.params.length !== 0) return Promise.resolve(false); - return this.moveHero(); + async wrappedExecute(): Promise { + // 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 { + 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 { - 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 { + // 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 { - 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 { 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, diff --git a/packages/common/src/logger.json b/packages/common/src/logger.json index 3fd3b6d..c338eba 100644 --- a/packages/common/src/logger.json +++ b/packages/common/src/logger.json @@ -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." } }