280 lines
8.9 KiB
TypeScript
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);
|
|
});
|
|
});
|