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