import { describe, expect, it } from "vitest"; import { applyPartyStuckRecoveryLanding, choosePartyStuckRecoveryPosition, createPartyStuckRecoveryState, markPartyStuckRecovered, resetPartyStuckRecoveryProgress, updatePartyStuckRecovery, type PartyStuckRecoverySample, } from "./partyStuckRecovery"; import type { MutablePartyWorldPosition, PartyWorldPosition } from "./partyRuntimeRegistry"; const origin: PartyWorldPosition = [0, 0, 0]; const sample = (nowMs: number, overrides: Partial = {}): PartyStuckRecoverySample => ({ position: origin, waypoint: [10, 0, 0], goalKey: "follow:leader", movementExpected: true, health: 100, paused: false, stopped: false, casting: false, controlled: false, nowMs, ...overrides, }); describe("per-teammate stuck navigation", () => { it("replans at two seconds and recovers at five, without a player-distance gate", () => { const state = createPartyStuckRecoveryState(origin, 0); expect(updatePartyStuckRecovery(state, sample(0))).toBe("none"); expect(updatePartyStuckRecovery(state, sample(1_999))).toBe("none"); expect(updatePartyStuckRecovery(state, sample(2_000))).toBe("replan"); expect(updatePartyStuckRecovery(state, sample(2_500))).toBe("none"); expect(updatePartyStuckRecovery(state, sample(4_999))).toBe("none"); expect(updatePartyStuckRecovery(state, sample(5_000))).toBe("recover"); expect(updatePartyStuckRecovery(state, sample(5_100))).toBe("none"); expect(updatePartyStuckRecovery(state, sample(6_000))).toBe("recover"); }); it("does not restart the timer when a replan changes waypoints or yields no path", () => { const state = createPartyStuckRecoveryState(origin, 0); updatePartyStuckRecovery(state, sample(0)); expect(updatePartyStuckRecovery(state, sample(2_000))).toBe("replan"); updatePartyStuckRecovery(state, sample(3_000, { waypoint: [0, 0, 10] })); updatePartyStuckRecovery(state, sample(4_000, { waypoint: null })); expect(updatePartyStuckRecovery(state, sample(5_000, { waypoint: null }))).toBe("recover"); }); it("cancels back-and-forth doorway shuffling instead of treating it as repeated progress", () => { const state = createPartyStuckRecoveryState(origin, 0); updatePartyStuckRecovery(state, sample(0)); updatePartyStuckRecovery(state, sample(500, { position: [0.6, 0, 0] })); const actions: string[] = []; for (let tick = 1; tick <= 10; tick += 1) { actions.push(updatePartyStuckRecovery(state, sample(500 + tick * 500, { position: tick % 2 ? origin : [0.6, 0, 0], }))); } expect(actions).toContain("replan"); expect(actions.at(-1)).toBe("recover"); expect(state.lastProgressAtMs).toBe(500); }); it("counts movement toward route waypoints even when the route goes away from the player", () => { const state = createPartyStuckRecoveryState(origin, 0); updatePartyStuckRecovery(state, sample(0)); for (let tick = 1; tick <= 12; tick += 1) { expect(updatePartyStuckRecovery(state, sample(tick * 500, { position: [tick * 0.5, 0, 0] }))).toBe("none"); } expect(state.lastProgressAtMs).toBe(6_000); }); it("does not mistake target movement or sideways motion for useful actor progress", () => { const state = createPartyStuckRecoveryState(origin, 0); updatePartyStuckRecovery(state, sample(0)); for (let tick = 1; tick <= 5; tick += 1) { updatePartyStuckRecovery(state, sample(tick * 1_000, { position: [0, 0, tick % 2], waypoint: [10 + tick, 0, 0] })); } expect(state.lastProgressAtMs).toBe(0); }); it("advances through dense waypoint chains and corners without false recovery", () => { const state = createPartyStuckRecoveryState(origin, 0); updatePartyStuckRecovery(state, sample(0, { waypoint: [0.08, 0, 0] })); for (let frame = 1; frame <= 400; frame += 1) { const point = (step: number): PartyWorldPosition => step <= 200 ? [step * 0.08, 0, 0] : [16, 0, (step - 200) * 0.08]; expect(updatePartyStuckRecovery(state, sample(frame * 1000 / 60, { position: point(frame), waypoint: point(frame + 1), }))).toBe("none"); } expect(state.lastProgressAtMs).toBeGreaterThan(6_000); }); it.each([ { movementExpected: false }, { health: 0 }, { paused: true }, { stopped: true }, { casting: true }, { controlled: true }, ])("excludes intentional immobility %j and starts a fresh timer afterward", (excluded) => { const state = createPartyStuckRecoveryState(origin, 0); updatePartyStuckRecovery(state, sample(0)); expect(updatePartyStuckRecovery(state, sample(10_000, excluded))).toBe("none"); expect(updatePartyStuckRecovery(state, sample(11_000))).toBe("none"); expect(updatePartyStuckRecovery(state, sample(12_999))).toBe("none"); expect(updatePartyStuckRecovery(state, sample(13_000))).toBe("replan"); }); it("keeps a ten-second teleport cooldown across pauses, holds, and goal changes", () => { const state = createPartyStuckRecoveryState(origin, 0); markPartyStuckRecovered(state, origin, 5_000); updatePartyStuckRecovery(state, sample(5_100)); expect(updatePartyStuckRecovery(state, sample(7_100))).toBe("replan"); expect(updatePartyStuckRecovery(state, sample(10_100))).toBe("none"); resetPartyStuckRecoveryProgress(state); updatePartyStuckRecovery(state, sample(10_200, { goalKey: "mob:new-target" })); expect(updatePartyStuckRecovery(state, sample(12_200, { goalKey: "mob:new-target" }))).toBe("replan"); expect(updatePartyStuckRecovery(state, sample(14_999, { goalKey: "mob:new-target" }))).toBe("none"); expect(updatePartyStuckRecovery(state, sample(15_200, { goalKey: "mob:new-target" }))).toBe("recover"); }); it("keeps independent teammate timers", () => { const stuck = createPartyStuckRecoveryState(origin, 0); const moving = createPartyStuckRecoveryState(origin, 0); updatePartyStuckRecovery(stuck, sample(0)); updatePartyStuckRecovery(moving, sample(0)); updatePartyStuckRecovery(stuck, sample(2_000)); for (let tick = 1; tick <= 10; tick += 1) updatePartyStuckRecovery(moving, sample(tick * 500, { position: [tick * 0.5, 0, 0] })); expect(updatePartyStuckRecovery(stuck, sample(5_000))).toBe("recover"); expect(updatePartyStuckRecovery(moving, sample(5_001, { position: [5, 0, 0] }))).toBe("none"); expect(moving.cooldownUntilMs).toBe(0); }); it("moves only the affected teammate and starts only its teleport cooldown", () => { const positions = new Map([ ["tank", [0, 0, 0]], ["rogue", [2, 0, 1]], ["mage", [3, 0, 2]], ]); const states = new Map([...positions].map(([id, position]) => [id, createPartyStuckRecoveryState(position, 0)])); expect(applyPartyStuckRecoveryLanding(positions, "rogue", [10, 0, 0], states.get("rogue")!, 5_000)).toBe(true); expect(positions.get("rogue")).toEqual([10, 0, 0]); expect(positions.get("tank")).toEqual([0, 0, 0]); expect(positions.get("mage")).toEqual([3, 0, 2]); expect(states.get("rogue")!.cooldownUntilMs).toBe(15_000); expect(states.get("tank")!.cooldownUntilMs).toBe(0); expect(states.get("mage")!.cooldownUntilMs).toBe(0); }); it("does not apply missing or malformed landings", () => { const positions = new Map([["tank", [0, 0, 0]]]); const state = createPartyStuckRecoveryState(origin, 0); expect(applyPartyStuckRecoveryLanding(positions, "missing", [10, 0, 0], state, 5_000)).toBe(false); expect(applyPartyStuckRecoveryLanding(positions, "tank", [NaN, 0, 0], state, 5_000)).toBe(false); expect(positions.get("tank")).toEqual(origin); expect(state.cooldownUntilMs).toBe(0); }); it("handles malformed samples without poisoning later progress", () => { const state = createPartyStuckRecoveryState(origin, 0); expect(updatePartyStuckRecovery(state, sample(NaN))).toBe("none"); expect(updatePartyStuckRecovery(state, sample(1, { position: [NaN, 0, 0] }))).toBe("none"); expect(updatePartyStuckRecovery(state, sample(10_000))).toBe("none"); }); }); describe("safe stuck-recovery landings", () => { const player: PartyWorldPosition = [10, 0, 0]; const choose = (trailPoints: readonly PartyWorldPosition[], validate: (point: PartyWorldPosition) => PartyWorldPosition | null = (point) => point) => choosePartyStuckRecoveryPosition({ current: origin, player, groupAnchor: player, trailPoints, validate, }); it("prefers a visited trail point near the group over returning to the player", () => { expect(choose([[2, 0, 0], [9, 0, 0]])).toEqual({ position: [9, 0, 0], source: "trail" }); }); it("skips wrong floors, distant points, current position, and blocked/unsafe landings", () => { const result = choose([[0.2, 0, 0], [10, 5, 0], [50, 0, 0], [9, 0, 0], [8, 0, 0]], (point) => point[0] === 9 ? null : point); expect(result).toEqual({ position: [8, 0, 0], source: "trail" }); }); it("falls back to a validated player point when no safe local trail exists", () => { expect(choose([], (point) => point)).toEqual({ position: player, source: "player" }); expect(choose([[9, 0, 0]], (point) => point[0] === 10 ? point : null)).toEqual({ position: player, source: "player" }); }); it("never blindly teleports to an invalid player point or a far-away projected room", () => { expect(choose([], () => null)).toBeNull(); expect(choose([[9, 0, 0]], () => [10, 0, 5])).toBeNull(); expect(choose([], () => [10, -5, 0])).toBeNull(); }); });