forked from phenom/healer-man
106 lines
4.4 KiB
TypeScript
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);
|
|
});
|
|
});
|