forked from phenom/healer-man
134 lines
6.4 KiB
TypeScript
134 lines
6.4 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { createMobPursuit, chooseMobPursuitLanding, type MobPursuitSample, type MobPursuitSpatial } from "./mobPursuit";
|
|
import type { PartyNavigationGraph } from "./partyNavigation";
|
|
|
|
function sample(patch: Partial<MobPursuitSample> = {}): MobPursuitSample {
|
|
return { id: "mob", targetId: "tank", position: [0, 0, 0], target: [4, 0, 0], home: [0, 0, 0],
|
|
phase: "chasing", health: 100, engaged: true, active: true, casting: false, controlled: false,
|
|
attackRange: 1.5, leashRange: 40, maximumStep: 0, nowMs: 0, ...patch };
|
|
}
|
|
|
|
function spatial(patch: Partial<MobPursuitSpatial> = {}): MobPursuitSpatial {
|
|
return { graph: () => null, hasLineOfSight: () => false, directAllowed: () => false,
|
|
routeAllowed: () => true, validateLanding: (point) => point, ...patch };
|
|
}
|
|
|
|
describe("wall-aware mob pursuit", () => {
|
|
it("moves toward a blocked target even when the old combat snapshot says attacking", () => {
|
|
const navigator = createMobPursuit(spatial({ directAllowed: () => true }));
|
|
const state = sample({ phase: "attacking", attackRange: 6, maximumStep: 0.2 });
|
|
expect(navigator.step(state)).toBe("none");
|
|
expect(state.position[0]).toBeCloseTo(0.2);
|
|
});
|
|
|
|
it("holds only when range and clear sight agree", () => {
|
|
const navigator = createMobPursuit(spatial({ hasLineOfSight: () => true, directAllowed: () => true }));
|
|
const state = sample({ attackRange: 6, maximumStep: 0.2 });
|
|
navigator.step(state);
|
|
expect(state.position).toEqual([0, 0, 0]);
|
|
expect(navigator.step({ ...state, nowMs: 10_000 })).toBe("none");
|
|
});
|
|
|
|
it("routes around a wall through a doorway rather than cutting through it", () => {
|
|
const positions = [[-2, 0, 0], [-2, 0, 3], [2, 0, 3], [2, 0, 0]] as const;
|
|
const graph: PartyNavigationGraph = {
|
|
nodes: positions.map((position, id) => ({ id, position, neighbors: [id - 1, id + 1]
|
|
.filter((to) => to >= 0 && to < positions.length).map((to) => ({ to, cost: 4 })) })),
|
|
weldTolerance: 0.001, sourceVertexCount: 4, sourceTriangleCount: 0,
|
|
weldedNodeCount: 4, componentCount: 1, discardedNodeCount: 0,
|
|
};
|
|
const clear = (a: readonly number[], b: readonly number[]) => {
|
|
if (a[0] * b[0] >= 0) return true;
|
|
const t = -a[0] / (b[0] - a[0]);
|
|
return a[2] + (b[2] - a[2]) * t >= 2.5;
|
|
};
|
|
const navigator = createMobPursuit(spatial({ graph: () => graph, directAllowed: clear, hasLineOfSight: clear }));
|
|
const state = sample({ position: [-2, 0, 0], home: [-2, 0, 0], target: [2, 0, 0], maximumStep: 0.1, attackRange: 0.5 });
|
|
let reachedDoorway = false;
|
|
for (let frame = 0; frame < 120; frame += 1) {
|
|
const previous = [...state.position];
|
|
expect(navigator.step({ ...state, nowMs: frame * 50 })).toBe("none");
|
|
expect(clear(previous, state.position)).toBe(true);
|
|
if (state.position[2] >= 2.5) reachedDoorway = true;
|
|
}
|
|
expect(reachedDoorway).toBe(true);
|
|
expect(Math.hypot(state.position[0] - 2, state.position[2])).toBeLessThanOrEqual(0.6);
|
|
});
|
|
|
|
it("recovers a nearby stalled mob only after five seconds and preserves a ten-second cooldown", () => {
|
|
const navigator = createMobPursuit(spatial({ hasLineOfSight: (a) => a[0] > 2 }));
|
|
const state = sample();
|
|
navigator.step(state);
|
|
expect(navigator.step({ ...state, nowMs: 2_000 })).toBe("none");
|
|
expect(navigator.step({ ...state, nowMs: 4_999 })).toBe("none");
|
|
expect(navigator.step({ ...state, nowMs: 5_000 })).toBe("teleported");
|
|
expect(state.position[0]).toBeGreaterThan(2);
|
|
state.position[0] = 0;
|
|
navigator.step({ ...state, nowMs: 5_001 });
|
|
navigator.step({ ...state, nowMs: 7_001 });
|
|
expect(navigator.step({ ...state, nowMs: 14_999 })).toBe("none");
|
|
expect(navigator.step({ ...state, nowMs: 15_000 })).toBe("teleported");
|
|
});
|
|
|
|
it.each([
|
|
{ active: false }, { casting: true }, { controlled: true }, { health: 0 }, { engaged: false },
|
|
])("does not recover intentional immobility %j", (exclusion) => {
|
|
const navigator = createMobPursuit(spatial());
|
|
const state = sample(exclusion);
|
|
navigator.step(state);
|
|
expect(navigator.step({ ...state, nowMs: 10_000 })).toBe("none");
|
|
expect(state.position).toEqual([0, 0, 0]);
|
|
});
|
|
|
|
it.each([
|
|
{ target: [12, 0, 0] as const }, { target: [4, 5, 0] as const }, { leashRange: 2 },
|
|
])("resets instead of teleporting to a distant, wrong-floor, or out-of-leash target %j", (patch) => {
|
|
const navigator = createMobPursuit(spatial());
|
|
const state = sample(patch);
|
|
navigator.step(state);
|
|
navigator.step({ ...state, nowMs: 2_000 });
|
|
expect(navigator.step({ ...state, nowMs: 5_000 })).toBe("reset");
|
|
expect(state.position).toEqual(state.home);
|
|
});
|
|
|
|
it("resets when every nearby target landing is blocked without applying an invalid home landing", () => {
|
|
const navigator = createMobPursuit(spatial({ validateLanding: () => null }));
|
|
const state = sample({ position: [1, 0, 0] });
|
|
navigator.step(state);
|
|
navigator.step({ ...state, nowMs: 2_000 });
|
|
expect(navigator.step({ ...state, nowMs: 5_000 })).toBe("reset");
|
|
expect(state.position).toEqual([1, 0, 0]);
|
|
});
|
|
|
|
it("recovers a stalled return to validated home, never to the old target", () => {
|
|
const navigator = createMobPursuit(spatial());
|
|
const state = sample({ engaged: false, phase: "returning", position: [5, 0, 0] });
|
|
navigator.step(state);
|
|
navigator.step({ ...state, nowMs: 2_000 });
|
|
expect(navigator.step({ ...state, nowMs: 5_000 })).toBe("home");
|
|
expect(state.position).toEqual(state.home);
|
|
});
|
|
|
|
it("bounds landing checks, rejects unsafe projections, and never blindly uses the target", () => {
|
|
let checks = 0;
|
|
const result = chooseMobPursuitLanding(sample(), spatial({
|
|
hasLineOfSight: () => true,
|
|
validateLanding: () => { checks += 1; return [20, 0, 0]; },
|
|
}));
|
|
expect(result).toBeNull();
|
|
expect(checks).toBe(16);
|
|
});
|
|
|
|
it("keeps different mobs' timers independent", () => {
|
|
const navigator = createMobPursuit(spatial({ hasLineOfSight: (a) => a[0] > 2 }));
|
|
const first = sample();
|
|
const second = sample({ id: "other", position: [0, 0, 0] });
|
|
navigator.step(first);
|
|
navigator.step({ ...second, nowMs: 4_000 });
|
|
navigator.step({ ...first, nowMs: 2_000 });
|
|
expect(navigator.step({ ...first, nowMs: 5_000 })).toBe("teleported");
|
|
expect(navigator.step({ ...second, nowMs: 5_000 })).toBe("none");
|
|
expect(second.position).toEqual([0, 0, 0]);
|
|
});
|
|
});
|