Release Healer Man 0.1.36

This commit is contained in:
phenom committed 2026-09-12 19:16:01 -04:00
1 parent 40d9f346d8
commit 03a4e3f51c
22 files changed
+1389 -153

No files matched your search

+133
View File
@@ -0,0 +1,133 @@
import { describe, expect, it } from "vitest";
import { createMobPursuit, chooseMobPursuitLanding, type MobPursuitSample, type MobPursuitSpatial } from "./mobPursuit";
import type { PartyNavigationGraph } from "./partyNavigation";
function sample(patch: Partial<MobPursuitSample> = {}): MobPursuitSample {
return { id: "mob", targetId: "tank", position: [0, 0, 0], target: [4, 0, 0], home: [0, 0, 0],
phase: "chasing", health: 100, engaged: true, active: true, casting: false, controlled: false,
attackRange: 1.5, leashRange: 40, maximumStep: 0, nowMs: 0, ...patch };
}
function spatial(patch: Partial<MobPursuitSpatial> = {}): MobPursuitSpatial {
return { graph: () => null, hasLineOfSight: () => false, directAllowed: () => false,
routeAllowed: () => true, validateLanding: (point) => point, ...patch };
}
describe("wall-aware mob pursuit", () => {
it("moves toward a blocked target even when the old combat snapshot says attacking", () => {
const navigator = createMobPursuit(spatial({ directAllowed: () => true }));
const state = sample({ phase: "attacking", attackRange: 6, maximumStep: 0.2 });
expect(navigator.step(state)).toBe("none");
expect(state.position[0]).toBeCloseTo(0.2);
});
it("holds only when range and clear sight agree", () => {
const navigator = createMobPursuit(spatial({ hasLineOfSight: () => true, directAllowed: () => true }));
const state = sample({ attackRange: 6, maximumStep: 0.2 });
navigator.step(state);
expect(state.position).toEqual([0, 0, 0]);
expect(navigator.step({ ...state, nowMs: 10_000 })).toBe("none");
});
it("routes around a wall through a doorway rather than cutting through it", () => {
const positions = [[-2, 0, 0], [-2, 0, 3], [2, 0, 3], [2, 0, 0]] as const;
const graph: PartyNavigationGraph = {
nodes: positions.map((position, id) => ({ id, position, neighbors: [id - 1, id + 1]
.filter((to) => to >= 0 && to < positions.length).map((to) => ({ to, cost: 4 })) })),
weldTolerance: 0.001, sourceVertexCount: 4, sourceTriangleCount: 0,
weldedNodeCount: 4, componentCount: 1, discardedNodeCount: 0,
};
const clear = (a: readonly number[], b: readonly number[]) => {
if (a[0] * b[0] >= 0) return true;
const t = -a[0] / (b[0] - a[0]);
return a[2] + (b[2] - a[2]) * t >= 2.5;
};
const navigator = createMobPursuit(spatial({ graph: () => graph, directAllowed: clear, hasLineOfSight: clear }));
const state = sample({ position: [-2, 0, 0], home: [-2, 0, 0], target: [2, 0, 0], maximumStep: 0.1, attackRange: 0.5 });
let reachedDoorway = false;
for (let frame = 0; frame < 120; frame += 1) {
const previous = [...state.position];
expect(navigator.step({ ...state, nowMs: frame * 50 })).toBe("none");
expect(clear(previous, state.position)).toBe(true);
if (state.position[2] >= 2.5) reachedDoorway = true;
}
expect(reachedDoorway).toBe(true);
expect(Math.hypot(state.position[0] - 2, state.position[2])).toBeLessThanOrEqual(0.6);
});
it("recovers a nearby stalled mob only after five seconds and preserves a ten-second cooldown", () => {
const navigator = createMobPursuit(spatial({ hasLineOfSight: (a) => a[0] > 2 }));
const state = sample();
navigator.step(state);
expect(navigator.step({ ...state, nowMs: 2_000 })).toBe("none");
expect(navigator.step({ ...state, nowMs: 4_999 })).toBe("none");
expect(navigator.step({ ...state, nowMs: 5_000 })).toBe("teleported");
expect(state.position[0]).toBeGreaterThan(2);
state.position[0] = 0;
navigator.step({ ...state, nowMs: 5_001 });
navigator.step({ ...state, nowMs: 7_001 });
expect(navigator.step({ ...state, nowMs: 14_999 })).toBe("none");
expect(navigator.step({ ...state, nowMs: 15_000 })).toBe("teleported");
});
it.each([
{ active: false }, { casting: true }, { controlled: true }, { health: 0 }, { engaged: false },
])("does not recover intentional immobility %j", (exclusion) => {
const navigator = createMobPursuit(spatial());
const state = sample(exclusion);
navigator.step(state);
expect(navigator.step({ ...state, nowMs: 10_000 })).toBe("none");
expect(state.position).toEqual([0, 0, 0]);
});
it.each([
{ target: [12, 0, 0] as const }, { target: [4, 5, 0] as const }, { leashRange: 2 },
])("resets instead of teleporting to a distant, wrong-floor, or out-of-leash target %j", (patch) => {
const navigator = createMobPursuit(spatial());
const state = sample(patch);
navigator.step(state);
navigator.step({ ...state, nowMs: 2_000 });
expect(navigator.step({ ...state, nowMs: 5_000 })).toBe("reset");
expect(state.position).toEqual(state.home);
});
it("resets when every nearby target landing is blocked without applying an invalid home landing", () => {
const navigator = createMobPursuit(spatial({ validateLanding: () => null }));
const state = sample({ position: [1, 0, 0] });
navigator.step(state);
navigator.step({ ...state, nowMs: 2_000 });
expect(navigator.step({ ...state, nowMs: 5_000 })).toBe("reset");
expect(state.position).toEqual([1, 0, 0]);
});
it("recovers a stalled return to validated home, never to the old target", () => {
const navigator = createMobPursuit(spatial());
const state = sample({ engaged: false, phase: "returning", position: [5, 0, 0] });
navigator.step(state);
navigator.step({ ...state, nowMs: 2_000 });
expect(navigator.step({ ...state, nowMs: 5_000 })).toBe("home");
expect(state.position).toEqual(state.home);
});
it("bounds landing checks, rejects unsafe projections, and never blindly uses the target", () => {
let checks = 0;
const result = chooseMobPursuitLanding(sample(), spatial({
hasLineOfSight: () => true,
validateLanding: () => { checks += 1; return [20, 0, 0]; },
}));
expect(result).toBeNull();
expect(checks).toBe(16);
});
it("keeps different mobs' timers independent", () => {
const navigator = createMobPursuit(spatial({ hasLineOfSight: (a) => a[0] > 2 }));
const first = sample();
const second = sample({ id: "other", position: [0, 0, 0] });
navigator.step(first);
navigator.step({ ...second, nowMs: 4_000 });
navigator.step({ ...first, nowMs: 2_000 });
expect(navigator.step({ ...first, nowMs: 5_000 })).toBe("teleported");
expect(navigator.step({ ...second, nowMs: 5_000 })).toBe("none");
expect(second.position).toEqual([0, 0, 0]);
});
});