Files
healer-man/src/game/partyAbilityAi.test.ts
T
2026-09-12 23:55:24 -04:00

106 lines
4.4 KiB
TypeScript

import { beforeEach, describe, expect, it } from "vitest";
import { abilitiesForClass, abilityAtLevel, resourceProfileForClass } from "./abilityCatalog";
import { aiHintsForAbility } from "./abilityRules";
import { useCombatStore } from "./combatStore";
import { EMPTY_ITEM_STATS } from "./itemStats";
import { commitPartyAbilityUse, choosePartyAbility, partyAbilityIsReady, partyAbilityAmount } from "./partyAbilityAi";
import { usePartyStore, type PartyMember } from "./partyStore";
import { deriveCharacterStats } from "./wotlkStats";
function memberFor(classId: PartyMember["classId"], role: PartyMember["role"]): PartyMember {
const base = usePartyStore.getState().members[0];
const profile = resourceProfileForClass(classId);
const maximum = profile.type === "mana"
? deriveCharacterStats(classId, 80, EMPTY_ITEM_STATS).mana
: profile.maximum;
return {
...base,
classId,
role,
level: 80,
resourceType: profile.type,
resourceName: profile.name,
resource: maximum,
maxResource: maximum,
resourcePools: { [profile.type]: maximum },
cooldowns: {},
globalCooldownEndsAt: 0,
nextActionAt: 0,
tauntReadyAt: 0,
};
}
describe("catalog-driven party rotations", () => {
beforeEach(() => {
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
usePartyStore.getState().initializeParty("party-ai", 80, useCombatStore.getState().health);
useCombatStore.getState().registerMob("caster", { name: "Caster", maxHealth: 1_000, xpReward: 0 });
useCombatStore.setState((state) => ({
mobs: {
...state.mobs,
caster: {
...state.mobs.caster,
activeCast: {
attackId: "test-cast",
name: "Test Cast",
school: "shadow",
interruptible: true,
targetActorId: "__player__",
sourcePosition: [0, 0, 0],
targetPosition: [1, 0, 0],
sourceYaw: Math.PI / 2,
startedAt: 1_000,
completesAt: 5_000,
},
},
},
}));
});
it("prioritizes an interrupt over ordinary damage deterministically", () => {
const member = memberFor("rogue", "damage");
const context = { now: 2_000, target: useCombatStore.getState().mobs.caster };
const first = choosePartyAbility(member, context);
const second = choosePartyAbility(member, context);
expect(first?.id).toBe(second?.id);
expect(first && aiHintsForAbility(first).categories).toContain("interrupt");
});
it("selects a real healer catalog spell and spends its resource/GCD", () => {
const member = memberFor("priest", "healer");
const ability = choosePartyAbility(member, {
now: 2_000,
target: null,
injuredAllies: 1,
lowestHealthFraction: 0.2,
});
expect(ability && aiHintsForAbility(ability).categories).toContain("heal");
const committed = commitPartyAbilityUse(member, ability!, 2_000)!;
expect(committed.resource).toBeLessThan(member.resource);
expect(committed.globalCooldownEndsAt).toBeGreaterThan(2_000);
});
it("will not cast a mana spell after mana exhaustion", () => {
const member = { ...memberFor("priest", "healer"), resource: 0, resourcePools: { mana: 0 } };
const heal = abilitiesForClass("priest")
.map((ability) => abilityAtLevel(ability, 80))
.find((ability) => aiHintsForAbility(ability).categories.includes("heal") && ability.cost.amount > 0)!;
expect(partyAbilityIsReady(member, heal, 2_000)).toBe(false);
});
it("preserves spell base damage when its bonus-power coefficient is zero", () => {
const member = { ...memberFor("warrior", "tank"), level: 5, gearStats: EMPTY_ITEM_STATS };
const ability = abilitiesForClass("warrior").find((candidate) => candidate.id === "warrior-heroic-strike")!;
expect(partyAbilityAmount(member, ability, {
kind: "damage", coefficient: 0, basePoints: 17, dieSides: 1, sourceLevel: 5,
})).toBe(17);
});
it("requires a learned CoA tree entry before the NPC can cast it", () => {
const member = { ...memberFor("pyromancer", "damage"), talentRanks: {} };
const ability = abilitiesForClass(member.classId).find((candidate) => candidate.talentEntryId !== undefined && !candidate.passive)!;
expect(partyAbilityIsReady(member, ability, 2_000)).toBe(false);
expect(partyAbilityIsReady({ ...member, talentRanks: { [`coa-entry-${ability.talentEntryId}`]: 1 } }, ability, 2_000)).toBe(true);
});
});