mirror of
https://github.com/motajs/template.git
synced 2026-09-13 10:18:50 +08:00
- Queue new movement until the stopped controller settles - Register pathfinding controller diagnostics and regression coverage
359 lines
11 KiB
TypeScript
359 lines
11 KiB
TypeScript
import { beforeAll, describe, expect, it, vi } from 'vitest';
|
|
import { ITileStore } from '@user/data-common';
|
|
|
|
vi.hoisted(() => {
|
|
vi.stubGlobal('main', { replayChecking: true });
|
|
vi.stubGlobal('location', { origin: 'http://localhost' });
|
|
Map.prototype.getOrInsertComputed ??= function <K, V>(
|
|
this: Map<K, V>,
|
|
key: K,
|
|
callback: (key: K) => V
|
|
): V {
|
|
const existing = this.get(key);
|
|
if (existing !== undefined) return existing;
|
|
const value = callback(key);
|
|
this.set(key, value);
|
|
return value;
|
|
};
|
|
});
|
|
|
|
interface TestModules {
|
|
EventExecutor: typeof import('@user/data-system').EventExecutor;
|
|
DefaultHeroMoveTopImpl: typeof import('../hero').DefaultHeroMoveTopImpl;
|
|
HeroMover: typeof import('@user/data-base').HeroMover;
|
|
HeroPathfinding: typeof import('./heroPathfinding').HeroPathfinding;
|
|
MapState: typeof import('@user/data-base').MapState;
|
|
TileStore: typeof import('@user/data-common').TileStore;
|
|
Dir8FaceHandler: typeof import('@user/data-common').Dir8FaceHandler;
|
|
FaceManager: typeof import('@user/data-common').FaceManager;
|
|
RoleFaceBinder: typeof import('@user/data-common').RoleFaceBinder;
|
|
}
|
|
|
|
interface EventCall {
|
|
readonly id: string;
|
|
readonly trigger: number;
|
|
readonly hero: Readonly<{ x: number; y: number }>;
|
|
}
|
|
|
|
interface HeroFixture {
|
|
x: number;
|
|
y: number;
|
|
floorId: string;
|
|
state: unknown;
|
|
mover?: { readonly faceDirection: number };
|
|
setPos(x: number, y: number): void;
|
|
getCurrentFaceDirection(): number;
|
|
}
|
|
|
|
let modules: TestModules;
|
|
|
|
beforeAll(async () => {
|
|
vi.stubGlobal('main', { replayChecking: true });
|
|
vi.stubGlobal('location', { origin: 'http://localhost' });
|
|
const base = await import('@user/data-base');
|
|
const common = await import('@user/data-common');
|
|
const state = await import('../hero');
|
|
const path = await import('./heroPathfinding');
|
|
const event = await import('@user/data-system');
|
|
modules = {
|
|
EventExecutor: event.EventExecutor,
|
|
DefaultHeroMoveTopImpl: state.DefaultHeroMoveTopImpl,
|
|
HeroMover: base.HeroMover,
|
|
HeroPathfinding: path.HeroPathfinding,
|
|
MapState: base.MapState,
|
|
TileStore: common.TileStore,
|
|
Dir8FaceHandler: common.Dir8FaceHandler,
|
|
FaceManager: common.FaceManager,
|
|
RoleFaceBinder: common.RoleFaceBinder
|
|
};
|
|
});
|
|
|
|
interface FixtureOptions {
|
|
readonly middleEvent?: boolean;
|
|
readonly targetNoPass?: boolean;
|
|
readonly sealedTarget?: boolean;
|
|
}
|
|
|
|
function createFixture(options: FixtureOptions = {}) {
|
|
const tileStore: ITileStore = new modules.TileStore() as never;
|
|
tileStore.addTile({
|
|
num: 1,
|
|
id: 'floor',
|
|
events: {},
|
|
type: 0,
|
|
pass: { onlyEvents: false, outPass: 15, inPass: 15 },
|
|
eventPass: true
|
|
});
|
|
tileStore.addTile({
|
|
num: 2,
|
|
id: 'wall',
|
|
events: { 40: 'wall-touch' },
|
|
type: 0,
|
|
pass: { onlyEvents: false, outPass: 0, inPass: 0 },
|
|
eventPass: true
|
|
});
|
|
const faceManager = new modules.FaceManager();
|
|
const faceHandler = new modules.Dir8FaceHandler();
|
|
faceManager.register(1, faceHandler);
|
|
const commonState = {
|
|
tileStore,
|
|
itemStore: {},
|
|
mapStore: {},
|
|
eventStore: {},
|
|
roleFace: new modules.RoleFaceBinder(),
|
|
faceManager,
|
|
saveSystem: {}
|
|
} as never;
|
|
const maps = new modules.MapState(tileStore, commonState);
|
|
const eventMap = maps.fromRaw({
|
|
floorId: 'F1',
|
|
width: 3,
|
|
map: {
|
|
0: options.sealedTarget
|
|
? [1, 2, 2]
|
|
: options.targetNoPass
|
|
? [1, 1, 2]
|
|
: [1, 1, 1]
|
|
},
|
|
layerAlias: { 0: 'event' },
|
|
events: {
|
|
0:
|
|
options.middleEvent === false
|
|
? {}
|
|
: { 1: { 30: 'middle-enter' } }
|
|
}
|
|
})!;
|
|
const events = new Map<string, object>();
|
|
const store = {
|
|
addEvent() {},
|
|
getEvent<
|
|
_P extends Record<string, unknown>,
|
|
_E extends Record<string, unknown>,
|
|
_R = void
|
|
>(id: string) {
|
|
return events.get(id) as never;
|
|
}
|
|
};
|
|
const executor = new modules.EventExecutor({} as never, () => store);
|
|
const state = {
|
|
maps,
|
|
eventSystem: { executor }
|
|
};
|
|
const hero: HeroFixture = {
|
|
x: 0,
|
|
y: 0,
|
|
floorId: 'F1',
|
|
state,
|
|
setPos(x: number, y: number) {
|
|
hero.x = x;
|
|
hero.y = y;
|
|
},
|
|
getCurrentFaceDirection() {
|
|
return 4;
|
|
}
|
|
};
|
|
const mover = new modules.HeroMover(hero as never, faceHandler);
|
|
hero.mover = mover;
|
|
const heroState = { location: hero };
|
|
const fixtureState = { ...state, hero: heroState } as never;
|
|
const topImpl = new modules.DefaultHeroMoveTopImpl(fixtureState);
|
|
mover.useTopImplementation(topImpl);
|
|
const pathfinding = new modules.HeroPathfinding(fixtureState, topImpl);
|
|
return {
|
|
eventMap,
|
|
events,
|
|
executor,
|
|
hero: heroState.location,
|
|
pathfinding,
|
|
state: fixtureState
|
|
};
|
|
}
|
|
|
|
function addEvent(
|
|
events: Map<string, object>,
|
|
id: string,
|
|
trigger: number,
|
|
calls: EventCall[]
|
|
): void {
|
|
events.set(id, {
|
|
trigger,
|
|
execute: async (
|
|
_param: unknown,
|
|
env: {
|
|
trigger: number;
|
|
heroLocator: Readonly<{ x: number; y: number }>;
|
|
}
|
|
) => {
|
|
calls.push({ id, trigger: env.trigger, hero: env.heroLocator });
|
|
return true;
|
|
}
|
|
});
|
|
}
|
|
|
|
function addBlockingEvent(
|
|
events: Map<string, object>,
|
|
id: string,
|
|
trigger: number,
|
|
calls: EventCall[],
|
|
blocked: Promise<void>
|
|
): void {
|
|
events.set(id, {
|
|
trigger,
|
|
execute: async (
|
|
_param: unknown,
|
|
env: {
|
|
trigger: number;
|
|
heroLocator: Readonly<{ x: number; y: number }>;
|
|
}
|
|
) => {
|
|
calls.push({ id, trigger: env.trigger, hero: env.heroLocator });
|
|
await blocked;
|
|
return true;
|
|
}
|
|
});
|
|
}
|
|
|
|
describe('hero pathfinding integration', () => {
|
|
// 验证勇士按最小路径逐步移动并按事件链顺序执行途经事件
|
|
it('moves the hero to the target and triggers the traversed event', async () => {
|
|
const calls: EventCall[] = [];
|
|
const fixture = createFixture();
|
|
addEvent(fixture.events, 'middle-enter', 2, calls);
|
|
|
|
const path = fixture.pathfinding.getPath({ x: 2, y: 0 });
|
|
expect(path.map(step => step.to)).toEqual([
|
|
{ x: 1, y: 0 },
|
|
{ x: 2, y: 0 }
|
|
]);
|
|
const result = fixture.pathfinding.moveTo({ x: 2, y: 0 });
|
|
expect(result).not.toBeNull();
|
|
await result!.controller.onEnd;
|
|
|
|
expect({ x: fixture.hero.x, y: fixture.hero.y }).toEqual({
|
|
x: 2,
|
|
y: 0
|
|
});
|
|
expect(calls).toEqual([
|
|
{ id: 'middle-enter', trigger: 2, hero: { x: 1, y: 0 } }
|
|
]);
|
|
});
|
|
|
|
// 验证无事件路径的瞬移一步到达目标
|
|
it('teleports directly when the path has no events', async () => {
|
|
const fixture = createFixture({ middleEvent: false });
|
|
const result = fixture.pathfinding.teleportTo({ x: 2, y: 0 });
|
|
expect(result).not.toBeNull();
|
|
await result!.controller.onEnd;
|
|
|
|
expect({ x: fixture.hero.x, y: fixture.hero.y }).toEqual({
|
|
x: 2,
|
|
y: 0
|
|
});
|
|
});
|
|
|
|
// 验证默认策略检测途经事件并自动回退为逐步移动
|
|
it('falls back to step movement when the path has an event', async () => {
|
|
const calls: EventCall[] = [];
|
|
const fixture = createFixture();
|
|
addEvent(fixture.events, 'middle-enter', 2, calls);
|
|
|
|
const result = fixture.pathfinding.teleportTo({ x: 2, y: 0 });
|
|
expect(result).not.toBeNull();
|
|
await result!.controller.onEnd;
|
|
|
|
expect({ x: fixture.hero.x, y: fixture.hero.y }).toEqual({
|
|
x: 2,
|
|
y: 0
|
|
});
|
|
expect(calls).toEqual([
|
|
{ id: 'middle-enter', trigger: 2, hero: { x: 1, y: 0 } }
|
|
]);
|
|
});
|
|
|
|
// 验证 no-pass 目标移动至相邻格、面朝目标并派发 OnTouch
|
|
it('touches a no-pass target from its reachable adjacent cell', async () => {
|
|
const calls: EventCall[] = [];
|
|
const fixture = createFixture({
|
|
middleEvent: false,
|
|
targetNoPass: true
|
|
});
|
|
addEvent(fixture.events, 'wall-touch', 1, calls);
|
|
|
|
const result = fixture.pathfinding.moveTo({ x: 2, y: 0 });
|
|
expect(result).not.toBeNull();
|
|
await result!.controller.onEnd;
|
|
|
|
expect({ x: fixture.hero.x, y: fixture.hero.y }).toEqual({
|
|
x: 1,
|
|
y: 0
|
|
});
|
|
expect(fixture.hero.mover!.faceDirection).toBe(3);
|
|
expect(calls).toEqual([
|
|
{ id: 'wall-touch', trigger: 1, hero: { x: 1, y: 0 } }
|
|
]);
|
|
});
|
|
|
|
// 验证四邻无可达格时不可达目标不移动且不触发事件
|
|
it('ignores a no-pass target without a reachable adjacent cell', () => {
|
|
const fixture = createFixture({ sealedTarget: true });
|
|
const path = fixture.pathfinding.getPath({ x: 2, y: 0 });
|
|
|
|
expect(path).toEqual([]);
|
|
expect(fixture.pathfinding.moveTo({ x: 2, y: 0 })).toBeNull();
|
|
expect({ x: fixture.hero.x, y: fixture.hero.y }).toEqual({
|
|
x: 0,
|
|
y: 0
|
|
});
|
|
});
|
|
|
|
// 验证新寻路会停止旧移动并从兑现后的最新坐标接管
|
|
it('hands over a moving path to a new target', async () => {
|
|
const calls: EventCall[] = [];
|
|
let release: () => void = () => {};
|
|
const blocked = new Promise<void>(resolve => {
|
|
release = resolve;
|
|
});
|
|
const fixture = createFixture();
|
|
addBlockingEvent(fixture.events, 'middle-enter', 2, calls, blocked);
|
|
|
|
fixture.pathfinding.moveTo({ x: 2, y: 0 });
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
const next = fixture.pathfinding.moveTo({ x: 0, y: 0 });
|
|
expect(next).not.toBeNull();
|
|
release();
|
|
await next!.controller.onEnd;
|
|
|
|
expect({ x: fixture.hero.x, y: fixture.hero.y }).toEqual({
|
|
x: 0,
|
|
y: 0
|
|
});
|
|
});
|
|
|
|
// 验证显式打断后移动器可再次启动新的寻路
|
|
it('leaves the mover restartable after explicit interruption', async () => {
|
|
const calls: EventCall[] = [];
|
|
let release: () => void = () => {};
|
|
const blocked = new Promise<void>(resolve => {
|
|
release = resolve;
|
|
});
|
|
const fixture = createFixture();
|
|
addBlockingEvent(fixture.events, 'middle-enter', 2, calls, blocked);
|
|
|
|
fixture.pathfinding.moveTo({ x: 2, y: 0 });
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
const interrupted = fixture.pathfinding.interrupt();
|
|
release();
|
|
await interrupted;
|
|
|
|
const resumed = fixture.pathfinding.moveTo({ x: 0, y: 0 });
|
|
expect(resumed).not.toBeNull();
|
|
await resumed!.controller.onEnd;
|
|
expect({ x: fixture.hero.x, y: fixture.hero.y }).toEqual({
|
|
x: 0,
|
|
y: 0
|
|
});
|
|
});
|
|
});
|