From dd4e66a335fdd4aedabbcc84d14073f9617b8f5e Mon Sep 17 00:00:00 2001 From: unanmed <1319491857@qq.com> Date: Sun, 13 Sep 2026 15:39:12 +0800 Subject: [PATCH] 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. --- .../data-common/src/replay/sandbox.ts | 36 +- packages-user/data-state/src/core.ts | 55 +- .../data-state/src/replay/commands.test.ts | 538 +++++++++--------- .../data-state/src/replay/commands.ts | 253 ++------ packages-user/data-state/src/replay/types.ts | 78 +-- packages/common/src/logger.json | 7 +- 6 files changed, 398 insertions(+), 569 deletions(-) diff --git a/packages-user/data-common/src/replay/sandbox.ts b/packages-user/data-common/src/replay/sandbox.ts index 37b8335..c2d4f82 100644 --- a/packages-user/data-common/src/replay/sandbox.ts +++ b/packages-user/data-common/src/replay/sandbox.ts @@ -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 { + 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; + } } diff --git a/packages-user/data-state/src/core.ts b/packages-user/data-state/src/core.ts index f1abb8f..2284016 100644 --- a/packages-user/data-state/src/core.ts +++ b/packages-user/data-state/src/core.ts @@ -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 旧样板图块定义对象 diff --git a/packages-user/data-state/src/replay/commands.test.ts b/packages-user/data-state/src/replay/commands.test.ts index 63f82be..98d00f7 100644 --- a/packages-user/data-state/src/replay/commands.test.ts +++ b/packages-user/data-state/src/replay/commands.test.ts @@ -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(); - const second = Promise.withResolvers(); const mover = state.hero.location.mover; const move = vi.spyOn(mover, 'step'); + const first = Promise.withResolvers(); 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(); - const second = Promise.withResolvers(); - 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(); + 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); - } - } - }); }); diff --git a/packages-user/data-state/src/replay/commands.ts b/packages-user/data-state/src/replay/commands.ts index 0f33dd9..2af7a99 100644 --- a/packages-user/data-state/src/replay/commands.ts +++ b/packages-user/data-state/src/replay/commands.ts @@ -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 { - 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 { + // 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 { + // 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 { - 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 { + // 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 { - 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 { - 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 -): void { - if (items.length !== REPLAY_COMMAND_ORDER.length) { - throw new Error( - 'Replay command registry must contain exactly eight items' - ); - } - const codes = new Set(); - 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); - } -} diff --git a/packages-user/data-state/src/replay/types.ts b/packages-user/data-state/src/replay/types.ts index 165d533..1200ad5 100644 --- a/packages-user/data-state/src/replay/types.ts +++ b/packages-user/data-state/src/replay/types.ts @@ -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; -} - -/** 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; diff --git a/packages/common/src/logger.json b/packages/common/src/logger.json index c338eba..84a039a 100644 --- a/packages/common/src/logger.json +++ b/packages/common/src/logger.json @@ -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.",