From 7ffc6c4171136276c9ef6d8f4ca8667422b51b56 Mon Sep 17 00:00:00 2001 From: unanmed <1319491857@qq.com> Date: Fri, 11 Sep 2026 13:38:38 +0800 Subject: [PATCH] fix(03-14): restore synchronous replay command handling - Remove Promise settlement from replay safety restoration - Adapt command booleans to the existing Promise boundary - Preserve stable replay interfaces and user-owned decorator placement --- .../03-data-completion/03-REPLAY-CONTRACT.md | 24 ++++---- packages-user/data-common/src/replay/func.ts | 27 +-------- .../data-state/src/replay/commands.ts | 57 ++++++++----------- 3 files changed, 39 insertions(+), 69 deletions(-) diff --git a/.planning/phases/03-data-completion/03-REPLAY-CONTRACT.md b/.planning/phases/03-data-completion/03-REPLAY-CONTRACT.md index e513f41..8e2d664 100644 --- a/.planning/phases/03-data-completion/03-REPLAY-CONTRACT.md +++ b/.planning/phases/03-data-completion/03-REPLAY-CONTRACT.md @@ -5,7 +5,7 @@ 本节晚于初始 replay checkpoint,优先于下方关于异步 command completion 和 decorator placement 的旧记录: -- replay command 的状态操作必须同步完成;command 不等待移动控制器、事件链或其他 Promise,不能用异步恢复 collection context。 +- replay command 的状态操作必须同步完成;command 不等待移动控制器、事件链或其他 Promise,collection context 在装饰方法返回时同步恢复。 - 既有 `ReplaySystem`、route 和 sandbox 只做使同步 command 正常运行所需的最小兼容调整,不重新设计录像系统。 - `@shouldReplay()` 不在本次 correction 中移动或新增;其最终位置由用户自行放到真正改变最终状态的方法上。 @@ -86,18 +86,20 @@ contract. ## Completion boundaries - Four-direction movement appends one direction to the hero mover, starts it, - and awaits the returned `mover controller.onEnd` Promise. + and returns `true` when the controller starts; it does not await + `mover controller.onEnd`. - Auto-pathfind calls the existing `PathfindingSystem.moveTo({ x, y })`; a null - result is `false`, and a non-null result is complete only after its returned - `controller.onEnd` Promise settles. -- Item and equipment calls are synchronous under the current interfaces; their + result is `false`, and a non-null result returns `true` immediately without + awaiting its controller. +- Item and equipment calls remain synchronous under the current interfaces; their boolean/undefined result is converted to the command's success boolean. -- Replay safety collection remains active across every decorated Promise until - that Promise settles, including nested decorated calls. Synchronous queries, - pure calculations, and internal helpers are outside the decoration boundary. -- A command never advances replay completion before its complete action Promise - settles. First-divergence thrown diagnostics remain the responsibility of - Plan 04 and do not change the replay boolean interface. +- Replay safety collection is restored to its previous context immediately after + every decorated method returns, including nested synchronous decorated calls. + Synchronous queries, pure calculations, and internal helpers are outside the + decoration boundary. +- `IReplayCommand.execute()` continues to return the existing `Promise` + boundary, but command implementations adapt their immediate boolean result with + `Promise.resolve` rather than adding asynchronous command sequencing. ## Explicit exclusions diff --git a/packages-user/data-common/src/replay/func.ts b/packages-user/data-common/src/replay/func.ts index c1befc0..f7ea5d9 100644 --- a/packages-user/data-common/src/replay/func.ts +++ b/packages-user/data-common/src/replay/func.ts @@ -15,13 +15,6 @@ interface IReplaySafetyDetailQueue { readonly collection: IReplaySafetyCollection; } -interface IPromiseLike { - then( - onFulfilled?: (value: unknown) => unknown, - onRejected?: (reason: unknown) => unknown - ): unknown; -} - type ReplayMethod = ( this: This, ...args: Args @@ -54,13 +47,6 @@ 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; @@ -184,18 +170,7 @@ export function shouldReplay(message: string): ReplayDecorator { currentCollection = newCollection; const result = method.apply(this, args); - if (isPromiseLike(result)) { - Promise.resolve(result).then( - () => { - currentCollection = before; - }, - () => { - currentCollection = before; - } - ); - } else { - currentCollection = before; - } + currentCollection = before; return result; }; }; diff --git a/packages-user/data-state/src/replay/commands.ts b/packages-user/data-state/src/replay/commands.ts index 48a26b4..c39062d 100644 --- a/packages-user/data-state/src/replay/commands.ts +++ b/packages-user/data-state/src/replay/commands.ts @@ -42,14 +42,14 @@ function resolveSlot( } interface IReplayCommandEntrances { - /** 执行一个方向的勇士移动,并等待移动控制器完成 */ - moveHero(direction: FaceDirection): Promise; + /** 执行一个方向的勇士移动 */ + moveHero(direction: FaceDirection): boolean; - /** 执行一次指定目标点的自动寻路移动,并等待移动控制器完成 */ - moveToPoint(x: number, y: number): Promise; + /** 执行一次指定目标点的自动寻路移动 */ + moveToPoint(x: number, y: number): boolean; /** 执行既有勇士道具使用入口 */ - useItem(item: number | string): Promise; + useItem(item: number | string): boolean; /** 执行既有勇士装备入口 */ equip( @@ -57,10 +57,10 @@ interface IReplayCommandEntrances { slot: number | string, slotIndex: number, autoUnload: boolean | undefined - ): Promise; + ): boolean; /** 执行既有勇士卸下装备入口 */ - unequip(slot: number): Promise; + unequip(slot: number): boolean; } class ReplayCommandEntrances implements IReplayCommandEntrances { @@ -74,7 +74,7 @@ class ReplayCommandEntrances implements IReplayCommandEntrances { ( this: ReplayCommandEntrances, direction: FaceDirection - ) => Promise + ) => boolean > ); this.moveToPoint = shouldReplay('replay command: pathfind hero')( @@ -83,21 +83,14 @@ class ReplayCommandEntrances implements IReplayCommandEntrances { name: 'moveToPoint' } as ClassMethodDecoratorContext< ReplayCommandEntrances, - ( - this: ReplayCommandEntrances, - x: number, - y: number - ) => Promise + (this: ReplayCommandEntrances, x: number, y: number) => boolean > ); this.useItem = shouldReplay('replay command: use item')(this.useItem, { name: 'useItem' } as ClassMethodDecoratorContext< ReplayCommandEntrances, - ( - this: ReplayCommandEntrances, - item: number | string - ) => Promise + (this: ReplayCommandEntrances, item: number | string) => boolean >); this.equip = shouldReplay('replay command: equip item')(this.equip, { name: 'equip' @@ -109,7 +102,7 @@ class ReplayCommandEntrances implements IReplayCommandEntrances { slot: number | string, slotIndex: number, autoUnload: boolean | undefined - ) => Promise + ) => boolean >); this.unequip = shouldReplay('replay command: unequip item')( this.unequip, @@ -117,38 +110,36 @@ class ReplayCommandEntrances implements IReplayCommandEntrances { name: 'unequip' } as ClassMethodDecoratorContext< ReplayCommandEntrances, - (this: ReplayCommandEntrances, slot: number) => Promise + (this: ReplayCommandEntrances, slot: number) => boolean > ); } - async moveHero(direction: FaceDirection): Promise { + moveHero(direction: FaceDirection): boolean { const mover = this.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 moveToPoint(x: number, y: number): Promise { + moveToPoint(x: number, y: number): boolean { const result = this.state.pathfinding.moveTo({ x, y }); if (!result) return false; - await result.controller.onEnd; return true; } - async useItem(item: number | string): Promise { + useItem(item: number | string): boolean { return this.state.hero.items.useItem(item); } - async equip( + equip( uid: number, slot: number | string, slotIndex: number, autoUnload: boolean | undefined - ): Promise { + ): boolean { if (this.state.hero.equip.getEquipped(slotIndex) === uid) return true; if ( this.state.hero.equip.canEquipTo(uid, slot) === @@ -160,7 +151,7 @@ class ReplayCommandEntrances implements IReplayCommandEntrances { return this.state.hero.equip.getEquipped(slotIndex) === uid; } - async unequip(slot: number): Promise { + unequip(slot: number): boolean { if (this.state.hero.equip.getEquipped(slot) === undefined) { return false; } @@ -176,7 +167,7 @@ function createMoveCommand( return { execute: (step: IReplayStepHandler): Promise => { if (step.params.length !== 0) return Promise.resolve(false); - return entries.moveHero(direction); + return Promise.resolve(entries.moveHero(direction)); } }; } @@ -213,7 +204,7 @@ export function createReplayCommandItems( if (!isNumber(x) || !isNumber(y)) { return Promise.resolve(false); } - return entries.moveToPoint(x, y); + return Promise.resolve(entries.moveToPoint(x, y)); } } }, @@ -224,7 +215,7 @@ export function createReplayCommandItems( if (step.params.length !== 1) return Promise.resolve(false); const item = step.params[0]; if (!isItem(item)) return Promise.resolve(false); - return entries.useItem(item); + return Promise.resolve(entries.useItem(item)); } } }, @@ -250,7 +241,9 @@ export function createReplayCommandItems( } const slotIndex = resolveSlot(state, slot); if (slotIndex === null) return Promise.resolve(false); - return entries.equip(uid, slot, slotIndex, autoUnload); + return Promise.resolve( + entries.equip(uid, slot, slotIndex, autoUnload) + ); } } }, @@ -269,7 +262,7 @@ export function createReplayCommandItems( ) { return Promise.resolve(false); } - return entries.unequip(slot); + return Promise.resolve(entries.unequip(slot)); } } }