refactor: replay system

This commit is contained in:
unanmed 2026-09-23 18:25:31 +08:00
parent 9247a8886c
commit 2198734f81
7 changed files with 195 additions and 67 deletions

View File

@ -27,7 +27,7 @@ function createCommand(
execute: (step: IReplayStepHandler) => Promise<boolean>,
notExecuted?: () => Promise<boolean>
): IReplayCommand {
return notExecuted ? { execute, notExecuted } : { execute };
return notExecuted ? { execute, finalize: notExecuted } : { execute };
}
// 用录像系统构造一个可手动驱动的录像沙箱

View File

@ -6,11 +6,17 @@ import {
} from '@motajs/common';
import {
IReplayArray,
IReplayCommand,
IReplayPassiveHandler,
IReplayReadStream,
IReplaySandbox,
IReplaySandboxHooks,
IReplaySystem
IReplayStepHandler,
IReplaySystem,
ReplayCommandResult,
ReplayCommandType
} from './types';
import { isNil } from 'lodash-es';
export class ReplaySandbox
extends Hookable<IReplaySandboxHooks>
@ -25,9 +31,15 @@ export class ReplaySandbox
private needPause: boolean = false;
/** 当前录像是否播放完毕 */
private ending: boolean = false;
/** 暂停 `Promise` 的 `resolve` 函数 */
private pauseResolve: () => void = () => {};
/** 被动录像步兑现函数 */
private passiveResolve: (status: ReplayCommandResult) => void = () => {};
/** 下一步被动录像步的 `StepHandler`,注意不是 `PassiveHandler` */
private passiveHandler: IReplayStepHandler | null = null;
/** 录像的流式读取器 */
private reader: Readonly<IReplayReadStream>;
@ -49,6 +61,20 @@ export class ReplaySandbox
return new HookController(this, hook);
}
getPassive(): IReplayPassiveHandler | null {
if (!this.passiveHandler) {
logger.error(73);
return null;
}
const handler: IReplayPassiveHandler = {
step: this.passiveHandler,
next: status => {
this.passiveResolve(status);
}
};
return handler;
}
setSpeed(speed: number): void {
this.speed = speed;
this.forEachHook(hook => hook.onSpeedSet?.(speed));
@ -121,19 +147,63 @@ export class ReplaySandbox
/**
* 触发上一步指令的连续步骤后处理
*/
private async finalizeLast(): Promise<boolean> {
if (this.last === -1) return true;
private async finalizeLast(): Promise<ReplayCommandResult> {
if (this.last === -1) return ReplayCommandResult.Success;
const last = this.system.getCommand(this.last);
if (!last) {
logger.warn(157, this.last.toString());
return false;
return ReplayCommandResult.Failed;
}
const success = (await last.notExecuted?.()) ?? true;
if (!success) {
logger.warn(175, this.last.toString());
return false;
return last.finalize?.() ?? ReplayCommandResult.Success;
}
/**
* 执行下一个录像步
* @param command 录像步对象
* @param next 录像步参数
*/
private async executeCommand(
command: IReplayCommand,
next: IReplayStepHandler
): Promise<ReplayCommandResult> {
if (command.type === ReplayCommandType.Passive) {
// 被动录像步
const { promise, resolve } =
Promise.withResolvers<ReplayCommandResult>();
this.passiveResolve = resolve;
const status = await promise;
if (isNil(status)) {
logger.error(71);
return ReplayCommandResult.Failed;
} else {
return status;
}
} else {
// 主动录像步
const status = command.execute(next);
return status;
}
}
/**
* 检查录像执行状态,进行适当的控制台输出
* @param status 录像执行状态
* @param method 录像步执行方法,可填 `active` `passive` `finalize`
*/
private checkReplayStatus(
status: ReplayCommandResult,
method: string
): boolean {
if (status === ReplayCommandResult.Success) return true;
else if (status === ReplayCommandResult.Failed) {
logger.error(72, method);
return false;
} else {
logger.log(
`Ignored replay error with a Ignored status returned by ${method} command.`
);
return true;
}
return true;
}
async step(): Promise<boolean> {
@ -143,44 +213,40 @@ export class ReplaySandbox
return false;
}
const next = this.reader.read();
if (!next) {
// notExecuted
const ne = await this.finalizeLast();
if (ne) {
this.last = -1;
this.ending = true;
}
return false;
}
// notExecuted
if (next.command !== this.last) {
// finalize 执行
if (!next || next.command !== this.last) {
const ne = await this.finalizeLast();
if (!ne) {
const success = this.checkReplayStatus(ne, 'finalize');
if (success) {
if (!next) {
this.last = -1;
this.ending = true;
return false;
}
} else {
return false;
}
}
this.last = next.command;
// execute
// 获取指令本身
const command = this.system.getCommand(next.command);
if (!command) {
logger.warn(157, next.command.toString());
return false;
}
const success = await command.execute(next);
if (!success) {
logger.warn(
158,
next.command.toString(),
JSON.stringify(next.params)
);
const status = await this.executeCommand(command, next);
const success = this.checkReplayStatus(
status,
command.type === ReplayCommandType.Active ? 'execute' : 'passive'
);
if (success) {
await Promise.all(this.forEachHook(hook => hook.onStep?.(next)));
return true;
} else {
return false;
}
// hook
await Promise.all(this.forEachHook(hook => hook.onStep?.(next)));
return true;
}
}

View File

@ -21,6 +21,22 @@ export const enum ReplayCode {
Unequip
}
export const enum ReplayCommandType {
/** 主动录像,即录像指令本身会执行某些操作 */
Active,
/** 被动录像,即外部通过 `IReplaySandbox.getPassive` 获取本步录像的参数,由外部进行后续操作 */
Passive
}
export const enum ReplayCommandResult {
/** 录像步执行成功 */
Success,
/** 录像步执行失败 */
Failed,
/** 录像步执行失败,但可以被忽略,继续执行后续录像。会在控制台输出信息。 */
Ignored
}
export type ReplayParamValue = number | string | boolean | bigint;
export interface IReplayStepHandler {
@ -32,19 +48,33 @@ export interface IReplayStepHandler {
readonly index: number;
}
export interface IReplayPassiveHandler {
/** 录像步信息 */
readonly step: IReplayStepHandler;
/**
* 本被动录像步执行完毕,进入下一个录像步
* @param status 被动录像步执行情况
*/
next(status: ReplayCommandResult): void;
}
export interface IReplayCommand {
/** 录像类型,是主动录像还是被动录像 */
readonly type: ReplayCommandType;
/**
* 执行当前录像步对应的操作逻辑
* @param step 当前录像步信息
* @returns 当此录像步执行完毕时兑现的 `Promise`,兑现值表示此录像步是否播放成功
* @returns 当此录像步执行完毕时兑现的 `Promise`,兑现值表示此录像步的执行情况
*/
execute(step: IReplayStepHandler): Promise<boolean>;
execute(step: IReplayStepHandler): Promise<ReplayCommandResult>;
/**
* 若上一步是当前录像指令,而下一步不是,触发此函数,一般用于连续步骤的后处理
* @returns 当此后处理执行完毕时兑现的 `Promise`,兑现值表示是否执行成功
*/
notExecuted?(): Promise<boolean>;
finalize?(): Promise<ReplayCommandResult>;
}
export interface IReplaySandboxHooks extends IHookBase {
@ -91,6 +121,11 @@ export interface IReplaySandbox extends IHookable<IReplaySandboxHooks> {
/** 当前是否正在播放,当调用 `play` 后,调用 `stop` 前,此值会是 `true` */
readonly playing: boolean;
/**
* 获取被动录像参数。如果下一步是被动录像,那么获取其参数,如果不是,返回 `null`
*/
getPassive(): IReplayPassiveHandler | null;
/**
* 设置播放倍率
* @param speed 播放倍率

View File

@ -122,7 +122,7 @@ describe('replay commands', () => {
expect(start).not.toHaveBeenCalled();
let result: boolean | undefined;
const pending = command.notExecuted().then(value => {
const pending = command.finalize().then(value => {
result = value;
});
await Promise.resolve();
@ -147,7 +147,7 @@ describe('replay commands', () => {
(mover as unknown as { moving: boolean }).moving = false;
vi.spyOn(mover, 'start').mockReturnValueOnce(null);
await expect(command.notExecuted()).resolves.toBe(false);
await expect(command.finalize()).resolves.toBe(false);
expect(error).toHaveBeenCalledWith(2004);
error.mockRestore();
});

View File

@ -2,7 +2,9 @@ import {
FaceDirection,
IReplayStepHandler,
IReplayCommand,
ReplayParamValue
ReplayParamValue,
ReplayCommandResult,
ReplayCommandType
} from '@user/data-common';
import { IStateSystem } from '@user/data-system';
import { logger } from '@motajs/common';
@ -18,6 +20,8 @@ export abstract class BaseReplayCommand implements IReplayCommand {
/** 预期的参数类型列表 */
protected abstract readonly paramTypes: readonly string[];
abstract readonly type: ReplayCommandType;
constructor(state: IStateSystem) {
this.state = state;
}
@ -37,16 +41,16 @@ export abstract class BaseReplayCommand implements IReplayCommand {
const e = expect.length.toString();
const p = parameter.length.toString();
logger.error(2001, command, e, p);
return false;
return ReplayCommandResult.Failed;
}
return parameter.every((v, i) => {
const type = typeof v;
if (type === expect[i]) {
return true;
return ReplayCommandResult.Success;
} else {
logger.error(2002, command, i.toString(), expect[i], type);
return false;
return ReplayCommandResult.Failed;
}
});
}
@ -55,11 +59,13 @@ export abstract class BaseReplayCommand implements IReplayCommand {
* 执行录像步,已进行必要的参数校验,内部仅包含录像逻辑,不必包含参数校验
* @param step 当前录像步信息
*/
abstract wrappedExecute(step: IReplayStepHandler): Promise<boolean>;
abstract wrappedExecute(
step: IReplayStepHandler
): Promise<ReplayCommandResult>;
execute(step: IReplayStepHandler): Promise<boolean> {
execute(step: IReplayStepHandler): Promise<ReplayCommandResult> {
if (!this.assertParameter(this.name, step.params, this.paramTypes)) {
return Promise.resolve(false);
return Promise.resolve(ReplayCommandResult.Failed);
} else {
return this.wrappedExecute(step);
}
@ -71,6 +77,8 @@ export abstract class BaseReplayCommand implements IReplayCommand {
//#region 移动指令
export class ReplayMove extends BaseReplayCommand implements IReplayCommand {
readonly type: ReplayCommandType = ReplayCommandType.Active;
protected readonly name: string = 'move';
protected readonly paramTypes: readonly string[] = [];
@ -81,27 +89,27 @@ export class ReplayMove extends BaseReplayCommand implements IReplayCommand {
super(state);
}
async wrappedExecute(): Promise<boolean> {
async wrappedExecute(): Promise<ReplayCommandResult> {
// Parameter: []
const mover = this.state.hero.location.mover;
if (mover.moving) {
logger.error(2003);
return false;
return ReplayCommandResult.Failed;
}
mover.step(this.direction);
return true;
return ReplayCommandResult.Success;
}
async notExecuted(): Promise<boolean> {
async finalize(): Promise<ReplayCommandResult> {
const mover = this.state.hero.location.mover;
const controller = mover.start();
if (!controller) {
logger.error(2004);
return false;
return ReplayCommandResult.Failed;
}
await controller.onEnd;
return true;
return ReplayCommandResult.Success;
}
}
@ -113,19 +121,23 @@ export class ReplayTeleport
extends BaseReplayCommand
implements IReplayCommand
{
readonly type: ReplayCommandType = ReplayCommandType.Active;
protected readonly name: string = 'teleport';
protected readonly paramTypes: readonly string[] = ['number', 'number'];
async wrappedExecute(step: IReplayStepHandler): Promise<boolean> {
async wrappedExecute(
step: IReplayStepHandler
): Promise<ReplayCommandResult> {
// 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;
return ReplayCommandResult.Failed;
}
await result.controller.onEnd;
return true;
return ReplayCommandResult.Success;
}
}
@ -134,17 +146,21 @@ export class ReplayTeleport
//#region 使用物品指令
export class ReplayUseItem extends BaseReplayCommand implements IReplayCommand {
readonly type: ReplayCommandType = ReplayCommandType.Active;
protected readonly name: string = 'use-item';
protected readonly paramTypes: readonly string[] = ['number'];
async wrappedExecute(step: IReplayStepHandler): Promise<boolean> {
async wrappedExecute(
step: IReplayStepHandler
): Promise<ReplayCommandResult> {
// Parameter: [int16 item]
const item = step.params[0] as number;
if (!this.state.hero.items.useItem(item)) {
logger.error(2006, item.toString());
return false;
return ReplayCommandResult.Failed;
}
return true;
return ReplayCommandResult.Success;
}
}
@ -153,14 +169,18 @@ export class ReplayUseItem extends BaseReplayCommand implements IReplayCommand {
//#region 装备指令
export class ReplayEquip extends BaseReplayCommand implements IReplayCommand {
readonly type: ReplayCommandType = ReplayCommandType.Active;
protected readonly name: string = 'equip';
protected readonly paramTypes: readonly string[] = [
'number',
'number',
'boolean'
'ReplayCommandResult'
];
async wrappedExecute(step: IReplayStepHandler): Promise<boolean> {
async wrappedExecute(
step: IReplayStepHandler
): Promise<ReplayCommandResult> {
// Parameter: [int16 uid, int8 slot, bool autoUnload]
const [uid, slot, autoUnload] = step.params as [
number,
@ -171,9 +191,9 @@ export class ReplayEquip extends BaseReplayCommand implements IReplayCommand {
equipment.equip(uid, slot, autoUnload);
if (equipment.getEquipped(slot) !== uid) {
logger.error(2007, uid.toString(), slot.toString());
return false;
return ReplayCommandResult.Failed;
}
return true;
return ReplayCommandResult.Success;
}
}
@ -182,19 +202,23 @@ export class ReplayEquip extends BaseReplayCommand implements IReplayCommand {
//#region 卸下装备指令
export class ReplayUnequip extends BaseReplayCommand implements IReplayCommand {
readonly type: ReplayCommandType = ReplayCommandType.Active;
protected readonly name: string = 'unequip';
protected readonly paramTypes: readonly string[] = ['number'];
async wrappedExecute(step: IReplayStepHandler): Promise<boolean> {
async wrappedExecute(
step: IReplayStepHandler
): Promise<ReplayCommandResult> {
// Parameter: [int8 slot]
const slot = step.params[0] as number;
const equipment = this.state.hero.equip;
equipment.unequip(slot);
if (equipment.getEquipped(slot) !== void 0) {
logger.error(2008, slot.toString());
return false;
return ReplayCommandResult.Failed;
}
return true;
return ReplayCommandResult.Success;
}
}

View File

@ -417,7 +417,7 @@ describe('replay playback error codes 2001-2008', () => {
(mover as unknown as { moving: boolean }).moving = false;
vi.spyOn(mover, 'start').mockReturnValueOnce(null);
await expect(move.notExecuted()).resolves.toBe(false);
await expect(move.finalize()).resolves.toBe(false);
expect(error).toHaveBeenCalledWith(2004);
});

View File

@ -70,6 +70,9 @@
"68": "Extra config identifier '$1' has conflicts. More than one extra config use this identifier, and the final load result is unpredictable since network decides which one to be loaded fast.",
"69": "$1 core config not loaded for the next loading phase.",
"70": "Cannot $1 replay param type $2 since they are not defined in this engine version.",
"71": "Passive replay command needs a ReplayCommandResult status transfering by IReplayPassiveHandler.next, but system got a undefined.",
"72": "Replay error when replaying $1 replay step, detailed error message will show together with this error info.",
"73": "No passive replay needed currently, but a 'getPassive' call detected, check if there's an issue on replay array or your replay logic.",
"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.",