import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry"; import { installCombatClock } from "./combatClock"; import { createDeathKnightRunes } from "./combatActors"; import { useCombatStore, PLAYER_AURA_ENTITY_ID } from "./combatStore"; import { usePartyStore } from "./partyStore"; import { talentNodesForTree } from "./talentCatalog"; let now = 1_000; let restoreClock: (() => void) | undefined; function ability(name: string) { const found = useCombatStore.getState().abilities.find((candidate) => candidate.name === name); if (!found) throw new Error("Missing Death Knight ability: " + name); return found; } function learnActiveAbility(name: string) { const found = ability(name); if (found.talentNodeId) { useCombatStore.setState((state) => ({ talentRanks: { ...state.talentRanks, [found.talentNodeId!]: 1 }, })); } return found; } function registerTarget(id = "dk-target", position: readonly [number, number, number] = [2, 0, 0]) { useCombatStore.getState().registerMob(id, { name: id, maxHealth: 1_000_000, xpReward: 0 }); setMobPosition(id, position); useCombatStore.getState().selectMob(id); } function selectPartyMember(index = 0, raceId: "undead" | "human" = "human") { const initial = usePartyStore.getState().members[index]; if (!initial) throw new Error("Expected a party member for the Death Knight test."); usePartyStore.setState((state) => ({ members: state.members.map((member) => member.id === initial.id ? { ...member, raceId, health: 1 } : member), })); registerPartyRuntimePosition(initial.id, [2, 0, 0]); usePartyStore.getState().selectMember(initial.id); return initial.id; } function setButchery(rank: number, runicPower = 0) { const node = talentNodesForTree("death-knight-blood").find((candidate) => candidate.name === "Butchery"); if (!node) throw new Error("Missing Death Knight Butchery talent."); useCombatStore.setState((state) => ({ talentRanks: { ...state.talentRanks, [node.id]: rank }, resource: runicPower, resourcePools: { ...state.resourcePools, "runic-power": runicPower }, })); } function registerXpTarget(id: string, xpReward: number) { useCombatStore.getState().registerMob(id, { name: id, maxHealth: 1, xpReward }); setMobPosition(id, [2, 0, 0]); } describe("Death Knight source-backed runtime mechanics", () => { beforeEach(() => { now = 1_000; restoreClock = installCombatClock(() => now); clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); useCombatStore.getState().initializeCharacter({ classId: "death-knight", level: 80, xp: 0 }); useCombatStore.setState({ resource: 100, resourcePools: { "runic-power": 100 } }); usePartyStore.getState().initializeParty("death-knight-source-runtime", 80, useCombatStore.getState().health, "tank", 5); }); afterEach(() => { restoreClock?.(); restoreClock = undefined; }); it("heals a friendly Undead with Death Coil and rejects a living ally", () => { const allyId = selectPartyMember(0, "human"); const deathCoil = ability("Death Coil"); const rejected = useCombatStore.getState().castAbility(deathCoil.id, { targetActorId: allyId, now, }); expect(rejected).toMatchObject({ ok: false, reason: "condition-not-met" }); usePartyStore.setState((state) => ({ members: state.members.map((member) => member.id === allyId ? { ...member, raceId: "undead" } : member), })); now = 3_000; const healed = useCombatStore.getState().castAbility(deathCoil.id, { targetActorId: allyId, now, }); expect(healed.ok).toBe(true); expect(healed.healing).toBeGreaterThan(0); expect(usePartyStore.getState().members.find((member) => member.id === allyId)!.health).toBeGreaterThan(1); }); it("uses Death Coil's damaging branch against a hostile living target", () => { registerTarget(); const deathCoil = ability("Death Coil"); const attempts = [1_000, 3_000, 5_000].map((castAt) => { now = castAt; useCombatStore.setState({ resource: 100, resourcePools: { "runic-power": 100 } }); return useCombatStore.getState().castAbility(deathCoil.id, { now }); }); expect(attempts.every((result) => result.ok)).toBe(true); expect(attempts.some((result) => result.damage > 0)).toBe(true); expect(attempts.every((result) => result.healing === 0)).toBe(true); }); it("absorbs three quarters of magical damage up to half of maximum health", () => { const shell = ability("Anti-Magic Shell"); expect(useCombatStore.getState().castAbility(shell.id, { now }).ok).toBe(true); const aura = useCombatStore.getState().auras.find((candidate) => candidate.targetId === PLAYER_AURA_ENTITY_ID && candidate.definition.name === "Anti-Magic Shell"); expect(aura?.absorbs[0]).toMatchObject({ remaining: Math.round(useCombatStore.getState().maxHealth * 0.5), fraction: 0.75, schools: ["holy", "fire", "nature", "frost", "shadow", "arcane"], }); now += 1; expect(useCombatStore.getState().damagePlayer(100, "fire")).toBe(25); expect(useCombatStore.getState().auras.find((candidate) => candidate.instanceId === aura!.instanceId) ?.absorbs[0].remaining).toBe(Math.round(useCombatStore.getState().maxHealth * 0.5) - 75); }); it("blocks harmful magic controls during Anti-Magic Shell while allowing physical controls", () => { const shell = ability("Anti-Magic Shell"); expect(useCombatStore.getState().castAbility(shell.id, { now }).ok).toBe(true); const addControlAttacker = (id: string, school: "shadow" | "physical") => { useCombatStore.getState().registerMob(id, { name: id, maxHealth: 1_000, xpReward: 0, attacks: [{ id: `${id}-stun`, name: "Stunning attack", delivery: "projectile", target: "primary", school, animation: "attack", range: 30, cooldownMs: 1_000, damageMultiplier: 0, effect: { kind: "control", mechanic: "stun", durationMs: 5_000 }, }], }); setMobPosition(id, [2, 0, 0]); useCombatStore.getState().damageMob(id, 1, now); return useCombatStore.getState().mobs[id].nextAttackAt; }; const magicReadyAt = addControlAttacker("magic-stun", "shadow"); useCombatStore.getState().advanceMobCombat(magicReadyAt, [0, 0, 0]); expect(useCombatStore.getState().mobs["magic-stun"].attackRevision).toBe(1); expect(useCombatStore.getState().controlledUntil).toBe(0); const physicalReadyAt = addControlAttacker("physical-stun", "physical"); useCombatStore.getState().advanceMobCombat(physicalReadyAt, [0, 0, 0]); expect(useCombatStore.getState().mobs["physical-stun"].attackRevision).toBe(1); expect(useCombatStore.getState().controlledUntil).toBeGreaterThan(physicalReadyAt); }); it("places Anti-Magic Zone at the selected point and protects party members within 10 yards", () => { const inRangeAlly = selectPartyMember(0, "human"); const outOfRangeAlly = usePartyStore.getState().members[1].id; usePartyStore.setState((state) => ({ members: state.members.map((member) => ({ ...member, health: 1 })), })); registerPartyRuntimePosition(inRangeAlly, [5, 0, 0]); registerPartyRuntimePosition(outOfRangeAlly, [11, 0, 0]); const antiMagicZone = learnActiveAbility("Anti-Magic Zone"); const cast = useCombatStore.getState().castAbility(antiMagicZone.id, { now, groundPosition: [0, 0, 0], }); expect(cast.ok).toBe(true); expect(useCombatStore.getState().summons).toContainEqual(expect.objectContaining({ creatureId: 28306, position: [0, 0, 0], expiresAt: now + 10_000, antiMagicZone: expect.objectContaining({ radius: 10, absorbFraction: 0.75 }), })); const partyAuras = useCombatStore.getState().auras.filter((aura) => ( aura.definition.utilityTags?.includes("wow335-death-knight-anti-magic-zone") )); expect(partyAuras.map((aura) => aura.targetId)).toEqual(expect.arrayContaining([ PLAYER_AURA_ENTITY_ID, inRangeAlly, ])); expect(partyAuras.map((aura) => aura.targetId)).not.toContain(outOfRangeAlly); expect(partyAuras.find((aura) => aura.targetId === inRangeAlly)?.absorbs[0]).toMatchObject({ remaining: expect.any(Number), fraction: 0.75, schools: ["holy", "fire", "nature", "frost", "shadow", "arcane"], }); expect(partyAuras.find((aura) => aura.targetId === inRangeAlly)!.absorbs[0].remaining).toBeGreaterThan(10_000); expect(useCombatStore.getState().damagePlayer(100, "fire")).toBe(25); expect(useCombatStore.getState().damagePlayer(100, "physical")).toBe(100); registerPartyRuntimePosition(inRangeAlly, [11, 0, 0]); now += 250; useCombatStore.getState().tick(0.25, now); expect(useCombatStore.getState().auras.some((aura) => aura.targetId === inRangeAlly && aura.definition.utilityTags?.includes("wow335-death-knight-anti-magic-zone"))).toBe(false); }); it("spends Bone Shield's four charges on landed damage while keeping its reduction non-stacking", () => { const boneShield = learnActiveAbility("Bone Shield"); const boneCast = useCombatStore.getState().castAbility(boneShield.id, { now }); expect(boneCast.reason).toBeUndefined(); expect(boneCast.ok).toBe(true); const auraId = useCombatStore.getState().auras.find((aura) => aura.definition.name === "Bone Shield")!.instanceId; for (const remaining of [3, 2, 1]) { now += 1; expect(useCombatStore.getState().damagePlayer(10, "fire")).toBe(8); expect(useCombatStore.getState().auras.find((aura) => aura.instanceId === auraId)?.stacks).toBe(remaining); } now += 1; expect(useCombatStore.getState().damagePlayer(10, "fire")).toBe(8); expect(useCombatStore.getState().auras.some((aura) => aura.instanceId === auraId)).toBe(false); now += 1; expect(useCombatStore.getState().damagePlayer(10, "fire")).toBe(10); }); it("converts exactly one spent Blood Rune for Blood Tap without activating a Frost Rune", () => { const initialRunes = createDeathKnightRunes().map((rune) => ( rune.slot === 0 || rune.slot === 2 ? { ...rune, readyAt: now + 10_000 } : rune )); useCombatStore.setState({ deathKnightRunes: initialRunes }); const bloodTap = ability("Blood Tap"); expect(useCombatStore.getState().castAbility(bloodTap.id, { now }).ok).toBe(true); const runes = useCombatStore.getState().deathKnightRunes; expect(runes.filter((rune) => rune.type === "death")).toEqual([ expect.objectContaining({ slot: 0, baseType: "blood", readyAt: now, deathRuneExpiresAt: now + 20_000 }), ]); expect(runes.find((rune) => rune.slot === 2)).toMatchObject({ type: "frost", readyAt: now + 10_000 }); expect(runes.filter((rune) => rune.readyAt <= now)).toHaveLength(5); }); it("consumes Deathchill only on Icy Touch, Howling Blast, Frost Strike, or Obliterate", () => { registerTarget(); const deathchill = learnActiveAbility("Deathchill"); expect(useCombatStore.getState().castAbility(deathchill.id, { now }).ok).toBe(true); const auraId = useCombatStore.getState().auras.find((aura) => ( aura.definition.utilityTags?.includes("wow335-deathchill-next-critical") ))!.instanceId; now = 3_000; expect(useCombatStore.getState().castAbility(ability("Chains of Ice").id, { now }).ok).toBe(true); expect(useCombatStore.getState().auras.some((aura) => aura.instanceId === auraId)).toBe(true); now = 5_000; const icyTouch = ability("Icy Touch"); expect(useCombatStore.getState().castAbility(icyTouch.id, { now }).ok).toBe(true); expect(useCombatStore.getState().auras.some((aura) => aura.instanceId === auraId)).toBe(false); expect(useCombatStore.getState().combatEvents.some((event) => ( event.abilityId === icyTouch.id && event.kind === "damage" && event.critical ))).toBe(true); }); it("heals Death Strike for five percent of maximum health per active disease", () => { registerTarget(); const icyTouch = ability("Icy Touch"); const plagueStrike = ability("Plague Strike"); const deathStrike = ability("Death Strike"); expect(useCombatStore.getState().castAbility(icyTouch.id, { now }).ok).toBe(true); now = 3_000; useCombatStore.setState((state) => ({ ...state, health: 1 })); const oneDisease = useCombatStore.getState().castAbility(deathStrike.id, { now }); expect(oneDisease.ok).toBe(true); expect(oneDisease.healing).toBeGreaterThan(0); now = 5_000; expect(useCombatStore.getState().castAbility(plagueStrike.id, { now }).ok).toBe(true); useCombatStore.setState((state) => ({ ...state, health: 1, deathKnightRunes: createDeathKnightRunes(now), })); now = 7_000; const twoDiseases = useCombatStore.getState().castAbility(deathStrike.id, { now }); expect(twoDiseases.ok).toBe(true); expect(twoDiseases.healing).toBeGreaterThan(oneDisease.healing); expect(twoDiseases.healing).toBeCloseTo(oneDisease.healing * 2, -1); }); it.each([ { rank: 1, expectedRunicPower: 10 }, { rank: 2, expectedRunicPower: 20 }, ])("grants Butchery rank $rank's runic power on an eligible player kill", ({ rank, expectedRunicPower }) => { const targetId = `butchery-rank-${rank}`; registerXpTarget(targetId, 1_000); setButchery(rank); const result = useCombatStore.getState().damageMob(targetId, 10, now); expect(result.killed).toBe(true); expect(useCombatStore.getState().resource).toBe(expectedRunicPower); expect(useCombatStore.getState().resourcePools["runic-power"]).toBe(expectedRunicPower); }); it("caps Butchery at the runic power maximum", () => { const targetId = "butchery-cap-target"; registerXpTarget(targetId, 1_000); setButchery(2, 95); useCombatStore.getState().damageMob(targetId, 10, now); expect(useCombatStore.getState().resource).toBe(100); expect(useCombatStore.getState().resourcePools["runic-power"]).toBe(100); }); it("does not grant Butchery without the talent or for a target with no XP reward", () => { const noTalentTargetId = "butchery-no-talent-target"; registerXpTarget(noTalentTargetId, 1_000); setButchery(0); useCombatStore.getState().damageMob(noTalentTargetId, 10, now); expect(useCombatStore.getState().resourcePools["runic-power"]).toBe(0); const noXpTargetId = "butchery-no-xp-target"; registerXpTarget(noXpTargetId, 0); setButchery(2); useCombatStore.getState().damageMob(noXpTargetId, 10, now + 1); expect(useCombatStore.getState().resourcePools["runic-power"]).toBe(0); }); it("does not grant Butchery for a party or pet killing blow", () => { setButchery(2); const partyActorId = usePartyStore.getState().members[0].id; for (const [targetId, actorId] of [ ["butchery-party-target", partyActorId], ["butchery-pet-target", "butchery-test-pet"], ] as const) { registerXpTarget(targetId, 1_000); useCombatStore.getState().damageMob(targetId, 10, now, { actorId, role: "damage", abilityId: "test-non-player-kill", }); } expect(useCombatStore.getState().resourcePools["runic-power"]).toBe(0); }); it("applies Improved Rune Tap's rank-specific healing and cooldown reductions", () => { useCombatStore.setState((state) => ({ ...state, health: 1, talentRanks: { ...state.talentRanks, "talent-1941": 1 }, })); const runeTap = ability("Rune Tap"); const baseline = useCombatStore.getState().castAbility(runeTap.id, { now }); expect(baseline.ok).toBe(true); now = 3_000; useCombatStore.setState((state) => ({ ...state, health: 1, cooldowns: {}, deathKnightRunes: createDeathKnightRunes(now), talentRanks: { ...state.talentRanks, "talent-1942": 3 }, })); const improved = useCombatStore.getState().castAbility(runeTap.id, { now }); expect(improved.ok).toBe(true); expect(improved.healing).toBeCloseTo(baseline.healing * 2, -1); expect(useCombatStore.getState().cooldowns[runeTap.id]).toBe(now + 30_000); }); });