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();
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;
}
}

View File

@ -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

View File

@ -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);
}
}
});
});

View File

@ -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);
}
}

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 {
/** 向上移动一步 */
@ -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;

View File

@ -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.",