Files
healer-man/src/game/mobAi.test.ts
T
2026-08-14 15:56:39 -04:00

186 lines
7.4 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
DEFAULT_BOSS_COMBAT_PROFILE,
DEFAULT_MOB_COMBAT_PROFILE,
canMobMove,
canMobSwing,
createMobCombatProfile,
firstMobSwingAt,
isMobInAttackRange,
isMobOutsideLeash,
mobMovementSpeed,
mobMovementSpeedMultiplier,
mobSwingIntervalMultiplier,
moveMobToward,
nextMobSwingAt,
planarDistanceSquared,
resolveMobAiTransition,
type MobAiStatus,
} from "./mobAi";
const home = [0, 0, 0] as const;
const normal = createMobCombatProfile({ level: 1 });
function transition(overrides: Partial<Parameters<typeof resolveMobAiTransition>[0]> = {}) {
return resolveMobAiTransition({
phase: "idle",
dead: false,
engaged: false,
playerAlive: true,
position: home,
homePosition: home,
targetPosition: [10, 0, 0],
profile: normal,
...overrides,
});
}
describe("mob combat profiles", () => {
it("scales damage by level and provides larger boss reach and leash", () => {
const mob = createMobCombatProfile({ level: 4 });
const boss = createMobCombatProfile({ boss: true, level: 4 });
expect(mob.attackDamage).toBe(12);
expect(boss.attackDamage).toBe(20);
expect(boss.attackRange).toBeGreaterThan(mob.attackRange);
expect(boss.leashRange).toBeGreaterThan(mob.leashRange);
expect(DEFAULT_MOB_COMBAT_PROFILE.attackDamage).toBe(6);
expect(DEFAULT_BOSS_COMBAT_PROFILE.attackDamage).toBe(14);
});
it("clamps malformed overrides without mutating exported defaults", () => {
const profile = createMobCombatProfile({
level: Number.NaN,
overrides: {
chaseSpeed: -5,
swingIntervalMs: 0,
attackDamage: Number.NaN,
leashRange: 12,
},
});
expect(profile).toMatchObject({ chaseSpeed: 0, swingIntervalMs: 1, attackDamage: 6, leashRange: 12 });
expect(DEFAULT_MOB_COMBAT_PROFILE.chaseSpeed).toBe(3.25);
});
});
describe("mob AI phase transitions", () => {
it("does not aggro from proximity without an explicit engagement", () => {
expect(transition({ targetPosition: [1, 0, 0] }).phase).toBe("idle");
});
it("chases an engaged distant target and attacks only inside reach with line of sight", () => {
expect(transition({ engaged: true }).phase).toBe("chasing");
expect(transition({ engaged: true, targetPosition: [3, 0, 0] }).phase).toBe("attacking");
expect(transition({ engaged: true, targetPosition: [3, 0, 0], lineOfSight: false }).phase).toBe("chasing");
});
it("returns when the target dies, disappears, or crosses the leash", () => {
expect(transition({ engaged: true, playerAlive: false }).phase).toBe("returning");
expect(transition({ engaged: true, targetPosition: null }).phase).toBe("returning");
expect(transition({ engaged: true, targetPosition: [normal.leashRange + 1, 0, 0] }).phase).toBe("returning");
expect(transition({ engaged: true, position: [normal.leashRange + 1, 0, 0] }).phase).toBe("returning");
});
it("keeps an evading mob returning until home, then requests one reset", () => {
const returning = transition({
phase: "returning",
engaged: false,
position: [4, 0, 0],
targetPosition: null,
});
expect(returning).toMatchObject({ phase: "returning", clearThreat: true, restoreAtHome: false, cancelSwing: true });
const homeResult = transition({
phase: "returning",
engaged: false,
position: [normal.returnTolerance / 2, 0, 0],
targetPosition: null,
});
expect(homeResult).toMatchObject({ phase: "idle", clearThreat: true, restoreAtHome: true });
});
it("makes death terminal until an explicit alive/reset state is supplied", () => {
expect(transition({ phase: "attacking", dead: true, engaged: true }).phase).toBe("dead");
expect(transition({ phase: "dead", dead: false, engaged: true }).phase).toBe("idle");
});
});
describe("mob ranges and status modifiers", () => {
const active = (kind: MobAiStatus["kind"], magnitude?: number): MobAiStatus => ({
kind,
endsAt: 5_000,
...(magnitude === undefined ? {} : { magnitude }),
});
it("uses planar reach plus a separate vertical tolerance", () => {
expect(planarDistanceSquared([0, 0, 0], [3, 20, 4])).toBe(25);
expect(isMobInAttackRange([0, 0, 0], [3, 1, 3], normal)).toBe(true);
expect(isMobInAttackRange([0, 0, 0], [3, normal.attackHeightTolerance + 1, 3], normal)).toBe(false);
expect(isMobOutsideLeash([0, 0, 0], [normal.leashRange + 1, 0, 0], home, normal)).toBe(true);
});
it("blocks directed movement for stun, root, and fear but lets expired controls lapse", () => {
for (const kind of ["stun", "root", "fear"] as const) {
expect(mobMovementSpeedMultiplier([active(kind)], 1_000)).toBe(0);
expect(canMobMove("chasing", false, [active(kind)], 1_000)).toBe(false);
}
expect(mobMovementSpeedMultiplier([{ kind: "root", endsAt: 999 }], 1_000)).toBe(1);
});
it("uses the strongest slow and scales chase/return speeds", () => {
const statuses = [active("slow", 0.25), active("slow", 0.5)];
expect(mobMovementSpeedMultiplier(statuses, 1_000)).toBe(0.5);
expect(mobMovementSpeed(normal, "chasing", statuses, 1_000)).toBeCloseTo(normal.chaseSpeed * 0.5);
expect(mobMovementSpeed(normal, "returning", [], 1_000)).toBe(normal.returnSpeed);
expect(mobMovementSpeed(normal, "attacking", [], 1_000)).toBe(0);
});
it("converts attack-speed reduction into a longer swing interval", () => {
const statuses = [active("attack-speed", 0.2)];
expect(mobSwingIntervalMultiplier(statuses, 1_000)).toBeCloseTo(1.25);
expect(nextMobSwingAt(1_000, normal, statuses)).toBe(3_000);
expect(firstMobSwingAt(1_000, normal)).toBe(2_600);
});
it("allows a rooted melee swing but rejects stun, fear, range, height, LOS, and early timers", () => {
const base = {
phase: "attacking" as const,
dead: false,
engaged: true,
playerAlive: true,
targetAvailable: true,
distanceToTarget: normal.attackRange,
verticalDistance: 0,
lineOfSight: true,
now: 2_000,
nextSwingAt: 2_000,
profile: normal,
};
expect(canMobSwing({ ...base, statuses: [active("root")] })).toBe(true);
expect(canMobSwing({ ...base, statuses: [active("stun")] })).toBe(false);
expect(canMobSwing({ ...base, statuses: [active("fear")] })).toBe(false);
expect(canMobSwing({ ...base, distanceToTarget: normal.attackRange + 0.01 })).toBe(false);
expect(canMobSwing({ ...base, verticalDistance: normal.attackHeightTolerance + 0.01 })).toBe(false);
expect(canMobSwing({ ...base, lineOfSight: false })).toBe(false);
expect(canMobSwing({ ...base, now: 1_999 })).toBe(false);
});
});
describe("mob steering", () => {
it("moves into caller-owned storage without overshooting", () => {
const out: [number, number, number] = [99, 99, 99];
expect(moveMobToward([0, 0, 0], [3, 4, 0], 2, out)).toBe(false);
expect(out[0]).toBeCloseTo(1.2);
expect(out[1]).toBeCloseTo(1.6);
expect(out[2]).toBe(0);
expect(moveMobToward(out, [3, 4, 0], 10, out)).toBe(true);
expect(out).toEqual([3, 4, 0]);
});
it("contains malformed coordinates instead of poisoning scene transforms", () => {
const out: [number, number, number] = [0, 0, 0];
expect(moveMobToward([Number.NaN, 2, 3], [5, 6, 7], 1, out)).toBe(false);
expect(out).toEqual([0, 2, 3]);
expect(planarDistanceSquared([0, 0, 0], [Number.NaN, 0, 0])).toBe(Number.POSITIVE_INFINITY);
});
});