fix(03-17): await replay movement completion

- Remove replay-safety decoration from command implementations
- Await directional and pathfinding controller completion
- Collapse directional commands into one parameterized class
This commit is contained in:
unanmed 2026-09-11 22:21:01 +08:00
parent 3df11049dd
commit df7d1e12c2
3 changed files with 143 additions and 279 deletions

View File

@ -1,9 +1,9 @@
# Phase 3 Plan 03: Replay Command Contract
## User structural supersession
## User structural supersession (S-05)
本节晚于初始 replay checkpoint优先于下方关于 command completion 和 decorator
placement 的旧记录。S-05 supersedes S-02 在 replay-step completion 边界上的结论:
placement 的旧记录。S-05 supersedes S-02 在 replay-step completion 边界上的结论:
- replay command 不调用 `shouldReplay`;移动和寻路 command 必须等待各自 controller 完成后再返回,确保下一录像步不会与上一步并发。`shouldReplay` 的最终落点仍由用户放在真正改变最终状态的低层方法上。
- 既有 `ReplaySystem`、route 和 sandbox 只做使同步 command 正常运行所需的最小兼容调整,不重新设计录像系统。
@ -52,8 +52,8 @@ their parameter count and primitive types before touching state:
| `unequip` | one numeric slot | `CoreState.hero.equip.unequip(slot)` |
Invalid parameter count/types, missing targets, an already-running action, or
a state API failure return `false`. A successful synchronous state API returns
silent success.
a state API failure return `false`. Item and equipment state APIs retain their
existing synchronous command boundary.
## Top-level registry ownership
@ -93,7 +93,7 @@ the singleton, IndexedDB, or a new options-bearing factory API. The existing
remain the action targets; no new `ICoreState` member is required by this
contract.
## Completion boundaries
## Completion boundaries under S-05
- Four-direction movement appends one direction to the hero mover, starts it,
and awaits the returned mover controller's `onEnd`; it returns `true` only

View File

@ -2,6 +2,7 @@ import { logger } from '@motajs/common';
import {
FaceDirection,
IReplayStepHandler,
IReplaySandbox,
IMoverController,
beginReplaySafetyCollection,
endReplaySafetyCollection,
@ -40,6 +41,11 @@ function controller(onEnd: Promise<void>): Readonly<IMoverController> {
};
}
interface IManualReplaySandbox extends IReplaySandbox {
playing: boolean;
pausing: boolean;
}
describe('replay commands', () => {
// 验证默认 command item 只按稳定 enum 顺序提供八个实现
it('creates the approved command order without module-owned numbering', () => {
@ -49,10 +55,10 @@ describe('replay commands', () => {
expect(items).toHaveLength(8);
expect(items.map(item => item.command.execute)).toHaveLength(8);
expect(items.map(item => item.command.constructor.name)).toEqual([
'ReplayUpCommand',
'ReplayRightCommand',
'ReplayDownCommand',
'ReplayLeftCommand',
'ReplayDirectionCommand',
'ReplayDirectionCommand',
'ReplayDirectionCommand',
'ReplayDirectionCommand',
'ReplayAutoPathfindCommand',
'ReplayUseItemCommand',
'ReplayEquipCommand',
@ -133,41 +139,81 @@ describe('replay commands', () => {
);
});
// 验证四向移动不等待 controller.onEnd 即完成 command
it('completes directional movement without awaiting the controller', async () => {
// 验证四向移动等待 controller.onEnd 后才完成 command 并进入下一步
it('awaits directional movement before the next replay step', async () => {
const state = createCoreState();
const items = createReplayCommandItems(state);
const deferred = Promise.withResolvers<void>();
const first = Promise.withResolvers<void>();
const second = Promise.withResolvers<void>();
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, [])
);
.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();
expect(move).toHaveBeenCalledWith(FaceDirection.Right);
expect(start).toHaveBeenCalledTimes(1);
await Promise.resolve();
expect(start).toHaveBeenCalledTimes(1);
first.resolve();
await expect(result).resolves.toBe(true);
deferred.resolve();
const next = sandbox.step();
await Promise.resolve();
expect(start).toHaveBeenCalledTimes(2);
second.resolve();
await expect(next).resolves.toBe(true);
});
// 验证自动寻路不等待 PathfindingSystem 返回的 controller
it('completes pathfinding without awaiting the controller', async () => {
// 验证自动寻路等待 PathfindingSystem 返回的 controller 后才进入下一步
it('awaits pathfinding before the next replay step', async () => {
const state = createCoreState();
const items = createReplayCommandItems(state);
const deferred = Promise.withResolvers<void>();
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]));
const first = Promise.withResolvers<void>();
const second = Promise.withResolvers<void>();
const moveTo = vi
.spyOn(state.pathfinding, 'moveTo')
.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);
await Promise.resolve();
expect(moveTo).toHaveBeenCalledTimes(1);
first.resolve();
await expect(result).resolves.toBe(true);
deferred.resolve();
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 expect(
items[ReplayCommandCode.AutoPathfindToPoint].command.execute(
@ -245,80 +291,16 @@ describe('replay commands', () => {
]);
});
// 验证真实 registry 的生产移动入口在同步返回时恢复安全收集上下文
it('restores registry movement safety context synchronously', async () => {
// 验证生产 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 deferred = Promise.withResolvers<void>();
const mover = state.hero.location.mover;
vi.spyOn(mover, 'start').mockReturnValue(controller(deferred.promise));
const warning = vi.spyOn(logger, 'warn');
let ended = false;
beginReplaySafetyCollection(replay);
try {
const action = replay
.getCommand(ReplayCommandCode.Right)!
.execute(step(ReplayCommandCode.Right, []));
await expect(action).resolves.toBe(true);
deferred.resolve();
endReplaySafetyCollection();
ended = true;
const detail = warning.mock.calls.find(call => call[0] === 161);
expect(detail).toBeDefined();
expect(String(detail![2])).toContain('replay command: move hero');
} finally {
if (!ended) endReplaySafetyCollection();
warning.mockRestore();
}
});
// 验证真实 registry 的道具和装备入口均经过生产 replay 安全边界
it('decorates real registry item and equipment actions', async () => {
const state = createCoreState();
const replay = new ReplaySystem();
registerReplayCommandItems(replay, createReplayCommandItems(state));
vi.spyOn(state.hero.items, 'useItem').mockReturnValue(true);
vi.spyOn(state.hero.equip, 'canEquipTo').mockReturnValue(
EquipStatus.CanEquip
const source = readFileSync(
new URL('./commands.ts', import.meta.url),
'utf8'
);
vi.spyOn(state.hero.equip, 'getEquipped')
.mockReturnValueOnce(undefined)
.mockReturnValueOnce(99);
vi.spyOn(state.hero.equip, 'equip').mockImplementation(() => undefined);
const warning = vi.spyOn(logger, 'warn');
let ended = false;
beginReplaySafetyCollection(replay);
try {
await expect(
replay
.getCommand(ReplayCommandCode.UseItem)!
.execute(step(ReplayCommandCode.UseItem, [12]))
).resolves.toBe(true);
await expect(
replay
.getCommand(ReplayCommandCode.Equip)!
.execute(step(ReplayCommandCode.Equip, [99, 0]))
).resolves.toBe(true);
endReplaySafetyCollection();
ended = true;
const detail = warning.mock.calls.find(call => call[0] === 161);
expect(detail).toBeDefined();
expect(String(detail![2])).toContain('replay command: use item');
expect(String(detail![2])).toContain('replay command: equip item');
} finally {
if (!ended) endReplaySafetyCollection();
warning.mockRestore();
}
});
// 验证生产 command 的参数校验和寻路查询不会制造 replay 安全记录
it('keeps pure path queries and validation outside the safety boundary', async () => {
const state = createCoreState();
const replay = new ReplaySystem();
registerReplayCommandItems(replay, createReplayCommandItems(state));
expect(source).not.toContain('shouldReplay');
const getPath = vi
.spyOn(state.pathfinding, 'getPath')
.mockReturnValue([]);
@ -427,17 +409,14 @@ describe('replay safety decorators', () => {
expect(source).not.toContain('shouldReplay');
});
// 验证八个 command 类各自拥有 execute 且不存在共享入口或跨类调用
it('keeps replay command ownership isolated in the command module', () => {
// 验证方向 command 共享一个参数化类且不存在旧的重复入口
it('keeps directional command ownership parameterized', () => {
const source = readFileSync(
new URL('./commands.ts', import.meta.url),
'utf8'
);
const classes = [
'ReplayUpCommand',
'ReplayRightCommand',
'ReplayDownCommand',
'ReplayLeftCommand',
'ReplayDirectionCommand',
'ReplayAutoPathfindCommand',
'ReplayUseItemCommand',
'ReplayEquipCommand',
@ -446,6 +425,24 @@ describe('replay safety decorators', () => {
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(

View File

@ -2,8 +2,7 @@ import {
FaceDirection,
IReplayStepHandler,
IReplaySystem,
IReplayCommand,
shouldReplay
IReplayCommand
} from '@user/data-common';
import { EquipStatus } from '@user/data-base';
import {
@ -41,139 +40,44 @@ function resolveSlot(
return index < 0 ? null : index;
}
class ReplayUpCommand implements IReplayCommand {
constructor(private readonly state: IReplayCommandState) {
this.moveHero = shouldReplay('replay command: move hero')(
this.moveHero,
{
name: 'moveHero'
} as ClassMethodDecoratorContext<
ReplayUpCommand,
(this: ReplayUpCommand) => boolean
>
);
}
class ReplayDirectionCommand implements IReplayCommand {
constructor(
private readonly state: IReplayCommandState,
private readonly direction: FaceDirection
) {}
private moveHero(): boolean {
const mover = this.state.hero.location.mover;
if (mover.moving) return false;
mover.step(FaceDirection.Up);
const controller = mover.start();
if (!controller) return false;
return true;
private async moveHero(): Promise<boolean> {
try {
const mover = this.state.hero.location.mover;
if (mover.moving) return false;
mover.step(this.direction);
const controller = mover.start();
if (!controller) return false;
await controller.onEnd;
return true;
} catch {
return false;
}
}
execute(step: IReplayStepHandler): Promise<boolean> {
if (step.params.length !== 0) return Promise.resolve(false);
return Promise.resolve(this.moveHero());
}
}
class ReplayRightCommand implements IReplayCommand {
constructor(private readonly state: IReplayCommandState) {
this.moveHero = shouldReplay('replay command: move hero')(
this.moveHero,
{
name: 'moveHero'
} as ClassMethodDecoratorContext<
ReplayRightCommand,
(this: ReplayRightCommand) => boolean
>
);
}
private moveHero(): boolean {
const mover = this.state.hero.location.mover;
if (mover.moving) return false;
mover.step(FaceDirection.Right);
const controller = mover.start();
if (!controller) return false;
return true;
}
execute(step: IReplayStepHandler): Promise<boolean> {
if (step.params.length !== 0) return Promise.resolve(false);
return Promise.resolve(this.moveHero());
}
}
class ReplayDownCommand implements IReplayCommand {
constructor(private readonly state: IReplayCommandState) {
this.moveHero = shouldReplay('replay command: move hero')(
this.moveHero,
{
name: 'moveHero'
} as ClassMethodDecoratorContext<
ReplayDownCommand,
(this: ReplayDownCommand) => boolean
>
);
}
private moveHero(): boolean {
const mover = this.state.hero.location.mover;
if (mover.moving) return false;
mover.step(FaceDirection.Down);
const controller = mover.start();
if (!controller) return false;
return true;
}
execute(step: IReplayStepHandler): Promise<boolean> {
if (step.params.length !== 0) return Promise.resolve(false);
return Promise.resolve(this.moveHero());
}
}
class ReplayLeftCommand implements IReplayCommand {
constructor(private readonly state: IReplayCommandState) {
this.moveHero = shouldReplay('replay command: move hero')(
this.moveHero,
{
name: 'moveHero'
} as ClassMethodDecoratorContext<
ReplayLeftCommand,
(this: ReplayLeftCommand) => boolean
>
);
}
private moveHero(): boolean {
const mover = this.state.hero.location.mover;
if (mover.moving) return false;
mover.step(FaceDirection.Left);
const controller = mover.start();
if (!controller) return false;
return true;
}
execute(step: IReplayStepHandler): Promise<boolean> {
if (step.params.length !== 0) return Promise.resolve(false);
return Promise.resolve(this.moveHero());
return this.moveHero();
}
}
class ReplayAutoPathfindCommand implements IReplayCommand {
constructor(private readonly state: IReplayCommandState) {
this.moveToPoint = shouldReplay('replay command: pathfind hero')(
this.moveToPoint,
{
name: 'moveToPoint'
} as ClassMethodDecoratorContext<
ReplayAutoPathfindCommand,
(
this: ReplayAutoPathfindCommand,
x: number,
y: number
) => boolean
>
);
}
constructor(private readonly state: IReplayCommandState) {}
private moveToPoint(x: number, y: number): boolean {
const result = this.state.pathfinding.moveTo({ x, y });
if (!result) return false;
return true;
private async moveToPoint(x: number, y: number): Promise<boolean> {
try {
const result = this.state.pathfinding.moveTo({ x, y });
if (!result) return false;
await result.controller.onEnd;
return true;
} catch {
return false;
}
}
execute(step: IReplayStepHandler): Promise<boolean> {
@ -181,19 +85,12 @@ class ReplayAutoPathfindCommand implements IReplayCommand {
const x = step.params[0];
const y = step.params[1];
if (!isNumber(x) || !isNumber(y)) return Promise.resolve(false);
return Promise.resolve(this.moveToPoint(x, y));
return this.moveToPoint(x, y);
}
}
class ReplayUseItemCommand implements IReplayCommand {
constructor(private readonly state: IReplayCommandState) {
this.useItem = shouldReplay('replay command: use item')(this.useItem, {
name: 'useItem'
} as ClassMethodDecoratorContext<
ReplayUseItemCommand,
(this: ReplayUseItemCommand, item: number | string) => boolean
>);
}
constructor(private readonly state: IReplayCommandState) {}
private useItem(item: number | string): boolean {
return this.state.hero.items.useItem(item);
@ -208,19 +105,7 @@ class ReplayUseItemCommand implements IReplayCommand {
}
class ReplayEquipCommand implements IReplayCommand {
constructor(private readonly state: IReplayCommandState) {
this.equip = shouldReplay('replay command: equip item')(this.equip, {
name: 'equip'
} as ClassMethodDecoratorContext<
ReplayEquipCommand,
(
this: ReplayEquipCommand,
uid: number,
slot: number | string,
autoUnload: boolean | undefined
) => boolean
>);
}
constructor(private readonly state: IReplayCommandState) {}
private equip(
uid: number,
@ -258,17 +143,7 @@ class ReplayEquipCommand implements IReplayCommand {
}
class ReplayUnequipCommand implements IReplayCommand {
constructor(private readonly state: IReplayCommandState) {
this.unequip = shouldReplay('replay command: unequip item')(
this.unequip,
{
name: 'unequip'
} as ClassMethodDecoratorContext<
ReplayUnequipCommand,
(this: ReplayUnequipCommand, slot: number) => boolean
>
);
}
constructor(private readonly state: IReplayCommandState) {}
private unequip(slot: number): boolean {
const equipment = this.state.hero.equip;
@ -294,43 +169,35 @@ export function createReplayCommandItems(
return [
{
code: ReplayCommandCode.Up,
// prettier-ignore
command: new (ReplayUpCommand)(state)
command: new ReplayDirectionCommand(state, FaceDirection.Up)
},
{
code: ReplayCommandCode.Right,
// prettier-ignore
command: new (ReplayRightCommand)(state)
command: new ReplayDirectionCommand(state, FaceDirection.Right)
},
{
code: ReplayCommandCode.Down,
// prettier-ignore
command: new (ReplayDownCommand)(state)
command: new ReplayDirectionCommand(state, FaceDirection.Down)
},
{
code: ReplayCommandCode.Left,
// prettier-ignore
command: new (ReplayLeftCommand)(state)
command: new ReplayDirectionCommand(state, FaceDirection.Left)
},
{
code: ReplayCommandCode.AutoPathfindToPoint,
// prettier-ignore
command: new (ReplayAutoPathfindCommand)(state)
command: new ReplayAutoPathfindCommand(state)
},
{
code: ReplayCommandCode.UseItem,
// prettier-ignore
command: new (ReplayUseItemCommand)(state)
command: new ReplayUseItemCommand(state)
},
{
code: ReplayCommandCode.Equip,
// prettier-ignore
command: new (ReplayEquipCommand)(state)
command: new ReplayEquipCommand(state)
},
{
code: ReplayCommandCode.Unequip,
// prettier-ignore
command: new (ReplayUnequipCommand)(state)
command: new ReplayUnequipCommand(state)
}
];
}