mirror of
https://github.com/motajs/template.git
synced 2026-09-20 06:20:17 +08:00
refactor(replay): rewrite remaining commands and register in CoreState
- ReplayUseItemCommand / ReplayEquipCommand / ReplayUnequipCommand now extend BaseReplayCommand with fixed numeric params and per-false-position logger error codes (2006-2010); string item/slot params removed, autoUnload is a required bool. - remove isNumber/isItem/isBoolean/isSlot/resolveSlot helpers; all logic now lives in the command classes. - drop createReplayCommandItems/registerReplayCommandItems; CoreState owns a private registerReplayCommand() registering the eight stable codes directly. - prune unused replay boundary types from replay/types.ts. - fix ReplaySandbox: a replay ending on a run of moves now finalizes the trailing run through notExecuted (previously the last move never started). - rewrite commands.test.ts for the base-class design and direct registration. Verified: data type gate 0 in-scope; vitest 19 files / 112 tests pass; prettier and eslint clean on touched files.
This commit is contained in:
parent
37bfb77301
commit
dd4e66a335
@ -126,22 +126,18 @@ export class ReplaySandbox
|
||||
}
|
||||
const next = this.reader.read();
|
||||
if (!next) {
|
||||
// notExecuted
|
||||
if (!(await this.finalizeLast())) {
|
||||
return false;
|
||||
}
|
||||
this.last = -1;
|
||||
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;
|
||||
}
|
||||
if (!(await this.finalizeLast())) {
|
||||
return false;
|
||||
}
|
||||
this.last = next.command;
|
||||
|
||||
@ -166,4 +162,22 @@ export class ReplaySandbox
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发上一步指令的连续步骤后处理
|
||||
*/
|
||||
private async finalizeLast(): Promise<boolean> {
|
||||
if (this.last === -1) return true;
|
||||
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;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@ -82,7 +82,14 @@ import {
|
||||
import { isNil } from 'lodash-es';
|
||||
import { DefaultHeroMoveTopImpl } from './hero';
|
||||
import { createEventBuiltinRegistrations } from './event/registrations';
|
||||
import { createReplayCommandItems, registerReplayCommandItems } from './replay';
|
||||
import {
|
||||
ReplayCommandCode,
|
||||
ReplayEquipCommand,
|
||||
ReplayMoveCommand,
|
||||
ReplayTeleportCommand,
|
||||
ReplayUnequipCommand,
|
||||
ReplayUseItemCommand
|
||||
} from './replay';
|
||||
|
||||
export class CoreState implements ICoreState {
|
||||
// Layer 0 公共层,最底层的接口,不会依赖任何其他内容,一般是工具性接口及不需要存档的数据
|
||||
@ -252,18 +259,52 @@ export class CoreState implements ICoreState {
|
||||
pathfinding.useMover(this.hero.location.mover);
|
||||
this.pathfinding = pathfinding;
|
||||
|
||||
const replaySystem = new ReplaySystem();
|
||||
registerReplayCommandItems(
|
||||
replaySystem,
|
||||
createReplayCommandItems(this)
|
||||
);
|
||||
this.replaySystem = replaySystem;
|
||||
this.replaySystem = new ReplaySystem();
|
||||
this.registerReplayCommand();
|
||||
|
||||
//#endregion
|
||||
}
|
||||
|
||||
//#region 私有方法
|
||||
|
||||
/**
|
||||
* 注册全部录像指令
|
||||
*/
|
||||
private registerReplayCommand() {
|
||||
this.replaySystem.registerCommand(
|
||||
ReplayCommandCode.Up,
|
||||
new ReplayMoveCommand(this, FaceDirection.Up)
|
||||
);
|
||||
this.replaySystem.registerCommand(
|
||||
ReplayCommandCode.Right,
|
||||
new ReplayMoveCommand(this, FaceDirection.Right)
|
||||
);
|
||||
this.replaySystem.registerCommand(
|
||||
ReplayCommandCode.Down,
|
||||
new ReplayMoveCommand(this, FaceDirection.Down)
|
||||
);
|
||||
this.replaySystem.registerCommand(
|
||||
ReplayCommandCode.Left,
|
||||
new ReplayMoveCommand(this, FaceDirection.Left)
|
||||
);
|
||||
this.replaySystem.registerCommand(
|
||||
ReplayCommandCode.AutoPathfindToPoint,
|
||||
new ReplayTeleportCommand(this)
|
||||
);
|
||||
this.replaySystem.registerCommand(
|
||||
ReplayCommandCode.UseItem,
|
||||
new ReplayUseItemCommand(this)
|
||||
);
|
||||
this.replaySystem.registerCommand(
|
||||
ReplayCommandCode.Equip,
|
||||
new ReplayEquipCommand(this)
|
||||
);
|
||||
this.replaySystem.registerCommand(
|
||||
ReplayCommandCode.Unequip,
|
||||
new ReplayUnequipCommand(this)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化图块存储对象
|
||||
* @param data 旧样板图块定义对象
|
||||
|
||||
@ -15,14 +15,13 @@ import { describe, expect, it, vi } from 'vitest';
|
||||
import { createCoreState } from '../core';
|
||||
import { ReplaySystem } from '../../../data-common/src/replay/system';
|
||||
import {
|
||||
createReplayCommandItems,
|
||||
registerReplayCommandItems
|
||||
ReplayEquipCommand,
|
||||
ReplayMoveCommand,
|
||||
ReplayTeleportCommand,
|
||||
ReplayUnequipCommand,
|
||||
ReplayUseItemCommand
|
||||
} from './commands';
|
||||
import {
|
||||
IReplayCommandItem,
|
||||
ReplayCommandCode,
|
||||
REPLAY_COMMAND_ORDER
|
||||
} from './types';
|
||||
import { ReplayCommandCode, REPLAY_COMMAND_ORDER } from './types';
|
||||
|
||||
function step(
|
||||
command: number,
|
||||
@ -47,45 +46,28 @@ interface IManualReplaySandbox extends IReplaySandbox {
|
||||
}
|
||||
|
||||
describe('replay commands', () => {
|
||||
// 验证默认 command item 只按稳定 enum 顺序提供八个实现
|
||||
it('creates the approved command order without module-owned numbering', () => {
|
||||
// 验证每个 CoreState 都按稳定顺序装配八个指令
|
||||
it('registers the eight stable commands in order on every CoreState', () => {
|
||||
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);
|
||||
expect(items.map(item => item.command.constructor.name)).toEqual([
|
||||
'ReplayDirectionCommand',
|
||||
'ReplayDirectionCommand',
|
||||
'ReplayDirectionCommand',
|
||||
'ReplayDirectionCommand',
|
||||
'ReplayAutoPathfindCommand',
|
||||
'ReplayUseItemCommand',
|
||||
'ReplayEquipCommand',
|
||||
'ReplayUnequipCommand'
|
||||
expect(REPLAY_COMMAND_ORDER).toEqual([
|
||||
ReplayCommandCode.Up,
|
||||
ReplayCommandCode.Right,
|
||||
ReplayCommandCode.Down,
|
||||
ReplayCommandCode.Left,
|
||||
ReplayCommandCode.AutoPathfindToPoint,
|
||||
ReplayCommandCode.UseItem,
|
||||
ReplayCommandCode.Equip,
|
||||
ReplayCommandCode.Unequip
|
||||
]);
|
||||
});
|
||||
|
||||
// 验证顶层注册器按稳定顺序注册并拒绝重复 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))
|
||||
REPLAY_COMMAND_ORDER.every(code =>
|
||||
state.replaySystem.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');
|
||||
});
|
||||
|
||||
// 验证每个 CoreState 都独立装配八个稳定 command 与寻路访问边界
|
||||
it('assembles an independent top-level registry for every CoreState', () => {
|
||||
// 验证每个 CoreState 独立装配,且四向共用一个参数化移动类
|
||||
it('assembles an independent command set per CoreState', () => {
|
||||
const first = createCoreState();
|
||||
const second = createCoreState();
|
||||
const firstCommands = REPLAY_COMMAND_ORDER.map(
|
||||
@ -94,213 +76,271 @@ describe('replay commands', () => {
|
||||
const secondCommands = REPLAY_COMMAND_ORDER.map(
|
||||
code => second.replaySystem.getCommand(code)!
|
||||
);
|
||||
expect(first.replaySystem).not.toBe(second.replaySystem);
|
||||
expect(first.pathfinding).not.toBe(second.pathfinding);
|
||||
expect(firstCommands).toHaveLength(8);
|
||||
expect(secondCommands).toHaveLength(8);
|
||||
expect(new Set(firstCommands).size).toBe(8);
|
||||
expect(new Set(secondCommands).size).toBe(8);
|
||||
expect(
|
||||
REPLAY_COMMAND_ORDER.every(
|
||||
code => first.replaySystem.getCommand(code) !== null
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
REPLAY_COMMAND_ORDER.every(
|
||||
code => second.replaySystem.getCommand(code) !== null
|
||||
)
|
||||
).toBe(true);
|
||||
expect(first.replaySystem.route).not.toBe(second.replaySystem.route);
|
||||
for (let index = 0; index < firstCommands.length; index++) {
|
||||
expect(firstCommands[index]).not.toBe(secondCommands[index]);
|
||||
}
|
||||
});
|
||||
|
||||
// 验证现有 command item 扩展边界可以替换单个实现而不改变注册器
|
||||
it('registers an existing custom command item through the current interface', () => {
|
||||
const state = createCoreState();
|
||||
const replay = new ReplaySystem();
|
||||
const defaultItems = createReplayCommandItems(state);
|
||||
const customItem: IReplayCommandItem = {
|
||||
code: ReplayCommandCode.Up,
|
||||
command: {
|
||||
execute: () => Promise.resolve(true)
|
||||
}
|
||||
};
|
||||
registerReplayCommandItems(replay, [
|
||||
customItem,
|
||||
...defaultItems.slice(1)
|
||||
expect(firstCommands.map(command => command.constructor.name)).toEqual([
|
||||
'ReplayMoveCommand',
|
||||
'ReplayMoveCommand',
|
||||
'ReplayMoveCommand',
|
||||
'ReplayMoveCommand',
|
||||
'ReplayTeleportCommand',
|
||||
'ReplayUseItemCommand',
|
||||
'ReplayEquipCommand',
|
||||
'ReplayUnequipCommand'
|
||||
]);
|
||||
expect(replay.getCommand(ReplayCommandCode.Up)).toBe(
|
||||
customItem.command
|
||||
);
|
||||
expect(replay.getCommand(ReplayCommandCode.Right)).toBe(
|
||||
defaultItems[1].command
|
||||
);
|
||||
});
|
||||
|
||||
// 验证稳定 registry 使用直接构造且不依赖手工 formatter 抑制
|
||||
it('keeps registry construction direct and formatter-normalized', () => {
|
||||
const source = readFileSync(
|
||||
new URL('./commands.ts', import.meta.url),
|
||||
'utf8'
|
||||
);
|
||||
expect(source).not.toContain('prettier-ignore');
|
||||
expect(source).toContain(
|
||||
'command: new ReplayDirectionCommand(state, FaceDirection.Up)'
|
||||
);
|
||||
expect(source).toContain(
|
||||
'command: new ReplayAutoPathfindCommand(state)'
|
||||
);
|
||||
expect(source).toContain('command: new ReplayUseItemCommand(state)');
|
||||
expect(source).toContain('command: new ReplayEquipCommand(state)');
|
||||
expect(source).toContain('command: new ReplayUnequipCommand(state)');
|
||||
});
|
||||
|
||||
// 验证四向移动等待 controller.onEnd 后才完成 command 并进入下一步
|
||||
it('awaits directional movement before the next replay step', async () => {
|
||||
// 验证移动指令只加入方向步,由 notExecuted 统一启动并等待
|
||||
it('steps direction without starting and finalizes through notExecuted', async () => {
|
||||
const state = createCoreState();
|
||||
const items = createReplayCommandItems(state);
|
||||
const first = Promise.withResolvers<void>();
|
||||
const second = Promise.withResolvers<void>();
|
||||
const mover = state.hero.location.mover;
|
||||
const move = vi.spyOn(mover, 'step');
|
||||
const first = Promise.withResolvers<void>();
|
||||
const start = vi
|
||||
.spyOn(mover, 'start')
|
||||
.mockReturnValueOnce(controller(first.promise))
|
||||
.mockReturnValueOnce(controller(second.promise));
|
||||
const replay = new ReplaySystem();
|
||||
registerReplayCommandItems(replay, items);
|
||||
replay.record(ReplayCommandCode.Right);
|
||||
replay.record(ReplayCommandCode.Right);
|
||||
const sandbox = replay.createReplaySandbox({
|
||||
route: replay.route,
|
||||
reseter: { reset: () => {} }
|
||||
}) as IManualReplaySandbox;
|
||||
sandbox.playing = true;
|
||||
sandbox.pausing = false;
|
||||
const result = sandbox.step();
|
||||
.mockReturnValueOnce(controller(first.promise));
|
||||
const command = new ReplayMoveCommand(state, FaceDirection.Right);
|
||||
|
||||
await expect(
|
||||
command.execute(step(ReplayCommandCode.Right, []))
|
||||
).resolves.toBe(true);
|
||||
expect(move).toHaveBeenCalledWith(FaceDirection.Right);
|
||||
expect(start).toHaveBeenCalledTimes(1);
|
||||
expect(start).not.toHaveBeenCalled();
|
||||
|
||||
let result: boolean | undefined;
|
||||
const pending = command.notExecuted().then(value => {
|
||||
result = value;
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(start).toHaveBeenCalledTimes(1);
|
||||
expect(result).toBeUndefined();
|
||||
first.resolve();
|
||||
await expect(result).resolves.toBe(true);
|
||||
const next = sandbox.step();
|
||||
await Promise.resolve();
|
||||
expect(start).toHaveBeenCalledTimes(2);
|
||||
second.resolve();
|
||||
await expect(next).resolves.toBe(true);
|
||||
await pending;
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
// 验证自动寻路等待 PathfindingSystem 返回的 controller 后才进入下一步
|
||||
it('awaits pathfinding before the next replay step', async () => {
|
||||
// 验证移动已在进行中或无法启动时返回 false 并记录错误码
|
||||
it('rejects a move while moving and a missing controller', async () => {
|
||||
const state = createCoreState();
|
||||
const error = vi.spyOn(logger, 'error');
|
||||
const mover = state.hero.location.mover;
|
||||
(mover as unknown as { moving: boolean }).moving = true;
|
||||
const command = new ReplayMoveCommand(state, FaceDirection.Up);
|
||||
await expect(
|
||||
command.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(command.notExecuted()).resolves.toBe(false);
|
||||
expect(error).toHaveBeenCalledWith(2004);
|
||||
error.mockRestore();
|
||||
});
|
||||
|
||||
// 验证瞬移等待寻路控制器完成,无路径时返回 false
|
||||
it('teleports and awaits the pathfinding controller', async () => {
|
||||
const state = createCoreState();
|
||||
const items = createReplayCommandItems(state);
|
||||
const first = Promise.withResolvers<void>();
|
||||
const second = Promise.withResolvers<void>();
|
||||
const moveTo = vi
|
||||
.spyOn(state.pathfinding, 'moveTo')
|
||||
const teleport = vi
|
||||
.spyOn(state.pathfinding, 'teleportTo')
|
||||
.mockReturnValueOnce({
|
||||
controller: controller(first.promise),
|
||||
path: []
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
controller: controller(second.promise),
|
||||
path: []
|
||||
});
|
||||
const replay = new ReplaySystem();
|
||||
registerReplayCommandItems(replay, items);
|
||||
replay.record(ReplayCommandCode.AutoPathfindToPoint, 2, 3);
|
||||
replay.record(ReplayCommandCode.AutoPathfindToPoint, 4, 5);
|
||||
const sandbox = replay.createReplaySandbox({
|
||||
route: replay.route,
|
||||
reseter: { reset: () => {} }
|
||||
}) as IManualReplaySandbox;
|
||||
sandbox.playing = true;
|
||||
sandbox.pausing = false;
|
||||
const result = sandbox.step();
|
||||
expect(moveTo).toHaveBeenCalledWith({ x: 2, y: 3 });
|
||||
expect(moveTo).toHaveBeenCalledTimes(1);
|
||||
const command = new ReplayTeleportCommand(state);
|
||||
|
||||
let result: boolean | undefined;
|
||||
const pending = command
|
||||
.execute(step(ReplayCommandCode.AutoPathfindToPoint, [2, 3]))
|
||||
.then(value => {
|
||||
result = value;
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(moveTo).toHaveBeenCalledTimes(1);
|
||||
expect(teleport).toHaveBeenCalledWith({ x: 2, y: 3 });
|
||||
expect(result).toBeUndefined();
|
||||
first.resolve();
|
||||
await expect(result).resolves.toBe(true);
|
||||
const next = sandbox.step();
|
||||
await Promise.resolve();
|
||||
expect(moveTo).toHaveBeenCalledWith({ x: 4, y: 5 });
|
||||
expect(moveTo).toHaveBeenCalledTimes(2);
|
||||
second.resolve();
|
||||
await expect(next).resolves.toBe(true);
|
||||
moveTo.mockReturnValue(null);
|
||||
await pending;
|
||||
expect(result).toBe(true);
|
||||
|
||||
const error = vi.spyOn(logger, 'error');
|
||||
teleport.mockReturnValueOnce(null);
|
||||
await expect(
|
||||
items[ReplayCommandCode.AutoPathfindToPoint].command.execute(
|
||||
step(ReplayCommandCode.AutoPathfindToPoint, [2, 3])
|
||||
)
|
||||
command.execute(step(ReplayCommandCode.AutoPathfindToPoint, [4, 5]))
|
||||
).resolves.toBe(false);
|
||||
expect(error).toHaveBeenCalledWith(2005, '4', '5');
|
||||
error.mockRestore();
|
||||
});
|
||||
|
||||
// 验证道具和装备 command 使用既有状态 API 并把失败结果返回给 replay
|
||||
it('returns the existing item and equipment action results', async () => {
|
||||
// 验证道具使用直接返回既有状态接口结果,失败时记录错误码
|
||||
it('returns the hero item-use result', async () => {
|
||||
const state = createCoreState();
|
||||
const items = createReplayCommandItems(state);
|
||||
const error = vi.spyOn(logger, 'error');
|
||||
const useItem = vi
|
||||
.spyOn(state.hero.items, 'useItem')
|
||||
.mockReturnValueOnce(true)
|
||||
.mockReturnValueOnce(false);
|
||||
const command = new ReplayUseItemCommand(state);
|
||||
|
||||
await expect(
|
||||
items[ReplayCommandCode.UseItem].command.execute(
|
||||
step(ReplayCommandCode.UseItem, [12])
|
||||
)
|
||||
command.execute(step(ReplayCommandCode.UseItem, [12]))
|
||||
).resolves.toBe(true);
|
||||
await expect(
|
||||
items[ReplayCommandCode.UseItem].command.execute(
|
||||
step(ReplayCommandCode.UseItem, ['unknown'])
|
||||
)
|
||||
command.execute(step(ReplayCommandCode.UseItem, [34]))
|
||||
).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();
|
||||
expect(error).toHaveBeenCalledWith(2006, '34');
|
||||
error.mockRestore();
|
||||
});
|
||||
|
||||
// 验证所有 command 对无效参数都以 false 结束而不推进状态
|
||||
// 验证装备指令复用既有装备边界并区分三种失败位置
|
||||
it('equips through the existing equipment boundary', async () => {
|
||||
const state = createCoreState();
|
||||
const equipment = state.hero.equip;
|
||||
const error = vi.spyOn(logger, 'error');
|
||||
const getEquipped = vi.spyOn(equipment, 'getEquipped');
|
||||
const canEquipTo = vi
|
||||
.spyOn(equipment, 'canEquipTo')
|
||||
.mockReturnValue(EquipStatus.CanEquip);
|
||||
const equip = vi
|
||||
.spyOn(equipment, 'equip')
|
||||
.mockImplementation(() => undefined);
|
||||
const command = new ReplayEquipCommand(state);
|
||||
|
||||
// 已经装备在目标槽位
|
||||
getEquipped.mockReturnValueOnce(99);
|
||||
await expect(
|
||||
command.execute(step(ReplayCommandCode.Equip, [99, 0, true]))
|
||||
).resolves.toBe(true);
|
||||
expect(canEquipTo).not.toHaveBeenCalled();
|
||||
|
||||
// 正常装备并校验结果
|
||||
getEquipped.mockReturnValueOnce(undefined).mockReturnValueOnce(99);
|
||||
await expect(
|
||||
command.execute(step(ReplayCommandCode.Equip, [99, 1, false]))
|
||||
).resolves.toBe(true);
|
||||
expect(canEquipTo).toHaveBeenCalledWith(99, 1);
|
||||
expect(equip).toHaveBeenCalledWith(99, 1, false);
|
||||
|
||||
// 无法装备
|
||||
getEquipped.mockReturnValueOnce(undefined);
|
||||
canEquipTo.mockReturnValueOnce(EquipStatus.CannotEquip);
|
||||
await expect(
|
||||
command.execute(step(ReplayCommandCode.Equip, [99, 2, true]))
|
||||
).resolves.toBe(false);
|
||||
expect(error).toHaveBeenCalledWith(2007, '99', '2');
|
||||
|
||||
// 装备未生效
|
||||
getEquipped
|
||||
.mockReturnValueOnce(undefined)
|
||||
.mockReturnValueOnce(undefined);
|
||||
canEquipTo.mockReturnValueOnce(EquipStatus.CanEquip);
|
||||
await expect(
|
||||
command.execute(step(ReplayCommandCode.Equip, [99, 3, true]))
|
||||
).resolves.toBe(false);
|
||||
expect(error).toHaveBeenCalledWith(2008, '99', '3');
|
||||
|
||||
error.mockRestore();
|
||||
});
|
||||
|
||||
// 验证卸下指令区分未装备与未生效两种失败位置
|
||||
it('unequips through the existing equipment boundary', async () => {
|
||||
const state = createCoreState();
|
||||
const equipment = state.hero.equip;
|
||||
const error = vi.spyOn(logger, 'error');
|
||||
const getEquipped = vi.spyOn(equipment, 'getEquipped');
|
||||
const unequip = vi
|
||||
.spyOn(equipment, 'unequip')
|
||||
.mockImplementation(() => undefined);
|
||||
const command = new ReplayUnequipCommand(state);
|
||||
|
||||
// 目标槽位本来就没有装备
|
||||
getEquipped.mockReturnValueOnce(undefined);
|
||||
await expect(
|
||||
command.execute(step(ReplayCommandCode.Unequip, [0]))
|
||||
).resolves.toBe(false);
|
||||
expect(error).toHaveBeenCalledWith(2009, '0');
|
||||
expect(unequip).not.toHaveBeenCalled();
|
||||
|
||||
// 正常卸下并校验槽位已清空
|
||||
getEquipped.mockReturnValueOnce(88).mockReturnValueOnce(undefined);
|
||||
await expect(
|
||||
command.execute(step(ReplayCommandCode.Unequip, [1]))
|
||||
).resolves.toBe(true);
|
||||
expect(unequip).toHaveBeenCalledWith(1);
|
||||
|
||||
// 卸下未生效
|
||||
getEquipped.mockReturnValueOnce(88).mockReturnValueOnce(88);
|
||||
await expect(
|
||||
command.execute(step(ReplayCommandCode.Unequip, [2]))
|
||||
).resolves.toBe(false);
|
||||
expect(error).toHaveBeenCalledWith(2010, '2');
|
||||
|
||||
error.mockRestore();
|
||||
});
|
||||
|
||||
// 验证沙箱在下一步指令前先结束上一步的移动批次
|
||||
it('finalizes the previous move before executing a different command', async () => {
|
||||
const state = createCoreState();
|
||||
const mover = state.hero.location.mover;
|
||||
const move = vi.spyOn(mover, 'step');
|
||||
const first = Promise.withResolvers<void>();
|
||||
const start = vi
|
||||
.spyOn(mover, 'start')
|
||||
.mockReturnValueOnce(controller(first.promise));
|
||||
const useItem = vi
|
||||
.spyOn(state.hero.items, 'useItem')
|
||||
.mockReturnValueOnce(true);
|
||||
const replay = state.replaySystem;
|
||||
replay.record(ReplayCommandCode.Right);
|
||||
replay.record(ReplayCommandCode.UseItem, 5);
|
||||
const sandbox = replay.createReplaySandbox({
|
||||
route: replay.route,
|
||||
reseter: { reset: () => {} }
|
||||
}) as IManualReplaySandbox;
|
||||
sandbox.playing = true;
|
||||
sandbox.pausing = false;
|
||||
|
||||
await expect(sandbox.step()).resolves.toBe(true);
|
||||
expect(move).toHaveBeenCalledWith(FaceDirection.Right);
|
||||
expect(start).not.toHaveBeenCalled();
|
||||
|
||||
const next = sandbox.step();
|
||||
await Promise.resolve();
|
||||
expect(start).toHaveBeenCalledTimes(1);
|
||||
expect(useItem).not.toHaveBeenCalled();
|
||||
first.resolve();
|
||||
await expect(next).resolves.toBe(true);
|
||||
expect(useItem).toHaveBeenCalledWith(5);
|
||||
});
|
||||
|
||||
// 验证参数数量或类型不符的指令以 false 结束
|
||||
it('returns false for invalid command parameters', async () => {
|
||||
const state = createCoreState();
|
||||
const items = createReplayCommandItems(state);
|
||||
const move = new ReplayMoveCommand(state, FaceDirection.Up);
|
||||
const teleport = new ReplayTeleportCommand(state);
|
||||
const useItem = new ReplayUseItemCommand(state);
|
||||
const equip = new ReplayEquipCommand(state);
|
||||
const unequip = new ReplayUnequipCommand(state);
|
||||
const invalid = [
|
||||
items[ReplayCommandCode.Up].command.execute(step(0, [1])),
|
||||
items[ReplayCommandCode.AutoPathfindToPoint].command.execute(
|
||||
step(4, ['x', 1])
|
||||
move.execute(step(ReplayCommandCode.Up, [1])),
|
||||
teleport.execute(
|
||||
step(ReplayCommandCode.AutoPathfindToPoint, ['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']))
|
||||
teleport.execute(step(ReplayCommandCode.AutoPathfindToPoint, [1])),
|
||||
useItem.execute(step(ReplayCommandCode.UseItem, [])),
|
||||
useItem.execute(step(ReplayCommandCode.UseItem, ['id'])),
|
||||
equip.execute(step(ReplayCommandCode.Equip, [1, 0])),
|
||||
equip.execute(step(ReplayCommandCode.Equip, [1, 0, 'x'])),
|
||||
unequip.execute(step(ReplayCommandCode.Unequip, ['slot']))
|
||||
];
|
||||
await expect(Promise.all(invalid)).resolves.toEqual([
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
@ -309,40 +349,33 @@ describe('replay commands', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
// 验证生产 command 不拥有 replay safety helper 且纯查询不制造安全记录
|
||||
it('keeps replay safety ownership below production commands', async () => {
|
||||
const state = createCoreState();
|
||||
const replay = new ReplaySystem();
|
||||
registerReplayCommandItems(replay, createReplayCommandItems(state));
|
||||
const source = readFileSync(
|
||||
// 验证注册直接写在 CoreState 内,且指令模块自包含、不含 replay safety
|
||||
it('keeps registration direct and the command module self-contained', () => {
|
||||
const commands = readFileSync(
|
||||
new URL('./commands.ts', import.meta.url),
|
||||
'utf8'
|
||||
);
|
||||
expect(source).not.toContain('shouldReplay');
|
||||
const getPath = vi
|
||||
.spyOn(state.pathfinding, 'getPath')
|
||||
.mockReturnValue([]);
|
||||
const warning = vi.spyOn(logger, 'warn');
|
||||
let ended = false;
|
||||
beginReplaySafetyCollection(replay);
|
||||
try {
|
||||
expect(state.pathfinding.getPath({ x: 1, y: 1 })).toEqual([]);
|
||||
await expect(
|
||||
replay
|
||||
.getCommand(ReplayCommandCode.UseItem)!
|
||||
.execute(step(ReplayCommandCode.UseItem, []))
|
||||
).resolves.toBe(false);
|
||||
endReplaySafetyCollection();
|
||||
ended = true;
|
||||
expect(commands).not.toContain('createReplayCommandItems');
|
||||
expect(commands).not.toContain('registerReplayCommandItems');
|
||||
expect(commands).not.toContain('prettier-ignore');
|
||||
expect(commands).not.toContain('function isNumber');
|
||||
expect(commands).not.toContain('function resolveSlot');
|
||||
expect(commands).not.toContain('shouldReplay');
|
||||
|
||||
expect(getPath).toHaveBeenCalledWith({ x: 1, y: 1 });
|
||||
expect(
|
||||
warning.mock.calls.filter(call => call[0] === 161)
|
||||
).toHaveLength(0);
|
||||
} finally {
|
||||
if (!ended) endReplaySafetyCollection();
|
||||
warning.mockRestore();
|
||||
}
|
||||
const core = readFileSync(
|
||||
new URL('../core.ts', import.meta.url),
|
||||
'utf8'
|
||||
);
|
||||
expect(core).not.toContain('createReplayCommandItems');
|
||||
expect(core).not.toContain('registerReplayCommandItems');
|
||||
expect(core).toContain('private registerReplayCommand()');
|
||||
expect((core.match(/new ReplayMoveCommand\(this,/g) ?? []).length).toBe(
|
||||
4
|
||||
);
|
||||
expect(core).toContain('new ReplayTeleportCommand(this)');
|
||||
expect(core).toContain('new ReplayUseItemCommand(this)');
|
||||
expect(core).toContain('new ReplayEquipCommand(this)');
|
||||
expect(core).toContain('new ReplayUnequipCommand(this)');
|
||||
});
|
||||
});
|
||||
|
||||
@ -426,53 +459,4 @@ describe('replay safety decorators', () => {
|
||||
);
|
||||
expect(source).not.toContain('shouldReplay');
|
||||
});
|
||||
|
||||
// 验证方向 command 共享一个参数化类且不存在旧的重复入口
|
||||
it('keeps directional command ownership parameterized', () => {
|
||||
const source = readFileSync(
|
||||
new URL('./commands.ts', import.meta.url),
|
||||
'utf8'
|
||||
);
|
||||
const classes = [
|
||||
'ReplayDirectionCommand',
|
||||
'ReplayAutoPathfindCommand',
|
||||
'ReplayUseItemCommand',
|
||||
'ReplayEquipCommand',
|
||||
'ReplayUnequipCommand'
|
||||
];
|
||||
expect(source).not.toContain('ReplayCommandEntrances');
|
||||
expect(source).not.toContain('createMoveCommand');
|
||||
expect(source).not.toMatch(/\bentries\./);
|
||||
expect(source).not.toMatch(
|
||||
/class\s+Replay(?:Up|Right|Down|Left)Command\b/
|
||||
);
|
||||
expect(
|
||||
(source.match(/new ReplayDirectionCommand\(state,/g) ?? []).length
|
||||
).toBe(4);
|
||||
expect(source).toContain(
|
||||
'new ReplayDirectionCommand(state, FaceDirection.Up)'
|
||||
);
|
||||
expect(source).toContain(
|
||||
'new ReplayDirectionCommand(state, FaceDirection.Right)'
|
||||
);
|
||||
expect(source).toContain(
|
||||
'new ReplayDirectionCommand(state, FaceDirection.Down)'
|
||||
);
|
||||
expect(source).toContain(
|
||||
'new ReplayDirectionCommand(state, FaceDirection.Left)'
|
||||
);
|
||||
for (const className of classes) {
|
||||
const body = source.match(
|
||||
new RegExp(
|
||||
`class\\s+${className}\\b[\\s\\S]*?(?=\\r?\\nclass\\s|\\r?\\nfunction\\s|\\r?\\nexport function\\s|\\r?\\n/\\*\\*/)`
|
||||
)
|
||||
)?.[0];
|
||||
expect(body).toBeDefined();
|
||||
expect(body).toMatch(/execute\s*\(/);
|
||||
for (const otherClass of classes) {
|
||||
if (otherClass === className) continue;
|
||||
expect(body).not.toContain(otherClass);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,63 +1,13 @@
|
||||
import {
|
||||
FaceDirection,
|
||||
IReplayStepHandler,
|
||||
IReplaySystem,
|
||||
IReplayCommand,
|
||||
ReplayParamValue
|
||||
} from '@user/data-common';
|
||||
import { EquipStatus } from '@user/data-base';
|
||||
import {
|
||||
IReplayCommandItem,
|
||||
IReplayCommandRegistry,
|
||||
IReplayCommandState,
|
||||
ReplayCommandCode,
|
||||
REPLAY_COMMAND_ORDER
|
||||
} from './types';
|
||||
import { IStateSystem } from '@user/data-system';
|
||||
import { logger } from '@motajs/common';
|
||||
|
||||
/**
|
||||
* 判断未知值是否为有限数值
|
||||
*/
|
||||
function isNumber(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isFinite(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断未知值是否为可用的道具编号或道具 id
|
||||
*/
|
||||
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';
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断未知值是否为可用的装备槽位编号或槽位 id
|
||||
*/
|
||||
function isSlot(value: unknown): value is number | string {
|
||||
return isNumber(value) || typeof value === 'string';
|
||||
}
|
||||
|
||||
/**
|
||||
* 将槽位编号或槽位 id 解析为装备槽位索引
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
//#region 指令基类
|
||||
|
||||
export abstract class BaseReplayCommand implements IReplayCommand {
|
||||
@ -187,24 +137,21 @@ export class ReplayTeleportCommand
|
||||
|
||||
//#region 使用物品指令
|
||||
|
||||
export class ReplayUseItemCommand implements IReplayCommand {
|
||||
constructor(private readonly state: IStateSystem) {}
|
||||
export class ReplayUseItemCommand
|
||||
extends BaseReplayCommand
|
||||
implements IReplayCommand
|
||||
{
|
||||
protected readonly name: string = 'use-item';
|
||||
protected readonly paramTypes: readonly string[] = ['number'];
|
||||
|
||||
/**
|
||||
* 使用指定道具并返回现有状态接口的结果
|
||||
*/
|
||||
private useItem(item: number | string): boolean {
|
||||
return this.state.hero.items.useItem(item);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验录像步参数并执行一次道具使用
|
||||
*/
|
||||
execute(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(this.useItem(item));
|
||||
async wrappedExecute(step: IReplayStepHandler): Promise<boolean> {
|
||||
// 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 true;
|
||||
}
|
||||
}
|
||||
|
||||
@ -212,47 +159,38 @@ export class ReplayUseItemCommand implements IReplayCommand {
|
||||
|
||||
//#region 装备指令
|
||||
|
||||
export class ReplayEquipCommand implements IReplayCommand {
|
||||
constructor(private readonly state: IStateSystem) {}
|
||||
export class ReplayEquipCommand
|
||||
extends BaseReplayCommand
|
||||
implements IReplayCommand
|
||||
{
|
||||
protected readonly name: string = 'equip';
|
||||
protected readonly paramTypes: readonly string[] = [
|
||||
'number',
|
||||
'number',
|
||||
'boolean'
|
||||
];
|
||||
|
||||
/**
|
||||
* 将指定装备穿到目标槽位并返回是否穿装成功
|
||||
*/
|
||||
private equip(
|
||||
uid: number,
|
||||
slot: number | string,
|
||||
autoUnload: boolean | undefined
|
||||
): boolean {
|
||||
async wrappedExecute(step: IReplayStepHandler): Promise<boolean> {
|
||||
// Parameter: [int16 uid, int8 slot, bool autoUnload]
|
||||
const [uid, slot, autoUnload] = step.params as [
|
||||
number,
|
||||
number,
|
||||
boolean
|
||||
];
|
||||
const equipment = this.state.hero.equip;
|
||||
const slotIndex = resolveSlot(this.state, slot);
|
||||
if (slotIndex === null) return false;
|
||||
if (equipment.getEquipped(slotIndex) === uid) return true;
|
||||
if (equipment.getEquipped(slot) === uid) {
|
||||
return true;
|
||||
}
|
||||
if (equipment.canEquipTo(uid, slot) === EquipStatus.CannotEquip) {
|
||||
logger.error(2007, uid.toString(), slot.toString());
|
||||
return false;
|
||||
}
|
||||
equipment.equip(uid, slot, autoUnload);
|
||||
return equipment.getEquipped(slotIndex) === uid;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验录像步参数并执行一次装备穿装
|
||||
*/
|
||||
execute(step: IReplayStepHandler): Promise<boolean> {
|
||||
if (step.params.length < 2 || step.params.length > 3) {
|
||||
return Promise.resolve(false);
|
||||
if (equipment.getEquipped(slot) !== uid) {
|
||||
logger.error(2008, uid.toString(), slot.toString());
|
||||
return 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(this.state, slot);
|
||||
if (slotIndex === null) return Promise.resolve(false);
|
||||
return Promise.resolve(this.equip(uid, slot, autoUnload));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@ -260,105 +198,28 @@ export class ReplayEquipCommand implements IReplayCommand {
|
||||
|
||||
//#region 卸下装备指令
|
||||
|
||||
export class ReplayUnequipCommand implements IReplayCommand {
|
||||
constructor(private readonly state: IStateSystem) {}
|
||||
export class ReplayUnequipCommand
|
||||
extends BaseReplayCommand
|
||||
implements IReplayCommand
|
||||
{
|
||||
protected readonly name: string = 'unequip';
|
||||
protected readonly paramTypes: readonly string[] = ['number'];
|
||||
|
||||
/**
|
||||
* 卸下指定槽位的装备并返回是否卸下成功
|
||||
*/
|
||||
private unequip(slot: number): boolean {
|
||||
async wrappedExecute(step: IReplayStepHandler): Promise<boolean> {
|
||||
// Parameter: [int8 slot]
|
||||
const slot = step.params[0] as number;
|
||||
const equipment = this.state.hero.equip;
|
||||
if (equipment.getEquipped(slot) === undefined) return false;
|
||||
equipment.unequip(slot);
|
||||
return equipment.getEquipped(slot) === undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验录像步参数并执行一次装备卸下
|
||||
*/
|
||||
execute(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 (equipment.getEquipped(slot) === undefined) {
|
||||
logger.error(2009, slot.toString());
|
||||
return false;
|
||||
}
|
||||
return Promise.resolve(this.unequip(slot));
|
||||
equipment.unequip(slot);
|
||||
if (equipment.getEquipped(slot) !== undefined) {
|
||||
logger.error(2010, slot.toString());
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
/**
|
||||
* 创建按稳定 enum 顺序排列的默认 replay command items
|
||||
*/
|
||||
export function createReplayCommandItems(
|
||||
state: IStateSystem
|
||||
): ReadonlyArray<IReplayCommandItem> {
|
||||
return [
|
||||
{
|
||||
code: ReplayCommandCode.Up,
|
||||
command: new ReplayMoveCommand(state, FaceDirection.Up)
|
||||
},
|
||||
{
|
||||
code: ReplayCommandCode.Right,
|
||||
command: new ReplayMoveCommand(state, FaceDirection.Right)
|
||||
},
|
||||
{
|
||||
code: ReplayCommandCode.Down,
|
||||
command: new ReplayMoveCommand(state, FaceDirection.Down)
|
||||
},
|
||||
{
|
||||
code: ReplayCommandCode.Left,
|
||||
command: new ReplayMoveCommand(state, FaceDirection.Left)
|
||||
},
|
||||
{
|
||||
code: ReplayCommandCode.AutoPathfindToPoint,
|
||||
command: new ReplayTeleportCommand(state)
|
||||
},
|
||||
{
|
||||
code: ReplayCommandCode.UseItem,
|
||||
command: new ReplayUseItemCommand(state)
|
||||
},
|
||||
{
|
||||
code: ReplayCommandCode.Equip,
|
||||
command: new ReplayEquipCommand(state)
|
||||
},
|
||||
{
|
||||
code: ReplayCommandCode.Unequip,
|
||||
command: new ReplayUnequipCommand(state)
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,3 @@
|
||||
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 {
|
||||
/** 向上移动一步 */
|
||||
@ -22,67 +18,7 @@ export const enum ReplayCommandCode {
|
||||
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,
|
||||
@ -93,15 +29,3 @@ export const REPLAY_COMMAND_ORDER: readonly ReplayCommandCode[] = [
|
||||
ReplayCommandCode.Equip,
|
||||
ReplayCommandCode.Unequip
|
||||
];
|
||||
|
||||
/** 顶层注册器使用的重放系统最小边界 */
|
||||
export interface IReplayCommandRegistry {
|
||||
/** 注册录像指令 */
|
||||
registerCommand(code: number, command: IReplayCommand): void;
|
||||
|
||||
/** 查询录像指令 */
|
||||
getCommand(code: number): IReplayCommand | null;
|
||||
}
|
||||
|
||||
/** command 参数读取结果 */
|
||||
export type ReplayCommandParam = ReplayParamValue;
|
||||
|
||||
@ -69,7 +69,12 @@
|
||||
"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."
|
||||
"2005": "Replay(teleport): Cannot find a way to target position: $1,$2.",
|
||||
"2006": "Replay(use-item): Failed to use item number $1.",
|
||||
"2007": "Replay(equip): Cannot equip uid $1 into slot $2.",
|
||||
"2008": "Replay(equip): Equipping uid $1 into slot $2 did not take effect.",
|
||||
"2009": "Replay(unequip): No equipment in slot $1.",
|
||||
"2010": "Replay(unequip): Unequipping slot $1 did not take effect."
|
||||
},
|
||||
"warn": {
|
||||
"1": "Resource with type of 'none' is loaded.",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user