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 { 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 { 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]); }); });