import { beforeEach, describe, expect, it } from "vitest"; import { abilitiesForCharacter, abilityById } from "./abilityCatalog"; import { PLAYER_AGGRO_ID } from "./aggro"; import { useCombatStore } from "./combatStore"; import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; import { advanceDungeonPartyCombat, resetPartyRuntimeClock, } from "./partyRuntime"; import { clearPartyRuntimeRegistry, registerPartyRuntimePosition, updatePartyRuntimeTargetVisibility, } from "./partyRuntimeRegistry"; import { createConfiguredPartyMember, usePartyStore } from "./partyStore"; import type { DamageSchool } from "./combatAuras"; import { ROM_SCOUT_PRIEST_DRUMS_OF_WAR, ROM_SCOUT_PRIEST_DRUMS_OF_WAR_AURA, } from "./romScoutPriestOverrides"; const TARGET_ID = "drums-of-war-target"; function initializeScoutPriest(): void { useCombatStore.getState().initializeCharacter({ classId: "rom-scout", secondaryClassId: "rom-priest", raceId: "rom-human", level: 80, }); } function registerTarget(): void { useCombatStore.getState().registerMob(TARGET_ID, { name: TARGET_ID, maxHealth: 1_000_000, xpReward: 0, }); setMobPosition(TARGET_ID, [3, 0, 0]); useCombatStore.getState().selectMob(TARGET_ID); } function shot() { const ability = useCombatStore.getState().abilities.find((candidate) => ( candidate.source === "runewaker" && candidate.dbcSpellId === ROM_SCOUT_PRIEST_DRUMS_OF_WAR.triggerSpellId )); if (!ability) throw new Error("RuneWaker Shot (490423) is missing from Scout/Priest abilities."); return ability; } function landPlayerShot(now: number, seed: string) { const ability = shot(); return useCombatStore.getState().damageMob( TARGET_ID, 100, now, { actorId: PLAYER_AGGRO_ID, role: "damage", abilityId: ability.id }, "physical", { attackKind: "ranged", abilityId: ability.id, seed, rules: { canMiss: false, canDodge: false, canParry: false, canBlock: false }, }, ); } function directHit( actorId: string, now: number, school: DamageSchool, attackKind: "melee" | "ranged" | "spell", seed: string, options: { readonly forceCrit?: boolean; readonly canCrit?: boolean } = {}, ) { const abilityId = `drums-direct-hit:${attackKind}:${school}`; return useCombatStore.getState().damageMob( TARGET_ID, 1_000, now, { actorId, role: "damage", abilityId }, school, { attackKind, abilityId, seed, criticalChanceBonusPercent: options.forceCrit ? 100 : 0, rules: { canMiss: false, canDodge: false, canParry: false, canBlock: false, canCrit: options.canCrit, }, }, ); } function activeDrumsAura(actorId: string, now: number, sourceId = PLAYER_AGGRO_ID) { return useCombatStore.getState().auras.find((aura) => ( aura.targetId === actorId && aura.sourceId === sourceId && aura.definition.id === ROM_SCOUT_PRIEST_DRUMS_OF_WAR_AURA.id && (aura.expiresAt === null || aura.expiresAt > now) )); } describe("RoM Scout/Priest Drums of War integration", () => { beforeEach(() => { clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); resetPartyRuntimeClock(); initializeScoutPriest(); }); it("applies CoA critical damage after a landed Shot and waits 15 seconds after expiry", () => { registerTarget(); const passive = useCombatStore.getState().abilities.find((ability) => ability.dbcSpellId === 493010); expect(passive).toMatchObject({ classId: "rom-scout", secondaryClassId: "rom-priest", passive: true }); const first = landPlayerShot(1_000, "drums-player-first"); expect(first.combatResult?.landed).toBe(true); expect(activeDrumsAura(PLAYER_AGGRO_ID, 1_000)).toMatchObject({ expiresAt: 16_000, definition: { modifiers: [ { kind: "stat", stat: "critical-damage", operation: "percent", value: 0.2 }, ], }, }); expect(useCombatStore.getState().cooldowns[`${ROM_SCOUT_PRIEST_DRUMS_OF_WAR.cooldownKey}:${PLAYER_AGGRO_ID}`]) .toBe(31_000); landPlayerShot(5_000, "drums-player-on-icd"); expect(activeDrumsAura(PLAYER_AGGRO_ID, 5_000)?.expiresAt).toBe(16_000); landPlayerShot(16_000, "drums-player-lockout-after-expiry"); expect(activeDrumsAura(PLAYER_AGGRO_ID, 16_000)).toBeUndefined(); landPlayerShot(30_999, "drums-player-last-lockout-ms"); expect(activeDrumsAura(PLAYER_AGGRO_ID, 30_999)).toBeUndefined(); landPlayerShot(31_000, "drums-player-ready-again"); expect(activeDrumsAura(PLAYER_AGGRO_ID, 31_000)?.expiresAt).toBe(46_000); }); it("increases player physical and magical critical damage without changing crit chance or ordinary hits", () => { registerTarget(); const baselinePhysicalChance = directHit( PLAYER_AGGRO_ID, 500, "physical", "ranged", "drums-player-physical-chance-before", ); const baselineMagicChance = directHit( PLAYER_AGGRO_ID, 600, "fire", "spell", "drums-player-magic-chance-before", ); const baselinePhysicalCrit = directHit( PLAYER_AGGRO_ID, 700, "physical", "ranged", "drums-player-physical-crit-before", { forceCrit: true }, ); const baselineMagicalCrit = directHit( PLAYER_AGGRO_ID, 800, "fire", "spell", "drums-player-magic-crit-before", { forceCrit: true }, ); expect(baselinePhysicalCrit.combatResult?.critical).toBe(true); expect(baselineMagicalCrit.combatResult?.critical).toBe(true); landPlayerShot(1_000, "drums-player-crit-buff"); const buffedPhysicalChance = directHit( PLAYER_AGGRO_ID, 2_000, "physical", "ranged", "drums-player-physical-chance-after", ); const buffedMagicChance = directHit( PLAYER_AGGRO_ID, 2_100, "fire", "spell", "drums-player-magic-chance-after", ); expect(buffedPhysicalChance.combatResult?.attackTable.criticalPercent) .toBe(baselinePhysicalChance.combatResult?.attackTable.criticalPercent); expect(buffedMagicChance.combatResult?.attackTable.criticalPercent) .toBe(baselineMagicChance.combatResult?.attackTable.criticalPercent); const physicalCrit = directHit( PLAYER_AGGRO_ID, 2_200, "physical", "ranged", "drums-player-physical-crit-after", { forceCrit: true }, ); const magicalCrit = directHit( PLAYER_AGGRO_ID, 2_300, "fire", "spell", "drums-player-magic-crit-after", { forceCrit: true }, ); expect(physicalCrit.combatResult?.critical).toBe(true); expect(magicalCrit.combatResult?.critical).toBe(true); expect(physicalCrit.combatResult?.damageBeforeMitigation) .toBe(Math.round((baselinePhysicalCrit.combatResult?.damageBeforeMitigation ?? 0) * 1.1)); expect(magicalCrit.combatResult?.damageBeforeMitigation) .toBe(Math.round((baselineMagicalCrit.combatResult?.damageBeforeMitigation ?? 0) * 1.1)); const ordinaryHit = directHit( PLAYER_AGGRO_ID, 2_400, "fire", "spell", "drums-player-ordinary-hit", { canCrit: false }, ); expect(ordinaryHit.combatResult?.critical).toBe(false); expect(ordinaryHit.combatResult?.damageBeforeMitigation).toBe(1_000); }); it("applies only to living party members within 50 yards of the caster", () => { const near = createConfiguredPartyMember({ name: "Near Ally", classId: "priest", specialization: "Discipline", role: "healer", }, 80, 0, "drums-near-party-member"); const far = createConfiguredPartyMember({ name: "Far Ally", classId: "warrior", specialization: "Arms", role: "damage", }, 80, 1, "drums-far-party-member"); usePartyStore.setState({ members: [near, far] }); useCombatStore.getState().synchronizeActors(); registerPartyRuntimePosition(near.id, [40, 0, 0]); registerPartyRuntimePosition(far.id, [51, 0, 0]); registerTarget(); const nearCritBefore = directHit( near.id, 500, "fire", "spell", "drums-near-ally-crit-before", { forceCrit: true }, ); expect(nearCritBefore.combatResult?.critical).toBe(true); landPlayerShot(1_000, "drums-player-party-radius"); expect(activeDrumsAura(PLAYER_AGGRO_ID, 1_000)).toBeDefined(); expect(useCombatStore.getState().auras.some((aura) => aura.targetId === near.id && aura.sourceId === PLAYER_AGGRO_ID && aura.definition.id === ROM_SCOUT_PRIEST_DRUMS_OF_WAR_AURA.id)).toBe(true); expect(useCombatStore.getState().auras.some((aura) => aura.targetId === far.id && aura.sourceId === PLAYER_AGGRO_ID && aura.definition.id === ROM_SCOUT_PRIEST_DRUMS_OF_WAR_AURA.id)).toBe(false); const nearCritAfter = directHit( near.id, 2_000, "fire", "spell", "drums-near-ally-crit-after", { forceCrit: true }, ); expect(nearCritAfter.combatResult?.critical).toBe(true); expect(nearCritAfter.combatResult?.damageBeforeMitigation) .toBe(Math.round((nearCritBefore.combatResult?.damageBeforeMitigation ?? 0) * 1.1)); }); it("passes party Shot through the ranged hit-resolution path before proc application", () => { registerTarget(); const playerHit = landPlayerShot(1_000, "drums-player-independent-icd"); expect(playerHit.combatResult?.landed).toBe(true); const seed = "drums-party-scout"; const member = createConfiguredPartyMember({ name: "Scout Priest", classId: "rom-scout", secondaryClassId: "rom-priest", specialization: "Primary", role: "damage", }, 80, 0, seed); const partyShot = abilitiesForCharacter(member.classId, member.secondaryClassId) .find((ability) => ability.dbcSpellId === ROM_SCOUT_PRIEST_DRUMS_OF_WAR.triggerSpellId); if (!partyShot) throw new Error("RuneWaker Shot (490423) is missing from party Scout/Priest abilities."); const passive = abilitiesForCharacter(member.classId, member.secondaryClassId) .find((ability) => ability.dbcSpellId === ROM_SCOUT_PRIEST_DRUMS_OF_WAR.passiveSpellId); if (!passive) throw new Error("Drums of War (493010) is missing from party Scout/Priest abilities."); expect(abilityById(partyShot.id)?.dbcSpellId).toBe(490_423); usePartyStore.getState().initializeParty(seed, 80, useCombatStore.getState().health, "damage", 2, "rom"); usePartyStore.getState().setCommand("attack"); usePartyStore.setState({ members: [{ ...member, resource: 10_000, maxResource: 10_000, resourcePools: { ...member.resourcePools, "nature-power": 10_000, mana: 10_000 }, activeCast: { abilityId: partyShot.id, school: "physical", mode: "cast", startedAt: 500, completesAt: 3_500, originalDurationMs: 3_000, pushbackHits: 0, origin: [0, 0, 0], targetId: TARGET_ID, }, activeCastTargetId: TARGET_ID, status: "attacking", }] }); registerPartyRuntimePosition(member.id, [0, 0, 0]); registerTarget(); const companionCritBefore = directHit( member.id, 2_000, "physical", "ranged", "drums-companion-crit-before", { forceCrit: true }, ); expect(companionCritBefore.combatResult?.critical).toBe(true); useCombatStore.getState().damageMob(TARGET_ID, 1, 3_400); updatePartyRuntimeTargetVisibility(member.id, TARGET_ID, true); useCombatStore.getState().synchronizeActors(); advanceDungeonPartyCombat(3_500); expect(useCombatStore.getState().auras.some((aura) => aura.sourceId === member.id && aura.targetId === member.id && aura.definition.id === ROM_SCOUT_PRIEST_DRUMS_OF_WAR_AURA.id && aura.expiresAt === 18_500)).toBe(true); expect(useCombatStore.getState().cooldowns[`${ROM_SCOUT_PRIEST_DRUMS_OF_WAR.cooldownKey}:${member.id}`]) .toBe(33_500); expect(activeDrumsAura(PLAYER_AGGRO_ID, 3_500)?.expiresAt).toBe(16_000); const companionAuras = useCombatStore.getState().auras; useCombatStore.setState({ auras: companionAuras.filter((aura) => !(aura.targetId === member.id && aura.sourceId === member.id && aura.definition.id === ROM_SCOUT_PRIEST_DRUMS_OF_WAR_AURA.id)), }); const companionCritWithoutDrums = directHit( member.id, 4_000, "physical", "ranged", "drums-companion-crit-without-aura", { forceCrit: true }, ); useCombatStore.setState({ auras: companionAuras }); const companionCritAfter = directHit( member.id, 4_100, "physical", "ranged", "drums-companion-crit-after", { forceCrit: true }, ); expect(companionCritAfter.combatResult?.critical).toBe(true); expect(companionCritAfter.combatResult?.damageBeforeMitigation) .toBe(Math.round((companionCritWithoutDrums.combatResult?.damageBeforeMitigation ?? 0) * 1.1)); }); });