forked from phenom/healer-man
182 lines
9.4 KiB
TypeScript
182 lines
9.4 KiB
TypeScript
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> = {}): 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<string, MutablePartyWorldPosition>([
|
|
["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<string, MutablePartyWorldPosition>([["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();
|
|
});
|
|
});
|