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); }); });