Files
healer-man/src/game/enemyFearMovement.test.ts
T
2026-10-06 22:25:34 -04:00

187 lines
8.1 KiB
TypeScript

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> = {}): 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> = {}): EnemyFearMovementSpatialAdapter {
return {
projectDestination: (requested) => requested,
hasLineOfSight: () => true,
pathLength: (from, to) => distance(from, to),
...overrides,
};
}
function movePlan(
plan: ReturnType<typeof planEnemyFearMovement>,
): Extract<ReturnType<typeof planEnemyFearMovement>, { 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<typeof planEnemyFearMovement> | 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]);
});
});