import { beforeEach, describe, expect, it } from "vitest"; import { PLAYER_AGGRO_ID } from "./aggro"; import { createDeathKnightRunes } from "./combatActors"; import { useCombatStore } from "./combatStore"; import { EMPTY_ITEM_STATS } from "./itemStats"; import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; import { advanceDungeonPartyCombat, resetPartyRuntimeClock, } from "./partyRuntime"; import { clearPartyRuntimeRegistry, registerPartyRuntimePosition, updatePartyRuntimeTargetVisibility, } from "./partyRuntimeRegistry"; import { createConfiguredPartyMember, usePartyStore } from "./partyStore"; import { abilitiesForCharacter, learnedAbilityForBuild } from "./abilityCatalog"; const TARGET_ID = "merciless-combat-target"; const TARGET_MAX_HEALTH = 1_000_000; const CAST_AT = 1_000; function setTargetHealthFraction(fraction: number): void { useCombatStore.setState((state) => ({ mobs: { ...state.mobs, [TARGET_ID]: { ...state.mobs[TARGET_ID], health: Math.round(TARGET_MAX_HEALTH * fraction), dead: false, }, } })); } function targetDamageEvent(sourceActorId: string, abilityId: string, occurredAt: number) { return useCombatStore.getState().combatEvents.find((event) => ( event.kind === "damage" && event.sourceActorId === sourceActorId && event.targetActorId === TARGET_ID && event.abilityId === abilityId && event.occurredAt === occurredAt )); } function resolvePlayerStrike( abilityName: string, rank: number, targetHealthFraction: number, includeFirstDiseaseTick = false, ) { clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); useCombatStore.getState().initializeCharacter({ classId: "death-knight", level: 80, xp: 0 }); useCombatStore.setState((state) => ({ talentRanks: { ...state.talentRanks, "talent-1993": rank }, gearStats: { ...EMPTY_ITEM_STATS, hitRating: 10_000, expertiseRating: 10_000 }, })); useCombatStore.getState().registerMob(TARGET_ID, { name: "Merciless Combat target", maxHealth: TARGET_MAX_HEALTH, xpReward: 0, }); setMobPosition(TARGET_ID, [2, 0, 0]); useCombatStore.getState().selectMob(TARGET_ID); setTargetHealthFraction(targetHealthFraction); const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === abilityName); if (!ability) throw new Error(`Missing Death Knight ability: ${abilityName}`); expect(useCombatStore.getState().castAbility(ability.id, [0, 0, 0], CAST_AT).ok).toBe(true); const direct = targetDamageEvent(PLAYER_AGGRO_ID, ability.id, CAST_AT); if (!direct) throw new Error(`${abilityName} did not produce a player damage event.`); let firstDiseaseTick: ReturnType; if (includeFirstDiseaseTick) { useCombatStore.getState().tick(0.1, CAST_AT + 3_000); firstDiseaseTick = useCombatStore.getState().combatEvents.find((event) => ( event.kind === "damage" && event.sourceActorId === PLAYER_AGGRO_ID && event.targetActorId === TARGET_ID && event.abilityId === ability.id && event.occurredAt === CAST_AT + 3_000 )); if (!firstDiseaseTick) throw new Error("Icy Touch did not produce its first disease tick."); } return { direct, firstDiseaseTick }; } function resolveCompanionIcyTouch(rank: number, targetHealthFraction: number) { clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); resetPartyRuntimeClock(); useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80, xp: 0 }); const seed = `merciless-combat-companion-${rank}-${targetHealthFraction}`; const configured = createConfiguredPartyMember({ name: "Frost Death Knight", classId: "death-knight", specialization: "Frost", specializationId: "death-knight:frost", talentTreeId: "death-knight-frost", talentRanks: { "talent-1993": rank }, role: "damage", }, 80, 0, seed); const icyTouch = abilitiesForCharacter("death-knight", null).find((ability) => ability.name === "Icy Touch"); if (!icyTouch) throw new Error("Death Knight Icy Touch is missing from the ability catalog."); const learnedIcyTouch = learnedAbilityForBuild(icyTouch, 80, configured.talentRanks); if (!learnedIcyTouch) throw new Error("Companion Death Knight did not learn Icy Touch."); usePartyStore.getState().initializeParty(seed, 80, useCombatStore.getState().health, "damage", 2); usePartyStore.setState({ members: [{ ...configured, gearStats: { ...EMPTY_ITEM_STATS, hitRating: 10_000, expertiseRating: 10_000 }, resource: 100, maxResource: 100, resourcePools: { ...configured.resourcePools, "runic-power": 100 }, runes: createDeathKnightRunes(CAST_AT), globalCooldownEndsAt: 0, nextActionAt: 0, activeCast: { abilityId: learnedIcyTouch.id, school: "frost", mode: "cast", startedAt: CAST_AT, completesAt: CAST_AT, originalDurationMs: 0, pushbackHits: 0, origin: [0, 0, 0], targetId: TARGET_ID, }, activeCastTargetId: TARGET_ID, status: "attacking", }], command: "attack", activeMobId: TARGET_ID, }); registerPartyRuntimePosition(configured.id, [0, 0, 0]); useCombatStore.getState().synchronizeActors(); useCombatStore.getState().registerMob(TARGET_ID, { name: "Merciless Combat companion target", maxHealth: TARGET_MAX_HEALTH, xpReward: 0, }); setMobPosition(TARGET_ID, [2, 0, 0]); useCombatStore.getState().damageMob(TARGET_ID, 1, 100); updatePartyRuntimeTargetVisibility(configured.id, TARGET_ID, true); setTargetHealthFraction(targetHealthFraction); advanceDungeonPartyCombat(CAST_AT); const hit = targetDamageEvent(configured.id, learnedIcyTouch.id, CAST_AT); if (!hit) throw new Error("Companion Icy Touch did not produce a damage event."); return hit; } describe("WoW Death Knight Merciless Combat integration", () => { beforeEach(() => { clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); }); it("applies both player ranks below 35%, with the strict threshold and spell-list controls", () => { const baseline = resolvePlayerStrike("Icy Touch", 0, 0.34).direct; const rankOne = resolvePlayerStrike("Icy Touch", 1, 0.34).direct; const rankTwo = resolvePlayerStrike("Icy Touch", 2, 0.34).direct; const atThreshold = resolvePlayerStrike("Icy Touch", 2, 0.35).direct; const otherStrikeBaseline = resolvePlayerStrike("Plague Strike", 0, 0.34).direct; const otherStrikeWithTalent = resolvePlayerStrike("Plague Strike", 2, 0.34).direct; expect(rankOne.rawAmount).toBe(Math.round(baseline.rawAmount * 1.06)); expect(rankTwo.rawAmount).toBe(Math.round(baseline.rawAmount * 1.12)); expect(atThreshold.rawAmount).toBe(baseline.rawAmount); expect(otherStrikeWithTalent.rawAmount).toBe(otherStrikeBaseline.rawAmount); }); it("does not apply the execute bonus to Icy Touch's periodic Fever ticks", () => { const baseline = resolvePlayerStrike("Icy Touch", 0, 0.34, true).firstDiseaseTick; const talented = resolvePlayerStrike("Icy Touch", 2, 0.34, true).firstDiseaseTick; expect(talented?.rawAmount).toBe(baseline?.rawAmount); }); it("applies both ranks to generated companion direct hits only below 35%", () => { const baseline = resolveCompanionIcyTouch(0, 0.34); const rankOne = resolveCompanionIcyTouch(1, 0.34); const rankTwo = resolveCompanionIcyTouch(2, 0.34); const atThreshold = resolveCompanionIcyTouch(2, 0.35); expect(rankOne.rawAmount).toBe(Math.round(baseline.rawAmount * 1.06)); expect(rankTwo.rawAmount).toBe(Math.round(baseline.rawAmount * 1.12)); expect(atThreshold.rawAmount).toBe(baseline.rawAmount); }); });