import { describe, expect, it, vi } from "vitest"; import { ENEMY_FEAR_SOURCE_RULES, planEnemyFearMovement, stepEnemyFearTowardDestination, type EnemyFearMovementInput, type EnemyFearMovementSpatialAdapter, type EnemyFearPosition, } from "./enemyFearMovement"; const distance = (left: EnemyFearPosition, right: EnemyFearPosition) => Math.hypot( right[0] - left[0], right[1] - left[1], right[2] - left[2], ); function input(overrides: Partial = {}): EnemyFearMovementInput { return { actorId: "party:healer", sourceActorId: "mob:captain", fearAppliedAtMs: 10_000, decisionIndex: 0, nowMs: 10_100, fearEndsAtMs: 15_000, stunEndsAtMs: null, currentPosition: [10, 0, 0], baseMovementSpeed: 4, sourceSpeedMultiplier: 1, worldUnitsPerYard: 1, getSourcePosition: () => [0, 0, 0], ...overrides, }; } function spatial(overrides: Partial = {}): EnemyFearMovementSpatialAdapter { return { projectDestination: (requested) => requested, hasLineOfSight: () => true, pathLength: (from, to) => distance(from, to), ...overrides, }; } function movePlan( plan: ReturnType, ): Extract, { kind: "move" }> { expect(plan.kind).toBe("move"); if (plan.kind !== "move") throw new Error(`Expected a move plan, received ${plan.reason}.`); return plan; } describe("source-bounded enemy fear movement", () => { it("chooses the same near-source destination for the same actor decision and moves away", () => { const request = input({ currentPosition: [10, 0, 0] }); const first = movePlan(planEnemyFearMovement(request, spatial())); const repeated = movePlan(planEnemyFearMovement(request, spatial())); const away = [request.currentPosition[0] - 0, request.currentPosition[2] - 0]; const step = [first.destination[0] - request.currentPosition[0], first.destination[2] - request.currentPosition[2]]; const distanceYards = distance(request.currentPosition, first.requestedDestination); expect(repeated.destination).toEqual(first.destination); expect(first.band).toBe("near-source"); expect(step[0] * away[0] + step[1] * away[1]).toBeGreaterThan(0); expect(distanceYards).toBeGreaterThanOrEqual(0.4 * (28 - 10)); expect(distanceYards).toBeLessThanOrEqual(1.3 * (28 - 10)); }); it("changes seeded choices when actor, source, or decision scope changes", () => { const baseline = movePlan(planEnemyFearMovement(input(), spatial())); const otherActor = movePlan(planEnemyFearMovement(input({ actorId: "summon:pet" }), spatial())); const otherSource = movePlan(planEnemyFearMovement(input({ sourceActorId: "mob:guard" }), spatial())); const nextDecision = movePlan(planEnemyFearMovement(input({ decisionIndex: 1 }), spatial())); expect(otherActor.destination).not.toEqual(baseline.destination); expect(otherSource.destination).not.toEqual(baseline.destination); expect(nextDecision.destination).not.toEqual(baseline.destination); }); it("moves partly toward a far source and wanders inside the quiet band", () => { const far = movePlan(planEnemyFearMovement(input({ currentPosition: [50, 0, 0], getSourcePosition: () => [0, 0, 0], }), spatial())); const nearSourceDistanceAfter = distance(far.destination, [0, 0, 0]); const quiet = movePlan(planEnemyFearMovement(input({ currentPosition: [35, 0, 0], getSourcePosition: () => [0, 0, 0], }), spatial())); expect(far.band).toBe("far-source"); expect(nearSourceDistanceAfter).toBeLessThan(50); expect(distance([35, 0, 0], quiet.requestedDestination)).toBeGreaterThanOrEqual(0.6 * 15); expect(distance([35, 0, 0], quiet.requestedDestination)).toBeLessThanOrEqual(1.2 * 15); expect(quiet.band).toBe("quiet-range"); }); it("uses the source-lost fallback without inventing a frozen cast origin", () => { const getSourcePosition = vi.fn(() => null); let selected: ReturnType | undefined; let selectedDecision = -1; for (let decisionIndex = 0; decisionIndex < 32; decisionIndex += 1) { const candidate = planEnemyFearMovement(input({ sourceActorId: "mob:removed-caster", getSourcePosition, decisionIndex, }), spatial()); if (candidate.kind === "move") { selected = candidate; selectedDecision = decisionIndex; break; } } const plan = movePlan(selected ?? { kind: "hold", reason: "path-rejected", retryAfterMs: 100 }); expect(getSourcePosition).toHaveBeenCalledTimes(selectedDecision + 1); expect(getSourcePosition).toHaveBeenCalledWith("mob:removed-caster"); expect(plan.band).toBe("source-lost"); expect(distance([10, 0, 0], plan.requestedDestination)).toBeGreaterThanOrEqual(0.4 * 28); expect(distance([10, 0, 0], plan.requestedDestination)).toBeLessThanOrEqual(30); }); it("gives an active stun priority over fear and keeps speed and expiry actor-local", () => { const sourceLookup = vi.fn(() => [0, 0, 0] as const); const stunned = planEnemyFearMovement(input({ stunEndsAtMs: 10_500, getSourcePosition: sourceLookup, }), spatial()); const fastActor = movePlan(planEnemyFearMovement(input({ baseMovementSpeed: 4, sourceSpeedMultiplier: 1.25, }), spatial())); const slowerActor = movePlan(planEnemyFearMovement(input({ actorId: "party:scout", baseMovementSpeed: 3, sourceSpeedMultiplier: 1, fearEndsAtMs: 10_200, }), spatial())); const expired = planEnemyFearMovement(input({ actorId: "party:scout", nowMs: 10_200, fearEndsAtMs: 10_200, getSourcePosition: sourceLookup, }), spatial()); expect(stunned).toMatchObject({ kind: "hold", reason: "stun", retryAfterMs: null }); expect(sourceLookup).not.toHaveBeenCalled(); expect(fastActor.movementSpeed).toBe(5); expect(slowerActor.movementSpeed).toBe(3); expect(expired).toMatchObject({ kind: "hold", reason: "fear-inactive" }); }); it("gates destination goals through map projection, LOS, and bounded paths", () => { const mapRejected = planEnemyFearMovement(input(), spatial({ projectDestination: () => null })); const noSight = planEnemyFearMovement(input(), spatial({ hasLineOfSight: () => false })); const noPath = planEnemyFearMovement(input(), spatial({ pathLength: () => null })); const shortPath = planEnemyFearMovement(input(), spatial({ pathLength: () => 1 })); const longPath = planEnemyFearMovement(input(), spatial({ pathLength: () => 31 })); expect(mapRejected).toMatchObject({ kind: "hold", reason: "map-rejected", retryAfterMs: 100 }); expect(noSight).toMatchObject({ kind: "hold", reason: "line-of-sight", retryAfterMs: 200 }); expect(noPath).toMatchObject({ kind: "hold", reason: "path-rejected", retryAfterMs: 100 }); expect(shortPath).toMatchObject({ kind: "hold", reason: "path-too-short", retryAfterMs: 100 }); expect(longPath).toMatchObject({ kind: "hold", reason: "path-too-long", retryAfterMs: 100 }); }); it("uses source path limits and estimates the next repath from actor-local speed", () => { const plan = movePlan(planEnemyFearMovement(input({ baseMovementSpeed: 2, sourceSpeedMultiplier: 1.25, }), spatial({ pathLength: () => 10 }))); expect(ENEMY_FEAR_SOURCE_RULES.maxPathLengthYards).toBe(30); expect(ENEMY_FEAR_SOURCE_RULES.minPathLengthYards).toBe(2); expect(plan.movementSpeed).toBe(2.5); expect(plan.seededWaitMs).toBeGreaterThanOrEqual(800); expect(plan.seededWaitMs).toBeLessThanOrEqual(1_500); expect(plan.nextDecisionAtMs).toBe(10_100 + 10 / 2.5 * 1_000 + plan.seededWaitMs); }); it("returns bounded steering steps without assigning the route goal directly", () => { const step = stepEnemyFearTowardDestination([0, 4, 0], [3, 9, 4], 2); expect(step[0]).toBeCloseTo(1.2); expect(step[1]).toBeCloseTo(6); expect(step[2]).toBeCloseTo(1.6); expect(stepEnemyFearTowardDestination([0, 4, 0], [3, 9, 4], 100)).toEqual([3, 9, 4]); expect(stepEnemyFearTowardDestination([0, 4, 0], [3, 9, 4], 0)).toEqual([0, 4, 0]); }); });