import { beforeEach, describe, expect, it } from "vitest"; import { abilitiesForCharacter, type AbilityDefinition } from "./abilityCatalog"; import { useCombatStore } from "./combatStore"; import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; import { clearPartyRuntimeRegistry, registerPartyRuntimePosition, updatePartyRuntimeTargetVisibility, } from "./partyRuntimeRegistry"; import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime"; import { createConfiguredPartyMember, usePartyStore } from "./partyStore"; function playerWhirlwind(state: ReturnType): AbilityDefinition { const ability = state.abilities.find((candidate) => candidate.dbcSpellId === 490133); if (!ability) throw new Error("RuneWaker Warrior Whirlwind is missing from Knight/Warrior abilities."); return ability; } function testShield(instanceId: string) { return { instanceId, itemId: 1, itemKey: instanceId, name: "Test Shield", quality: "uncommon" as const, inventorySlot: "off-hand" as const, armorType: "shield" as const, weaponType: null, itemLevel: 1, sourceItemLevel: 1, stats: { ...useCombatStore.getState().gearStats }, quantity: 1, sourceBossKey: "test", sourceBossName: "Test", acquiredAt: 1, }; } describe("RuneWaker Knight/Warrior Deadly Whirlwind integration", () => { beforeEach(() => { clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); resetPartyRuntimeClock(); usePartyStore.setState({ members: [] }); }); it("reduces player Whirlwind to 40 Rage and a 10-second cooldown when Deadly Whirlwind is learned", () => { useCombatStore.getState().initializeCharacter({ classId: "rom-knight", secondaryClassId: "rom-warrior", raceId: "rom-human", level: 80, }); const initial = useCombatStore.getState(); const whirlwind = playerWhirlwind(initial); expect(initial.abilities.find((ability) => ability.dbcSpellId === 491639)).toMatchObject({ classId: "rom-knight", secondaryClassId: "rom-warrior", passive: true, }); useCombatStore.setState((state) => ({ resourcePools: { ...state.resourcePools, rage: 40 }, maxResourcePools: { ...state.maxResourcePools, rage: 100 }, })); useCombatStore.getState().registerMob("knight-whirlwind-target", { name: "Whirlwind Target", maxHealth: 100_000, xpReward: 0, }); setMobPosition("knight-whirlwind-target", [3, 0, 0]); useCombatStore.getState().selectMob("knight-whirlwind-target"); const result = useCombatStore.getState().castAbility(whirlwind.id, undefined, 1_000); expect(result, JSON.stringify(result)).toMatchObject({ ok: true }); const final = useCombatStore.getState(); expect(final.resourcePools.rage).toBe(0); expect(final.cooldowns[whirlwind.id]).toBe(11_000); expect(final.combatEvents.some((event) => event.kind === "damage" && event.abilityId === whirlwind.id && event.targetActorId === "knight-whirlwind-target" && event.effectiveAmount > 0)).toBe(true); }); it("uses the learned paired passive for companion Whirlwind cost and cooldown", () => { useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 }); const knightSeed = createConfiguredPartyMember({ name: "Knight Warrior", classId: "rom-knight", secondaryClassId: "rom-warrior", specialization: "Primary", role: "damage", }, 80, 0, "deadly-whirlwind-party"); const abilities = abilitiesForCharacter("rom-knight", "rom-warrior"); const whirlwind = abilities.find((ability) => ability.dbcSpellId === 490133)!; const knight = { ...knightSeed, resourcePools: { ...knightSeed.resourcePools, rage: 40 }, cooldowns: Object.fromEntries(abilities .filter((ability) => ability.id !== whirlwind.id) .map((ability) => [ability.id, 60_000])), }; usePartyStore.setState({ members: [knight], command: "attack" }); useCombatStore.getState().synchronizeActors(); registerPartyRuntimePosition(knight.id, [0, 0, 0]); useCombatStore.getState().registerMob("party-knight-whirlwind-target", { name: "Party Whirlwind Target", maxHealth: 100_000, xpReward: 0, }); // Keep the target inside Whirlwind's area but outside the companion's melee range // so its autoattack cannot add Rage after the discounted spell is spent. setMobPosition("party-knight-whirlwind-target", [10, 0, 0]); useCombatStore.getState().damageMob("party-knight-whirlwind-target", 1, 100); updatePartyRuntimeTargetVisibility(knight.id, "party-knight-whirlwind-target", true); advanceDungeonPartyCombat(1_000); const finalMember = usePartyStore.getState().members.find((member) => member.id === knight.id)!; expect(finalMember.resourcePools.rage).toBe(0); expect(finalMember.cooldowns[whirlwind.id]).toBe(11_000); expect(useCombatStore.getState().combatEvents.some((event) => event.kind === "damage" && event.sourceActorId === knight.id && event.abilityId === whirlwind.id && event.targetActorId === "party-knight-whirlwind-target" && event.effectiveAmount > 0)).toBe(true); }); it("applies current Compassion to player Whirlwind Shield only after a landed hit", () => { const playerShield = testShield("compassion-player-shield"); useCombatStore.getState().initializeCharacter({ classId: "rom-knight", secondaryClassId: "rom-priest", raceId: "rom-human", level: 80, inventory: [playerShield], equipment: { player: { "off-hand": playerShield.instanceId } }, }); const initial = useCombatStore.getState(); const shield = initial.abilities.find((ability) => ability.dbcSpellId === 490175)!; expect(shield).toMatchObject({ cooldownMs: 5_000, radius: 100 }); expect(initial.abilities.find((ability) => ability.dbcSpellId === 491635)).toMatchObject({ classId: "rom-knight", secondaryClassId: "rom-priest", passive: true, }); const allySeed = createConfiguredPartyMember({ name: "Wounded Ally", classId: "priest", specialization: "Holy", role: "healer", }, 80, 0, "compassion-player-ally"); const ally = { ...allySeed, health: 1 }; usePartyStore.setState({ members: [ally] }); registerPartyRuntimePosition(ally.id, [2, 0, 0]); useCombatStore.getState().synchronizeActors(); useCombatStore.getState().registerMob("compassion-player-target", { name: "Compassion Target", maxHealth: 100_000, xpReward: 0, }); setMobPosition("compassion-player-target", [8, 0, 0]); useCombatStore.getState().selectMob("compassion-player-target"); const result = useCombatStore.getState().castAbility(shield.id, undefined, 1_000); const final = useCombatStore.getState(); expect(result, JSON.stringify(result)).toMatchObject({ ok: true }); expect(final.cooldowns[shield.id]).toBe(5_000); expect(usePartyStore.getState().members.find((member) => member.id === ally.id)!.health).toBeGreaterThan(1); expect(final.combatEvents.some((event) => event.kind === "damage" && event.abilityId === shield.id && event.targetActorId === "compassion-player-target" && event.effectiveAmount > 0 && event.school === "holy")).toBe(true); expect(final.auras.some((aura) => aura.definition.id === "rom-knight-holy-illumination-proc" && aura.targetId === ally.id && aura.expiresAt === 11_000)).toBe(true); }); it("applies current Compassion heal, Holy Illumination and 4-second cooldown for a generated companion", () => { useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 }); const seed = "compassion-party-knight-shaman"; const knightSeed = createConfiguredPartyMember({ name: "Knight Shaman", classId: "rom-knight", secondaryClassId: "rom-priest", specialization: "Primary", role: "damage", }, 80, 0, seed); const abilities = abilitiesForCharacter("rom-knight", "rom-priest"); const shield = abilities.find((ability) => ability.dbcSpellId === 490175)!; const knight = { ...knightSeed, health: 1, cooldowns: Object.fromEntries(abilities .filter((ability) => ability.id !== shield.id) .map((ability) => [ability.id, 60_000])), }; const partyShield = testShield("compassion-party-shield"); usePartyStore.setState({ members: [knight], command: "attack" }); const assignments = { ...useCombatStore.getState().equipment, [knight.id]: { "off-hand": partyShield.instanceId }, }; useCombatStore.setState((state) => ({ equipment: assignments, inventory: [...state.inventory, partyShield], })); usePartyStore.getState().syncEquipment(assignments, useCombatStore.getState().inventory); useCombatStore.getState().synchronizeActors(); registerPartyRuntimePosition(knight.id, [0, 0, 0]); useCombatStore.getState().registerMob("compassion-party-target", { name: "Compassion Party Target", maxHealth: 100_000, xpReward: 0, }); setMobPosition("compassion-party-target", [10, 0, 0]); useCombatStore.getState().damageMob("compassion-party-target", 1, 100); updatePartyRuntimeTargetVisibility(knight.id, "compassion-party-target", true); advanceDungeonPartyCombat(1_000); const finalMember = usePartyStore.getState().members.find((member) => member.id === knight.id)!; const final = useCombatStore.getState(); expect(final.combatEvents.some((event) => event.kind === "damage" && event.sourceActorId === knight.id && event.abilityId === shield.id && event.targetActorId === "compassion-party-target" && event.effectiveAmount > 0)).toBe(true); expect(finalMember.health).toBeGreaterThan(1); expect(finalMember.cooldowns[shield.id]).toBe(5_000); expect(final.combatEvents.some((event) => event.kind === "damage" && event.sourceActorId === knight.id && event.abilityId === shield.id && event.targetActorId === "compassion-party-target" && event.effectiveAmount > 0 && event.school === "holy")).toBe(true); expect(final.auras.some((aura) => aura.definition.id === "rom-knight-holy-illumination-proc" && aura.sourceId === knight.id && aura.targetId === knight.id && aura.expiresAt === 11_000)).toBe(true); }); });