diff --git a/packages-user/data-common/src/replay/func.ts b/packages-user/data-common/src/replay/func.ts index ad36036..c1befc0 100644 --- a/packages-user/data-common/src/replay/func.ts +++ b/packages-user/data-common/src/replay/func.ts @@ -15,14 +15,22 @@ interface IReplaySafetyDetailQueue { readonly collection: IReplaySafetyCollection; } -type ReplayDecorator = < - Return, - This, - Func extends (this: This, ...args: any[]) => Return ->( - method: Func, - context: ClassMethodDecoratorContext -) => (this: This, ...args: any[]) => Return; +interface IPromiseLike { + then( + onFulfilled?: (value: unknown) => unknown, + onRejected?: (reason: unknown) => unknown + ): unknown; +} + +type ReplayMethod = ( + this: This, + ...args: Args +) => Return; + +type ReplayDecorator = ( + method: ReplayMethod, + context: ClassMethodDecoratorContext> +) => ReplayMethod; /** 本次收集使用的录像系统 */ let replaySystem: IReplaySystem | null = null; @@ -46,6 +54,21 @@ let detailCode = 0; /** 录像收集详细信息队列,保留 50 个以确保可以在控制台重复输出 */ const detailQueue: IReplaySafetyDetailQueue[] = []; +function isPromiseLike(value: unknown): value is IPromiseLike { + if (!value || (typeof value !== 'object' && typeof value !== 'function')) { + return false; + } + return typeof (value as { then?: unknown }).then === 'function'; +} + +function resetReplaySafetyCollection(): void { + replaySystem = null; + collecting = false; + beforeLength = 0; + shouldIgnore = false; + currentCollection = null; +} + /** * 开始录像安全性检查收集,一般情况下不需要手动调用此接口 * @param system 录像系统对象 @@ -58,6 +81,7 @@ export function beginReplaySafetyCollection(system: IReplaySystem): void { replaySystem = system; collecting = true; beforeLength = system.route.length; + shouldIgnore = false; collection.messages.length = 0; currentCollection = collection; } @@ -70,30 +94,34 @@ export function endReplaySafetyCollection(): void { logger.warn(160); return; } - if (shouldIgnore) return; - if (replaySystem.route.length > beforeLength) return; - if (collection.messages.length === 0) return; + try { + if (shouldIgnore) return; + if (replaySystem.route.length > beforeLength) return; + if (collection.messages.length === 0) return; - // 需要把收集内容输出,这里只输出一层,完整输出需要在控制台手动调用 - const code = detailCode++; - const col: IReplaySafetyCollection = { - name: `detail#${code}`, - messages: collection.messages.slice() - }; - const command = `Mota.require('@user/data-common').logReplaySafetyDetail(${code});`; - const simplified = [...collection.messages] - .sort((a, b) => b.messages.length - a.messages.length) - .map(v => v.name) - .join('\n'); - logger.warn(161, command, simplified); + // 需要把收集内容输出,这里只输出一层,完整输出需要在控制台手动调用 + const code = detailCode++; + const col: IReplaySafetyCollection = { + name: `detail#${code}`, + messages: collection.messages.slice() + }; + const command = `Mota.require('@user/data-common').logReplaySafetyDetail(${code});`; + const simplified = [...collection.messages] + .sort((a, b) => b.messages.length - a.messages.length) + .map(v => v.name) + .join('\n'); + logger.warn(161, command, simplified); - while (detailQueue.length > 50) { - detailQueue.shift(); + while (detailQueue.length > 50) { + detailQueue.shift(); + } + detailQueue.push({ + code, + collection: col + }); + } finally { + resetReplaySafetyCollection(); } - detailQueue.push({ - code, - collection: col - }); } /** @@ -133,16 +161,14 @@ export function logReplaySafetyDetail(code: number): void { * @param message 如果录像安全检查出现问题,那么会使用此作为信息输出 */ export function shouldReplay(message: string): ReplayDecorator { - return function < - Return, - This, - Args extends any[], - Func extends (this: This, ...args: Args) => Return - >( + return function ( this: This, - method: Func, - context: ClassMethodDecoratorContext - ): (this: This, ...args: Args) => Return { + method: ReplayMethod, + context: ClassMethodDecoratorContext< + This, + ReplayMethod + > + ): ReplayMethod { return function (this: This, ...args: Args): Return { const before = currentCollection; if (!before) return method.apply(this, args); @@ -158,7 +184,18 @@ export function shouldReplay(message: string): ReplayDecorator { currentCollection = newCollection; const result = method.apply(this, args); - currentCollection = before; + if (isPromiseLike(result)) { + Promise.resolve(result).then( + () => { + currentCollection = before; + }, + () => { + currentCollection = before; + } + ); + } else { + currentCollection = before; + } return result; }; }; @@ -169,12 +206,10 @@ export function shouldReplay(message: string): ReplayDecorator { * 不要使用此方法来规避控制台的录像错误警告,否则很可能导致录像出错。 */ export function ignoreReplay(): ReplayDecorator { - return function < - Return, - This, - Args extends any[], - Func extends (this: This, ...args: Args) => Return - >(this: This, method: Func): (this: This, ...args: Args) => Return { + return function ( + this: This, + method: ReplayMethod + ): ReplayMethod { return function (this: This, ...args: Args): Return { shouldIgnore = true; return method.apply(this, args); diff --git a/packages-user/data-common/src/replay/index.ts b/packages-user/data-common/src/replay/index.ts index 7e40945..4196243 100644 --- a/packages-user/data-common/src/replay/index.ts +++ b/packages-user/data-common/src/replay/index.ts @@ -1,3 +1,5 @@ export * from './array'; +export * from './func'; export * from './sandbox'; +export * from './system'; export * from './types'; diff --git a/packages-user/data-state/src/replay/commands.test.ts b/packages-user/data-state/src/replay/commands.test.ts new file mode 100644 index 0000000..9239796 --- /dev/null +++ b/packages-user/data-state/src/replay/commands.test.ts @@ -0,0 +1,257 @@ +import { logger } from '@motajs/common'; +import { + FaceDirection, + IReplayStepHandler, + IMoverController, + beginReplaySafetyCollection, + endReplaySafetyCollection, + logReplaySafetyDetail, + shouldReplay +} from '@user/data-common'; +import { EquipStatus } from '@user/data-base'; +import { describe, expect, it, vi } from 'vitest'; +import { createCoreState } from '../core'; +import { ReplaySystem } from '../../../data-common/src/replay/system'; +import { + createReplayCommandItems, + registerReplayCommandItems +} from './commands'; +import { + IReplayCommandItem, + ReplayCommandCode, + REPLAY_COMMAND_ORDER +} from './types'; + +function step( + command: number, + params: IReplayStepHandler['params'] +): IReplayStepHandler { + return { command, params, index: 0 }; +} + +function controller(onEnd: Promise): Readonly { + return { + done: false, + onEnd, + push: () => {}, + insert: () => {}, + stop: () => onEnd + }; +} + +describe('replay commands', () => { + // 验证默认 command item 只按稳定 enum 顺序提供八个实现 + it('creates the approved command order without module-owned numbering', () => { + 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); + }); + + // 验证顶层注册器按稳定顺序注册并拒绝重复 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)) + ).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'); + }); + + // 验证四向移动在 controller.onEnd 兑现前不会完成 command + it('awaits a directional movement controller', async () => { + const state = createCoreState(); + const items = createReplayCommandItems(state); + const deferred = Promise.withResolvers(); + const mover = state.hero.location.mover; + const move = vi.spyOn(mover, 'step'); + const start = vi + .spyOn(mover, 'start') + .mockReturnValue(controller(deferred.promise)); + const result = items[ReplayCommandCode.Right].command.execute( + step(ReplayCommandCode.Right, []) + ); + let settled = false; + void result.then(() => { + settled = true; + }); + await Promise.resolve(); + expect(settled).toBe(false); + expect(move).toHaveBeenCalledWith(FaceDirection.Right); + expect(start).toHaveBeenCalledTimes(1); + deferred.resolve(); + await expect(result).resolves.toBe(true); + }); + + // 验证自动寻路等待 PathfindingSystem 返回的完整 controller + it('awaits the pathfinding controller and returns false when no path exists', async () => { + const state = createCoreState(); + const items = createReplayCommandItems(state); + const deferred = Promise.withResolvers(); + const moveTo = vi.spyOn(state.pathfinding, 'moveTo'); + moveTo.mockReturnValue({ + controller: controller(deferred.promise), + path: [] + }); + const result = items[ + ReplayCommandCode.AutoPathfindToPoint + ].command.execute(step(ReplayCommandCode.AutoPathfindToPoint, [2, 3])); + await Promise.resolve(); + expect(moveTo).toHaveBeenCalledWith({ x: 2, y: 3 }); + deferred.resolve(); + await expect(result).resolves.toBe(true); + moveTo.mockReturnValue(null); + await expect( + items[ReplayCommandCode.AutoPathfindToPoint].command.execute( + step(ReplayCommandCode.AutoPathfindToPoint, [2, 3]) + ) + ).resolves.toBe(false); + }); + + // 验证道具和装备 command 使用既有状态 API 并把失败结果返回给 replay + it('returns the existing item and equipment action results', async () => { + const state = createCoreState(); + const items = createReplayCommandItems(state); + const useItem = vi + .spyOn(state.hero.items, 'useItem') + .mockReturnValueOnce(true) + .mockReturnValueOnce(false); + await expect( + items[ReplayCommandCode.UseItem].command.execute( + step(ReplayCommandCode.UseItem, [12]) + ) + ).resolves.toBe(true); + await expect( + items[ReplayCommandCode.UseItem].command.execute( + step(ReplayCommandCode.UseItem, ['unknown']) + ) + ).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(); + }); + + // 验证所有 command 对无效参数都以 false 结束而不推进状态 + it('returns false for invalid command parameters', async () => { + const state = createCoreState(); + const items = createReplayCommandItems(state); + const invalid = [ + items[ReplayCommandCode.Up].command.execute(step(0, [1])), + items[ReplayCommandCode.AutoPathfindToPoint].command.execute( + step(4, ['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'])) + ]; + await expect(Promise.all(invalid)).resolves.toEqual([ + false, + false, + false, + false, + false + ]); + }); +}); + +describe('replay safety decorators', () => { + // 验证异步 decorator 在 Promise 兑现前保留嵌套 collection 上下文 + it('keeps collection context through deferred nested actions', async () => { + interface DecoratedFixture { + inner(): void; + outer(gate: Promise): Promise; + } + const inner = shouldReplay('inner')( + function (this: DecoratedFixture): void {}, + { name: 'inner' } as ClassMethodDecoratorContext< + DecoratedFixture, + (this: DecoratedFixture) => void + > + ); + const outer = shouldReplay('outer')( + async function ( + this: DecoratedFixture, + gate: Promise + ): Promise { + await gate; + this.inner(); + }, + { name: 'outer' } as ClassMethodDecoratorContext< + DecoratedFixture, + (this: DecoratedFixture, gate: Promise) => Promise + > + ); + const fixture: DecoratedFixture = { + inner, + outer + }; + + const replay = new ReplaySystem(); + const gate = Promise.withResolvers(); + const warning = vi.spyOn(logger, 'warn'); + const group = vi.spyOn(console, 'group').mockImplementation(() => {}); + beginReplaySafetyCollection(replay); + const action = fixture.outer(gate.promise); + gate.resolve(); + await action; + endReplaySafetyCollection(); + + const detail = warning.mock.calls.find(call => call[0] === 161); + expect(detail).toBeDefined(); + const command = String(detail![1]); + const match = command.match(/\((\d+)\)/); + expect(match).not.toBeNull(); + const output = vi.spyOn(console, 'log').mockImplementation(() => {}); + logReplaySafetyDetail(Number(match![1])); + expect(group).toHaveBeenCalled(); + expect(output.mock.calls.flat().join(' ')).toContain('inner'); + warning.mockRestore(); + group.mockRestore(); + output.mockRestore(); + }); + + // 验证同步 collection 在结束后可重新开始且不会残留旧上下文 + it('resets the collection lifecycle after completion', () => { + const replay = new ReplaySystem(); + const warning = vi.spyOn(logger, 'warn'); + beginReplaySafetyCollection(replay); + endReplaySafetyCollection(); + beginReplaySafetyCollection(replay); + endReplaySafetyCollection(); + expect(warning).not.toHaveBeenCalledWith(159); + warning.mockRestore(); + }); +}); diff --git a/packages-user/data-state/src/replay/commands.ts b/packages-user/data-state/src/replay/commands.ts new file mode 100644 index 0000000..45b3730 --- /dev/null +++ b/packages-user/data-state/src/replay/commands.ts @@ -0,0 +1,204 @@ +import { + FaceDirection, + IReplayStepHandler, + IReplaySystem +} from '@user/data-common'; +import { EquipStatus } from '@user/data-base'; +import { + IReplayCommandItem, + IReplayCommandRegistry, + IReplayCommandState, + ReplayCommandCode, + REPLAY_COMMAND_ORDER +} from './types'; + +function isNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value); +} + +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'; +} + +function isSlot(value: unknown): value is number | string { + return isNumber(value) || typeof value === 'string'; +} + +async function moveHero( + state: IReplayCommandState, + direction: FaceDirection, + step: IReplayStepHandler +): Promise { + if (step.params.length !== 0) return false; + const mover = state.hero.location.mover; + if (mover.moving) return false; + mover.step(direction); + const controller = mover.start(); + if (!controller) return false; + await controller.onEnd; + return true; +} + +async function moveToPoint( + state: IReplayCommandState, + step: IReplayStepHandler +): Promise { + if (step.params.length !== 2) return false; + const x = step.params[0]; + const y = step.params[1]; + if (!isNumber(x) || !isNumber(y)) return false; + const result = state.pathfinding.moveTo({ x, y }); + if (!result) return false; + await result.controller.onEnd; + return true; +} + +function useItem( + state: IReplayCommandState, + 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(state.hero.items.useItem(item)); +} + +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; +} + +function equip( + state: IReplayCommandState, + step: IReplayStepHandler +): Promise { + if (step.params.length < 2 || step.params.length > 3) { + return Promise.resolve(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(state, slot); + if (slotIndex === null) return Promise.resolve(false); + if (state.hero.equip.getEquipped(slotIndex) === uid) { + return Promise.resolve(true); + } + if (state.hero.equip.canEquipTo(uid, slot) === EquipStatus.CannotEquip) { + return Promise.resolve(false); + } + state.hero.equip.equip(uid, slot, autoUnload); + return Promise.resolve(state.hero.equip.getEquipped(slotIndex) === uid); +} + +function unequip( + state: IReplayCommandState, + 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 (state.hero.equip.getEquipped(slot) === undefined) { + return Promise.resolve(false); + } + state.hero.equip.unequip(slot); + return Promise.resolve(state.hero.equip.getEquipped(slot) === undefined); +} + +function createMoveCommand( + state: IReplayCommandState, + direction: FaceDirection +) { + return { + execute: (step: IReplayStepHandler): Promise => + moveHero(state, direction, step) + }; +} + +/** 创建按稳定 enum 顺序排列的默认 replay command items */ +export function createReplayCommandItems( + state: IReplayCommandState +): ReadonlyArray { + return [ + { + code: ReplayCommandCode.Up, + command: createMoveCommand(state, FaceDirection.Up) + }, + { + code: ReplayCommandCode.Right, + command: createMoveCommand(state, FaceDirection.Right) + }, + { + code: ReplayCommandCode.Down, + command: createMoveCommand(state, FaceDirection.Down) + }, + { + code: ReplayCommandCode.Left, + command: createMoveCommand(state, FaceDirection.Left) + }, + { + code: ReplayCommandCode.AutoPathfindToPoint, + command: { execute: step => moveToPoint(state, step) } + }, + { + code: ReplayCommandCode.UseItem, + command: { execute: step => useItem(state, step) } + }, + { + code: ReplayCommandCode.Equip, + command: { execute: step => equip(state, step) } + }, + { + code: ReplayCommandCode.Unequip, + command: { execute: step => unequip(state, step) } + } + ]; +} + +/** 按 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/index.ts b/packages-user/data-state/src/replay/index.ts new file mode 100644 index 0000000..b9d7505 --- /dev/null +++ b/packages-user/data-state/src/replay/index.ts @@ -0,0 +1,2 @@ +export * from './commands'; +export * from './types'; diff --git a/packages-user/data-state/src/replay/types.ts b/packages-user/data-state/src/replay/types.ts new file mode 100644 index 0000000..165d533 --- /dev/null +++ b/packages-user/data-state/src/replay/types.ts @@ -0,0 +1,107 @@ +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 { + /** 向上移动一步 */ + Up = 0, + /** 向右移动一步 */ + Right = 1, + /** 向下移动一步 */ + Down = 2, + /** 向左移动一步 */ + Left = 3, + /** 自动寻路至目标点 */ + AutoPathfindToPoint = 4, + /** 使用道具 */ + UseItem = 5, + /** 装备物品 */ + Equip = 6, + /** 卸下装备 */ + 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, + ReplayCommandCode.Down, + ReplayCommandCode.Left, + ReplayCommandCode.AutoPathfindToPoint, + ReplayCommandCode.UseItem, + ReplayCommandCode.Equip, + ReplayCommandCode.Unequip +]; + +/** 顶层注册器使用的重放系统最小边界 */ +export interface IReplayCommandRegistry { + /** 注册录像指令 */ + registerCommand(code: number, command: IReplayCommand): void; + + /** 查询录像指令 */ + getCommand(code: number): IReplayCommand | null; +} + +/** command 参数读取结果 */ +export type ReplayCommandParam = ReplayParamValue;