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