186 lines
7.4 KiB
TypeScript
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);
|
|
});
|
|
});
|