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:
unanmed 2026-09-13 15:39:12 +08:00
parent 37bfb77301
commit dd4e66a335
6 changed files with 398 additions and 569 deletions

View File

@ -126,22 +126,18 @@ export class ReplaySandbox
} }
const next = this.reader.read(); const next = this.reader.read();
if (!next) { if (!next) {
// notExecuted
if (!(await this.finalizeLast())) {
return false;
}
this.last = -1;
this.ending = true; this.ending = true;
return false; return false;
} }
// notExecuted // notExecuted
if (this.last !== -1) { if (!(await this.finalizeLast())) {
const last = this.system.getCommand(this.last); return false;
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; this.last = next.command;
@ -166,4 +162,22 @@ export class ReplaySandbox
return true; 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;
}
} }

View File

@ -82,7 +82,14 @@ import {
import { isNil } from 'lodash-es'; import { isNil } from 'lodash-es';
import { DefaultHeroMoveTopImpl } from './hero'; import { DefaultHeroMoveTopImpl } from './hero';
import { createEventBuiltinRegistrations } from './event/registrations'; import { createEventBuiltinRegistrations } from './event/registrations';
import { createReplayCommandItems, registerReplayCommandItems } from './replay'; import {
ReplayCommandCode,
ReplayEquipCommand,
ReplayMoveCommand,
ReplayTeleportCommand,
ReplayUnequipCommand,
ReplayUseItemCommand
} from './replay';
export class CoreState implements ICoreState { export class CoreState implements ICoreState {
// Layer 0 公共层,最底层的接口,不会依赖任何其他内容,一般是工具性接口及不需要存档的数据 // Layer 0 公共层,最底层的接口,不会依赖任何其他内容,一般是工具性接口及不需要存档的数据
@ -252,18 +259,52 @@ export class CoreState implements ICoreState {
pathfinding.useMover(this.hero.location.mover); pathfinding.useMover(this.hero.location.mover);
this.pathfinding = pathfinding; this.pathfinding = pathfinding;
const replaySystem = new ReplaySystem(); this.replaySystem = new ReplaySystem();
registerReplayCommandItems( this.registerReplayCommand();
replaySystem,
createReplayCommandItems(this)
);
this.replaySystem = replaySystem;
//#endregion //#endregion
} }
//#region 私有方法 //#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 旧样板图块定义对象 * @param data 旧样板图块定义对象

View File

@ -15,14 +15,13 @@ import { describe, expect, it, vi } from 'vitest';
import { createCoreState } from '../core'; import { createCoreState } from '../core';
import { ReplaySystem } from '../../../data-common/src/replay/system'; import { ReplaySystem } from '../../../data-common/src/replay/system';
import { import {
createReplayCommandItems, ReplayEquipCommand,
registerReplayCommandItems ReplayMoveCommand,
ReplayTeleportCommand,
ReplayUnequipCommand,
ReplayUseItemCommand
} from './commands'; } from './commands';
import { import { ReplayCommandCode, REPLAY_COMMAND_ORDER } from './types';
IReplayCommandItem,
ReplayCommandCode,
REPLAY_COMMAND_ORDER
} from './types';
function step( function step(
command: number, command: number,
@ -47,45 +46,28 @@ interface IManualReplaySandbox extends IReplaySandbox {
} }
describe('replay commands', () => { describe('replay commands', () => {
// 验证默认 command item 只按稳定 enum 顺序提供八个实现 // 验证每个 CoreState 都按稳定顺序装配八个指令
it('creates the approved command order without module-owned numbering', () => { it('registers the eight stable commands in order on every CoreState', () => {
const state = createCoreState(); const state = createCoreState();
const items = createReplayCommandItems(state); expect(REPLAY_COMMAND_ORDER).toEqual([
expect(items.map(item => item.code)).toEqual(REPLAY_COMMAND_ORDER); ReplayCommandCode.Up,
expect(items).toHaveLength(8); ReplayCommandCode.Right,
expect(items.map(item => item.command.execute)).toHaveLength(8); ReplayCommandCode.Down,
expect(items.map(item => item.command.constructor.name)).toEqual([ ReplayCommandCode.Left,
'ReplayDirectionCommand', ReplayCommandCode.AutoPathfindToPoint,
'ReplayDirectionCommand', ReplayCommandCode.UseItem,
'ReplayDirectionCommand', ReplayCommandCode.Equip,
'ReplayDirectionCommand', ReplayCommandCode.Unequip
'ReplayAutoPathfindCommand',
'ReplayUseItemCommand',
'ReplayEquipCommand',
'ReplayUnequipCommand'
]); ]);
});
// 验证顶层注册器按稳定顺序注册并拒绝重复 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( expect(
REPLAY_COMMAND_ORDER.every(code => replay.getCommand(code)) REPLAY_COMMAND_ORDER.every(code =>
state.replaySystem.getCommand(code)
)
).toBe(true); ).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 与寻路访问边界 // 验证每个 CoreState 独立装配,且四向共用一个参数化移动类
it('assembles an independent top-level registry for every CoreState', () => { it('assembles an independent command set per CoreState', () => {
const first = createCoreState(); const first = createCoreState();
const second = createCoreState(); const second = createCoreState();
const firstCommands = REPLAY_COMMAND_ORDER.map( const firstCommands = REPLAY_COMMAND_ORDER.map(
@ -94,213 +76,271 @@ describe('replay commands', () => {
const secondCommands = REPLAY_COMMAND_ORDER.map( const secondCommands = REPLAY_COMMAND_ORDER.map(
code => second.replaySystem.getCommand(code)! 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(firstCommands).size).toBe(8);
expect(new Set(secondCommands).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++) { for (let index = 0; index < firstCommands.length; index++) {
expect(firstCommands[index]).not.toBe(secondCommands[index]); expect(firstCommands[index]).not.toBe(secondCommands[index]);
} }
}); expect(firstCommands.map(command => command.constructor.name)).toEqual([
'ReplayMoveCommand',
// 验证现有 command item 扩展边界可以替换单个实现而不改变注册器 'ReplayMoveCommand',
it('registers an existing custom command item through the current interface', () => { 'ReplayMoveCommand',
const state = createCoreState(); 'ReplayMoveCommand',
const replay = new ReplaySystem(); 'ReplayTeleportCommand',
const defaultItems = createReplayCommandItems(state); 'ReplayUseItemCommand',
const customItem: IReplayCommandItem = { 'ReplayEquipCommand',
code: ReplayCommandCode.Up, 'ReplayUnequipCommand'
command: {
execute: () => Promise.resolve(true)
}
};
registerReplayCommandItems(replay, [
customItem,
...defaultItems.slice(1)
]); ]);
expect(replay.getCommand(ReplayCommandCode.Up)).toBe(
customItem.command
);
expect(replay.getCommand(ReplayCommandCode.Right)).toBe(
defaultItems[1].command
);
}); });
// 验证稳定 registry 使用直接构造且不依赖手工 formatter 抑制 // 验证移动指令只加入方向步,由 notExecuted 统一启动并等待
it('keeps registry construction direct and formatter-normalized', () => { it('steps direction without starting and finalizes through notExecuted', async () => {
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 () => {
const state = createCoreState(); const state = createCoreState();
const items = createReplayCommandItems(state);
const first = Promise.withResolvers<void>();
const second = Promise.withResolvers<void>();
const mover = state.hero.location.mover; const mover = state.hero.location.mover;
const move = vi.spyOn(mover, 'step'); const move = vi.spyOn(mover, 'step');
const first = Promise.withResolvers<void>();
const start = vi const start = vi
.spyOn(mover, 'start') .spyOn(mover, 'start')
.mockReturnValueOnce(controller(first.promise)) .mockReturnValueOnce(controller(first.promise));
.mockReturnValueOnce(controller(second.promise)); const command = new ReplayMoveCommand(state, FaceDirection.Right);
const replay = new ReplaySystem();
registerReplayCommandItems(replay, items); await expect(
replay.record(ReplayCommandCode.Right); command.execute(step(ReplayCommandCode.Right, []))
replay.record(ReplayCommandCode.Right); ).resolves.toBe(true);
const sandbox = replay.createReplaySandbox({
route: replay.route,
reseter: { reset: () => {} }
}) as IManualReplaySandbox;
sandbox.playing = true;
sandbox.pausing = false;
const result = sandbox.step();
expect(move).toHaveBeenCalledWith(FaceDirection.Right); 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(); await Promise.resolve();
expect(start).toHaveBeenCalledTimes(1); expect(start).toHaveBeenCalledTimes(1);
expect(result).toBeUndefined();
first.resolve(); first.resolve();
await expect(result).resolves.toBe(true); await pending;
const next = sandbox.step(); expect(result).toBe(true);
await Promise.resolve();
expect(start).toHaveBeenCalledTimes(2);
second.resolve();
await expect(next).resolves.toBe(true);
}); });
// 验证自动寻路等待 PathfindingSystem 返回的 controller 后才进入下一步 // 验证移动已在进行中或无法启动时返回 false 并记录错误码
it('awaits pathfinding before the next replay step', async () => { 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 state = createCoreState();
const items = createReplayCommandItems(state);
const first = Promise.withResolvers<void>(); const first = Promise.withResolvers<void>();
const second = Promise.withResolvers<void>(); const teleport = vi
const moveTo = vi .spyOn(state.pathfinding, 'teleportTo')
.spyOn(state.pathfinding, 'moveTo')
.mockReturnValueOnce({ .mockReturnValueOnce({
controller: controller(first.promise), controller: controller(first.promise),
path: [] path: []
})
.mockReturnValueOnce({
controller: controller(second.promise),
path: []
}); });
const replay = new ReplaySystem(); const command = new ReplayTeleportCommand(state);
registerReplayCommandItems(replay, items);
replay.record(ReplayCommandCode.AutoPathfindToPoint, 2, 3); let result: boolean | undefined;
replay.record(ReplayCommandCode.AutoPathfindToPoint, 4, 5); const pending = command
const sandbox = replay.createReplaySandbox({ .execute(step(ReplayCommandCode.AutoPathfindToPoint, [2, 3]))
route: replay.route, .then(value => {
reseter: { reset: () => {} } result = value;
}) as IManualReplaySandbox; });
sandbox.playing = true;
sandbox.pausing = false;
const result = sandbox.step();
expect(moveTo).toHaveBeenCalledWith({ x: 2, y: 3 });
expect(moveTo).toHaveBeenCalledTimes(1);
await Promise.resolve(); await Promise.resolve();
expect(moveTo).toHaveBeenCalledTimes(1); expect(teleport).toHaveBeenCalledWith({ x: 2, y: 3 });
expect(result).toBeUndefined();
first.resolve(); first.resolve();
await expect(result).resolves.toBe(true); await pending;
const next = sandbox.step(); expect(result).toBe(true);
await Promise.resolve();
expect(moveTo).toHaveBeenCalledWith({ x: 4, y: 5 }); const error = vi.spyOn(logger, 'error');
expect(moveTo).toHaveBeenCalledTimes(2); teleport.mockReturnValueOnce(null);
second.resolve();
await expect(next).resolves.toBe(true);
moveTo.mockReturnValue(null);
await expect( await expect(
items[ReplayCommandCode.AutoPathfindToPoint].command.execute( command.execute(step(ReplayCommandCode.AutoPathfindToPoint, [4, 5]))
step(ReplayCommandCode.AutoPathfindToPoint, [2, 3])
)
).resolves.toBe(false); ).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 state = createCoreState();
const items = createReplayCommandItems(state); const error = vi.spyOn(logger, 'error');
const useItem = vi const useItem = vi
.spyOn(state.hero.items, 'useItem') .spyOn(state.hero.items, 'useItem')
.mockReturnValueOnce(true) .mockReturnValueOnce(true)
.mockReturnValueOnce(false); .mockReturnValueOnce(false);
const command = new ReplayUseItemCommand(state);
await expect( await expect(
items[ReplayCommandCode.UseItem].command.execute( command.execute(step(ReplayCommandCode.UseItem, [12]))
step(ReplayCommandCode.UseItem, [12])
)
).resolves.toBe(true); ).resolves.toBe(true);
await expect( await expect(
items[ReplayCommandCode.UseItem].command.execute( command.execute(step(ReplayCommandCode.UseItem, [34]))
step(ReplayCommandCode.UseItem, ['unknown'])
)
).resolves.toBe(false); ).resolves.toBe(false);
expect(useItem).toHaveBeenNthCalledWith(1, 12); expect(useItem).toHaveBeenNthCalledWith(1, 12);
expect(error).toHaveBeenCalledWith(2006, '34');
const canEquipTo = vi error.mockRestore();
.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('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 () => { it('returns false for invalid command parameters', async () => {
const state = createCoreState(); 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 = [ const invalid = [
items[ReplayCommandCode.Up].command.execute(step(0, [1])), move.execute(step(ReplayCommandCode.Up, [1])),
items[ReplayCommandCode.AutoPathfindToPoint].command.execute( teleport.execute(
step(4, ['x', 1]) step(ReplayCommandCode.AutoPathfindToPoint, ['x', 1])
), ),
items[ReplayCommandCode.UseItem].command.execute(step(5, [])), teleport.execute(step(ReplayCommandCode.AutoPathfindToPoint, [1])),
items[ReplayCommandCode.Equip].command.execute(step(6, [1])), useItem.execute(step(ReplayCommandCode.UseItem, [])),
items[ReplayCommandCode.Unequip].command.execute(step(7, ['slot'])) 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([ await expect(Promise.all(invalid)).resolves.toEqual([
false,
false,
false,
false, false,
false, false,
false, false,
@ -309,40 +349,33 @@ describe('replay commands', () => {
]); ]);
}); });
// 验证生产 command 不拥有 replay safety helper 且纯查询不制造安全记录 // 验证注册直接写在 CoreState 内,且指令模块自包含、不含 replay safety
it('keeps replay safety ownership below production commands', async () => { it('keeps registration direct and the command module self-contained', () => {
const state = createCoreState(); const commands = readFileSync(
const replay = new ReplaySystem();
registerReplayCommandItems(replay, createReplayCommandItems(state));
const source = readFileSync(
new URL('./commands.ts', import.meta.url), new URL('./commands.ts', import.meta.url),
'utf8' 'utf8'
); );
expect(source).not.toContain('shouldReplay'); expect(commands).not.toContain('createReplayCommandItems');
const getPath = vi expect(commands).not.toContain('registerReplayCommandItems');
.spyOn(state.pathfinding, 'getPath') expect(commands).not.toContain('prettier-ignore');
.mockReturnValue([]); expect(commands).not.toContain('function isNumber');
const warning = vi.spyOn(logger, 'warn'); expect(commands).not.toContain('function resolveSlot');
let ended = false; expect(commands).not.toContain('shouldReplay');
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(getPath).toHaveBeenCalledWith({ x: 1, y: 1 }); const core = readFileSync(
expect( new URL('../core.ts', import.meta.url),
warning.mock.calls.filter(call => call[0] === 161) 'utf8'
).toHaveLength(0); );
} finally { expect(core).not.toContain('createReplayCommandItems');
if (!ended) endReplaySafetyCollection(); expect(core).not.toContain('registerReplayCommandItems');
warning.mockRestore(); 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'); 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);
}
}
});
}); });

View File

@ -1,63 +1,13 @@
import { import {
FaceDirection, FaceDirection,
IReplayStepHandler, IReplayStepHandler,
IReplaySystem,
IReplayCommand, IReplayCommand,
ReplayParamValue ReplayParamValue
} from '@user/data-common'; } from '@user/data-common';
import { EquipStatus } from '@user/data-base'; import { EquipStatus } from '@user/data-base';
import {
IReplayCommandItem,
IReplayCommandRegistry,
IReplayCommandState,
ReplayCommandCode,
REPLAY_COMMAND_ORDER
} from './types';
import { IStateSystem } from '@user/data-system'; import { IStateSystem } from '@user/data-system';
import { logger } from '@motajs/common'; 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 指令基类 //#region 指令基类
export abstract class BaseReplayCommand implements IReplayCommand { export abstract class BaseReplayCommand implements IReplayCommand {
@ -187,24 +137,21 @@ export class ReplayTeleportCommand
//#region 使用物品指令 //#region 使用物品指令
export class ReplayUseItemCommand implements IReplayCommand { export class ReplayUseItemCommand
constructor(private readonly state: IStateSystem) {} extends BaseReplayCommand
implements IReplayCommand
{
protected readonly name: string = 'use-item';
protected readonly paramTypes: readonly string[] = ['number'];
/** async wrappedExecute(step: IReplayStepHandler): Promise<boolean> {
* 使用指定道具并返回现有状态接口的结果 // Parameter: [int16 item]
*/ const item = step.params[0] as number;
private useItem(item: number | string): boolean { if (!this.state.hero.items.useItem(item)) {
return this.state.hero.items.useItem(item); logger.error(2006, item.toString());
} return false;
}
/** return true;
* 校验录像步参数并执行一次道具使用
*/
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));
} }
} }
@ -212,47 +159,38 @@ export class ReplayUseItemCommand implements IReplayCommand {
//#region 装备指令 //#region 装备指令
export class ReplayEquipCommand implements IReplayCommand { export class ReplayEquipCommand
constructor(private readonly state: IStateSystem) {} extends BaseReplayCommand
implements IReplayCommand
{
protected readonly name: string = 'equip';
protected readonly paramTypes: readonly string[] = [
'number',
'number',
'boolean'
];
/** async wrappedExecute(step: IReplayStepHandler): Promise<boolean> {
* 将指定装备穿到目标槽位并返回是否穿装成功 // Parameter: [int16 uid, int8 slot, bool autoUnload]
*/ const [uid, slot, autoUnload] = step.params as [
private equip( number,
uid: number, number,
slot: number | string, boolean
autoUnload: boolean | undefined ];
): boolean {
const equipment = this.state.hero.equip; const equipment = this.state.hero.equip;
const slotIndex = resolveSlot(this.state, slot); if (equipment.getEquipped(slot) === uid) {
if (slotIndex === null) return false; return true;
if (equipment.getEquipped(slotIndex) === uid) return true; }
if (equipment.canEquipTo(uid, slot) === EquipStatus.CannotEquip) { if (equipment.canEquipTo(uid, slot) === EquipStatus.CannotEquip) {
logger.error(2007, uid.toString(), slot.toString());
return false; return false;
} }
equipment.equip(uid, slot, autoUnload); equipment.equip(uid, slot, autoUnload);
return equipment.getEquipped(slotIndex) === uid; if (equipment.getEquipped(slot) !== uid) {
} logger.error(2008, uid.toString(), slot.toString());
return false;
/**
* 校验录像步参数并执行一次装备穿装
*/
execute(step: IReplayStepHandler): Promise<boolean> {
if (step.params.length < 2 || step.params.length > 3) {
return Promise.resolve(false);
} }
const uid = step.params[0]; return true;
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));
} }
} }
@ -260,105 +198,28 @@ export class ReplayEquipCommand implements IReplayCommand {
//#region 卸下装备指令 //#region 卸下装备指令
export class ReplayUnequipCommand implements IReplayCommand { export class ReplayUnequipCommand
constructor(private readonly state: IStateSystem) {} extends BaseReplayCommand
implements IReplayCommand
{
protected readonly name: string = 'unequip';
protected readonly paramTypes: readonly string[] = ['number'];
/** async wrappedExecute(step: IReplayStepHandler): Promise<boolean> {
* 卸下指定槽位的装备并返回是否卸下成功 // Parameter: [int8 slot]
*/ const slot = step.params[0] as number;
private unequip(slot: number): boolean {
const equipment = this.state.hero.equip; const equipment = this.state.hero.equip;
if (equipment.getEquipped(slot) === undefined) return false; if (equipment.getEquipped(slot) === undefined) {
equipment.unequip(slot); logger.error(2009, slot.toString());
return equipment.getEquipped(slot) === undefined; return false;
}
/**
* 校验录像步参数并执行一次装备卸下
*/
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);
} }
return Promise.resolve(this.unequip(slot)); equipment.unequip(slot);
if (equipment.getEquipped(slot) !== undefined) {
logger.error(2010, slot.toString());
return false;
}
return true;
} }
} }
//#endregion //#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);
}
}

View File

@ -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 { export const enum ReplayCommandCode {
/** 向上移动一步 */ /** 向上移动一步 */
@ -22,67 +18,7 @@ export const enum ReplayCommandCode {
Unequip = 7 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[] = [ export const REPLAY_COMMAND_ORDER: readonly ReplayCommandCode[] = [
ReplayCommandCode.Up, ReplayCommandCode.Up,
ReplayCommandCode.Right, ReplayCommandCode.Right,
@ -93,15 +29,3 @@ export const REPLAY_COMMAND_ORDER: readonly ReplayCommandCode[] = [
ReplayCommandCode.Equip, ReplayCommandCode.Equip,
ReplayCommandCode.Unequip ReplayCommandCode.Unequip
]; ];
/** 顶层注册器使用的重放系统最小边界 */
export interface IReplayCommandRegistry {
/** 注册录像指令 */
registerCommand(code: number, command: IReplayCommand): void;
/** 查询录像指令 */
getCommand(code: number): IReplayCommand | null;
}
/** command 参数读取结果 */
export type ReplayCommandParam = ReplayParamValue;

View File

@ -69,7 +69,12 @@
"2002": "Replay($1): Replay parameter type mismatch: index $2, expected $3, got $4.", "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.", "2003": "Replay(move): Expected hero to be stopped before execute moving step.",
"2004": "Replay(move): Unexpected move controller missing while executing move behavior.", "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": { "warn": {
"1": "Resource with type of 'none' is loaded.", "1": "Resource with type of 'none' is loaded.",