187 lines
8.1 KiB
TypeScript
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]);
|
|
});
|
|
});
|