Files
healer-man/src/game/partyMovement.test.ts
T
2026-09-12 19:16:01 -04:00

280 lines
8.9 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
appendPartyBreadcrumb,
createPartyBreadcrumbCursor,
createPartyBreadcrumbTrail,
nextPartyBreadcrumbFollowWaypoint,
nextPartyBreadcrumbWaypoint,
partyCombatMovementGoal,
partyFormationPosition,
partyMovementProfile,
partyNaturalFollowPosition,
partyPersonalizedCombatMovementGoal,
partyPresentationY,
partyShouldMoveToComfortTarget,
personalizedPartyCombatRangeBand,
resetPartyBreadcrumbTrail,
stepPartyPosition,
synchronizePartyBreadcrumbCursor,
} from "./partyMovement";
describe("party world movement", () => {
it("steps without overshooting", () => {
const out: [number, number, number] = [0, 0, 0];
expect(stepPartyPosition([0, 0, 0], [3, 0, 4], 2, out)).toBe(2);
expect(out).toEqual([1.2, 0, 1.6]);
stepPartyPosition(out, [3, 0, 4], 10, out);
expect(out).toEqual([3, 0, 4]);
});
it("grounds presentation without undoing vertical navigation progress", () => {
const current: [number, number, number] = [0, 0, 0];
const next: [number, number, number] = [0, 0, 0];
stepPartyPosition(current, [0, 0.4, 0], 0.2, next);
expect(partyPresentationY(next[1], -0.1, 0.85)).toBe(-0.1);
expect(next).toEqual([0, 0.2, 0]);
});
it("places a formation relative to its travel direction", () => {
const out: [number, number, number] = [0, 0, 0];
partyFormationPosition([10, 2, 20], 0, 1, 2, 3, out);
expect(out).toEqual([12, 2, 17]);
});
it("builds stable and distinct bounded movement personalities", () => {
const first = partyMovementProfile("member-a", 0);
const repeat = partyMovementProfile("member-a", 0);
const second = partyMovementProfile("member-b", 1);
expect(first).toEqual(repeat);
expect(second).not.toEqual(first);
for (const profile of [first, second]) {
expect(Math.abs(profile.followDistanceJitter)).toBeLessThanOrEqual(0.35);
expect(Math.abs(profile.lateralOffset)).toBeLessThanOrEqual(0.65);
expect(profile.comfortSlack).toBeGreaterThanOrEqual(0.35);
expect(profile.comfortSlack).toBeLessThanOrEqual(0.85);
expect(profile.speedMultiplier).toBeGreaterThanOrEqual(0.94);
expect(profile.speedMultiplier).toBeLessThanOrEqual(1.06);
expect(Math.abs(profile.combatAngleOffset)).toBeLessThanOrEqual(50 * Math.PI / 180);
}
});
it("places natural followers inside personal spacing and movement bands", () => {
const profile = partyMovementProfile("member-c", 2);
const out: [number, number, number] = [0, 0, 0];
partyNaturalFollowPosition([10, 2, 20], 0, 1, 3.75, profile, 5_000, out);
expect(out[1]).toBe(2);
expect(Math.abs(out[0] - 10)).toBeLessThanOrEqual(0.83);
expect(20 - out[2]).toBeGreaterThanOrEqual(3.4);
expect(20 - out[2]).toBeLessThanOrEqual(4.1);
expect(partyShouldMoveToComfortTarget(out, out, false, profile.comfortSlack)).toBe(false);
expect(partyShouldMoveToComfortTarget([0, 0, 0], out, false, profile.comfortSlack)).toBe(true);
});
it("keeps personalized combat bands inside role-safe bounds", () => {
const profiles = [
partyMovementProfile("ranged-a", 1),
partyMovementProfile("ranged-b", 2),
];
for (const profile of profiles) {
const ranged = personalizedPartyCombatRangeBand({
minimum: 10,
preferred: 14.5,
maximum: 18.1,
}, profile);
expect(ranged.minimum).toBeGreaterThanOrEqual(9);
expect(ranged.maximum).toBeLessThanOrEqual(18.1);
expect(ranged.preferred).toBeGreaterThan(ranged.minimum);
expect(ranged.preferred).toBeLessThan(ranged.maximum);
const melee = personalizedPartyCombatRangeBand({
minimum: 0,
preferred: 3.3,
maximum: 3.55,
}, profile);
expect(melee.minimum).toBe(0);
expect(melee.maximum).toBeLessThanOrEqual(3.55);
expect(melee.preferred).toBeLessThan(melee.maximum);
}
});
it("uses a stable combat bearing without forcing in-range actors to reposition", () => {
const out: [number, number, number] = [0, 0, 0];
expect(partyPersonalizedCombatMovementGoal(
[20, 0, 0],
[0, 0, 0],
true,
{ minimum: 10, preferred: 14, maximum: 18 },
Math.PI / 2,
out,
)).toBe("approach");
expect(out[0]).toBeCloseTo(14);
expect(out[1]).toBe(0);
expect(out[2]).toBeCloseTo(0);
expect(partyPersonalizedCombatMovementGoal(
[1, 0, 1],
[0, 0, 0],
true,
{ minimum: 0, preferred: 2.9, maximum: 3.15 },
Math.PI / 2,
out,
)).toBe("hold");
});
it("makes melee approach a fleeing target and hold once in range", () => {
const out: [number, number, number] = [0, 0, 0];
expect(partyCombatMovementGoal(
[0, 0, 0],
[8, 0, 0],
true,
0,
3,
3.5,
out,
)).toBe("approach");
expect(out).toEqual([8, 0, 0]);
expect(partyCombatMovementGoal(
[5, 0, 0],
[8, 0, 0],
true,
0,
3,
3.5,
out,
)).toBe("hold");
});
it("keeps ranged attackers in a distance band", () => {
const out: [number, number, number] = [0, 0, 0];
expect(partyCombatMovementGoal(
[12, 0, 0],
[0, 0, 0],
true,
10,
14.5,
18,
out,
)).toBe("hold");
expect(partyCombatMovementGoal(
[4, 0, 0],
[0, 0, 0],
true,
10,
14.5,
18,
out,
)).toBe("retreat");
expect(out).toEqual([14.5, 0, 0]);
});
it("approaches until static line of sight is restored", () => {
const out: [number, number, number] = [0, 0, 0];
expect(partyCombatMovementGoal(
[12, 0, 0],
[0, 0, 0],
false,
10,
14.5,
18,
out,
)).toBe("approach");
expect(out).toEqual([0, 0, 0]);
});
it("makes a lagging follower consume an L-shaped breadcrumb trail without cutting the corner", () => {
const trail = createPartyBreadcrumbTrail();
for (const point of [
[0, 0, 0],
[1, 0, 0],
[2, 0, 0],
[2, 0, 1],
[2, 0, 2],
] as const) appendPartyBreadcrumb(trail, point, 0.1, 20);
const cursor = createPartyBreadcrumbCursor();
const current: [number, number, number] = [0, 0, 0];
const target: [number, number, number] = [0, 0, 0];
const visited: [number, number, number][] = [];
for (let step = 0; step < 4; step += 1) {
expect(nextPartyBreadcrumbWaypoint(trail, cursor, current, 0, 0.1, target)).toBe(true);
current[0] = target[0];
current[1] = target[1];
current[2] = target[2];
visited.push([...current]);
}
expect(visited).toEqual([
[1, 0, 0],
[2, 0, 0],
[2, 0, 1],
[2, 0, 2],
]);
});
it("does not skip a close-spaced production breadcrumb at an L-corner", () => {
const trail = createPartyBreadcrumbTrail();
for (const point of [
[0, 0, 0],
[0.45, 0, 0],
[0.45, 0, 0.45],
] as const) appendPartyBreadcrumb(trail, point, 0.44, 20);
const cursor = createPartyBreadcrumbCursor();
const out: [number, number, number] = [0, 0, 0];
expect(nextPartyBreadcrumbWaypoint(trail, cursor, [0, 0, 0], 0, 0.12, out)).toBe(true);
expect(out).toEqual([0.45, 0, 0]);
});
it("keeps trailing members behind the newest breadcrumb and survives trail pruning", () => {
const trail = createPartyBreadcrumbTrail();
const cursor = createPartyBreadcrumbCursor();
const out: [number, number, number] = [0, 0, 0];
for (let x = 0; x <= 8; x += 1) appendPartyBreadcrumb(trail, [x, 0, 0], 0.1, 4);
expect(trail.points[0].position[0]).toBeGreaterThan(0);
expect(nextPartyBreadcrumbWaypoint(trail, cursor, [4, 0, 0], 2, 0.1, out)).toBe(true);
expect(out[0]).toBeLessThanOrEqual(6);
resetPartyBreadcrumbTrail(trail, [20, 3, 4]);
cursor.breadcrumbId = null;
expect(nextPartyBreadcrumbWaypoint(trail, cursor, [20, 3, 4], 0, 0.1, out)).toBe(true);
expect(out).toEqual([20, 3, 4]);
});
it("synchronizes direct followers to the matching safe trail point", () => {
const trail = createPartyBreadcrumbTrail();
for (let x = 0; x <= 6; x += 1) appendPartyBreadcrumb(trail, [x, 0, 0], 0.1, 20);
const cursor = createPartyBreadcrumbCursor();
synchronizePartyBreadcrumbCursor(trail, cursor, 2);
expect(cursor.breadcrumbId).toBe(trail.points.find((point) => point.position[0] === 4)?.id);
});
it("lets a distant follower rejoin a trail before it is long enough for formation spacing", () => {
const trail = createPartyBreadcrumbTrail();
const cursor = createPartyBreadcrumbCursor();
const out: [number, number, number] = [0, 0, 0];
appendPartyBreadcrumb(trail, [10, 0, 0], 0, 20);
expect(nextPartyBreadcrumbFollowWaypoint(
trail,
cursor,
[0, 0, 0],
2,
0.1,
out,
)).toBe(true);
expect(out).toEqual([10, 0, 0]);
cursor.breadcrumbId = null;
expect(nextPartyBreadcrumbFollowWaypoint(
trail,
cursor,
[8.5, 0, 0],
2,
0.1,
out,
)).toBe(false);
});
});