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
This commit is contained in:
unanmed 2026-09-11 13:38:38 +08:00
parent d9a7faa5b9
commit 7ffc6c4171
3 changed files with 39 additions and 69 deletions

View File

@ -5,7 +5,7 @@
本节晚于初始 replay checkpoint优先于下方关于异步 command completion 和 decorator
placement 的旧记录:
- replay command 的状态操作必须同步完成command 不等待移动控制器、事件链或其他 Promise不能用异步恢复 collection context。
- replay command 的状态操作必须同步完成command 不等待移动控制器、事件链或其他 Promisecollection 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<boolean>`
boundary, but command implementations adapt their immediate boolean result with
`Promise.resolve` rather than adding asynchronous command sequencing.
## Explicit exclusions

View File

@ -15,13 +15,6 @@ interface IReplaySafetyDetailQueue {
readonly collection: IReplaySafetyCollection;
}
interface IPromiseLike {
then(
onFulfilled?: (value: unknown) => unknown,
onRejected?: (reason: unknown) => unknown
): unknown;
}
type ReplayMethod<This, Args extends unknown[], Return> = (
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;
};
};

View File

@ -42,14 +42,14 @@ function resolveSlot(
}
interface IReplayCommandEntrances {
/** 执行一个方向的勇士移动,并等待移动控制器完成 */
moveHero(direction: FaceDirection): Promise<boolean>;
/** 执行一个方向的勇士移动 */
moveHero(direction: FaceDirection): boolean;
/** 执行一次指定目标点的自动寻路移动,并等待移动控制器完成 */
moveToPoint(x: number, y: number): Promise<boolean>;
/** 执行一次指定目标点的自动寻路移动 */
moveToPoint(x: number, y: number): boolean;
/** 执行既有勇士道具使用入口 */
useItem(item: number | string): Promise<boolean>;
useItem(item: number | string): boolean;
/** 执行既有勇士装备入口 */
equip(
@ -57,10 +57,10 @@ interface IReplayCommandEntrances {
slot: number | string,
slotIndex: number,
autoUnload: boolean | undefined
): Promise<boolean>;
): boolean;
/** 执行既有勇士卸下装备入口 */
unequip(slot: number): Promise<boolean>;
unequip(slot: number): boolean;
}
class ReplayCommandEntrances implements IReplayCommandEntrances {
@ -74,7 +74,7 @@ class ReplayCommandEntrances implements IReplayCommandEntrances {
(
this: ReplayCommandEntrances,
direction: FaceDirection
) => Promise<boolean>
) => 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<boolean>
(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<boolean>
(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>
) => 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<boolean>
(this: ReplayCommandEntrances, slot: number) => boolean
>
);
}
async moveHero(direction: FaceDirection): Promise<boolean> {
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<boolean> {
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<boolean> {
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> {
): 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<boolean> {
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<boolean> => {
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));
}
}
}