import { beforeEach, describe, expect, it } from "vitest"; import { abilityTalentNodeId, preloadAbilityCatalogsForClasses } from "./abilityCatalog"; import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry"; import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore"; import { applyAura } from "./combatAuras"; import { PLAYER_AGGRO_ID } from "./aggro"; import { usePartyStore } from "./partyStore"; import { WOW335_ABILITIES_BY_CLASS } from "./wow335AbilityCatalog"; import { WOW335_TALENT_NODES } from "./wow335Talents.generated"; function abilityId(name: string): string { const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === name); expect(ability, name).toBeDefined(); return ability!.id; } function unlockTalentAbility(name: string): string { const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === name); expect(ability, name).toBeDefined(); const nodeId = abilityTalentNodeId(ability!); expect(nodeId, name).toBeTruthy(); useCombatStore.setState((state) => ({ talentRanks: { ...state.talentRanks, [nodeId!]: 1 } })); return ability!.id; } function setTalentRank(name: string, rank: number): void { const talent = WOW335_TALENT_NODES.find((candidate) => candidate.name === name); expect(talent, name).toBeDefined(); useCombatStore.setState((state) => ({ talentRanks: { ...state.talentRanks, [talent!.id]: rank }, })); } function castAndFinish(name: string, now: number): number { const id = abilityId(name); const result = useCombatStore.getState().castAbility(id, undefined, now); expect(result.ok, `${name}: ${JSON.stringify(result)}`).toBe(true); const activeCast = useCombatStore.getState().activeCast; if (activeCast?.abilityId === id) { useCombatStore.getState().tick(0.1, activeCast.completesAt); return activeCast.completesAt; } return now; } function addNearbyMob(id: string, x: number, maxHealth = 100_000): void { useCombatStore.getState().registerMob(id, { name: id, maxHealth }); setMobPosition(id, [x, 0, 0]); } function deterministicUnit(seed: string): number { let hash = 2166136261; for (let index = 0; index < seed.length; index += 1) { hash ^= seed.charCodeAt(index); hash = Math.imul(hash, 16777619); } return (hash >>> 0) / 0x1_0000_0000; } describe("WoW 3.3.5 Warlock mechanics", () => { beforeEach(async () => { clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); await preloadAbilityCatalogsForClasses(["warlock"]); useCombatStore.getState().initializeCharacter({ classId: "warlock", level: 80 }); useCombatStore.getState().resetSettings(); useCombatStore.getState().setPlayerPosition([0, 0, 0]); usePartyStore.getState().initializeParty("wow335-warlock-mechanics-tests", 1, useCombatStore.getState().health); useCombatStore.setState((state) => ({ resource: state.maxResource, resourcePools: { ...state.resourcePools, mana: state.maxResourcePools.mana }, })); addNearbyMob("warlock-target", 4); useCombatStore.getState().selectMob("warlock-target"); }); it("requires and consumes the caster's Immolate for Conflagrate", () => { const target = useCombatStore.getState().mobs["warlock-target"]!; const conflagrateId = unlockTalentAbility("Conflagrate"); const unavailable = useCombatStore.getState().castAbility(conflagrateId, undefined, 1_000); expect(unavailable).toMatchObject({ ok: false, reason: "condition-not-met" }); const immolateCompleteAt = castAndFinish("Immolate", 3_000); const immolateId = abilityId("Immolate"); expect(useCombatStore.getState().effects.some((effect) => ( effect.kind === "dot" && effect.sourceAbilityId === immolateId && effect.targetId === target.id ))).toBe(true); const conflagrate = useCombatStore.getState().castAbility(conflagrateId, undefined, immolateCompleteAt + 100); expect(conflagrate.ok, JSON.stringify(conflagrate)).toBe(true); expect(conflagrate.damage).toBeGreaterThan(0); expect(useCombatStore.getState().effects.some((effect) => ( effect.kind === "dot" && effect.sourceAbilityId === immolateId && effect.targetId === target.id ))).toBe(false); }); it("applies Aftermath's generated rank-2 slow after a landed Conflagrate", () => { setTalentRank("Aftermath", 2); const conflagrateId = unlockTalentAbility("Conflagrate"); castAndFinish("Immolate", 3_000); const conflagrateAt = castAndFinish("Conflagrate", 8_000); const aftermathSlow = useCombatStore.getState().mobs["warlock-target"]?.statuses.find((status) => ( status.kind === "slow" && status.sourceAbilityId === conflagrateId )); expect(aftermathSlow).toMatchObject({ kind: "slow", sourceAbilityId: conflagrateId, sourceActorId: PLAYER_AGGRO_ID, magnitude: 0.5, endsAt: conflagrateAt + 4_000, }); }); it("models Shadowflame's missing periodic child so Conflagrate can consume it", () => { const shadowflameCompleteAt = castAndFinish("Shadowflame", 3_000); const shadowflameId = abilityId("Shadowflame"); const activeShadowflame = useCombatStore.getState().effects.find((effect) => ( effect.kind === "dot" && effect.sourceAbilityId === shadowflameId && effect.targetId === "warlock-target" )); expect(activeShadowflame).toMatchObject({ kind: "dot", intervalMs: 2_000, remainingTicks: 4, damageSchool: "fire" }); const conflagrate = useCombatStore.getState().castAbility( unlockTalentAbility("Conflagrate"), undefined, shadowflameCompleteAt + 2_000, ); expect(conflagrate.ok, JSON.stringify(conflagrate)).toBe(true); expect(conflagrate.damage).toBeGreaterThan(0); expect(useCombatStore.getState().effects.some((effect) => ( effect.kind === "dot" && effect.sourceAbilityId === shadowflameId ))).toBe(false); }); it("ramps Curse of Agony ticks through half, full, then one-and-a-half damage", () => { const curseId = abilityId("Curse of Agony"); const completeAt = castAndFinish("Curse of Agony", 1_000); for (let tick = 1; tick <= 12; tick += 1) { useCombatStore.getState().tick(0.1, completeAt + tick * 2_000); } const amounts = useCombatStore.getState().combatEvents .filter((event) => event.kind === "damage" && event.abilityId === curseId && event.targetActorId === "warlock-target") .map((event) => event.effectiveAmount); expect(amounts).toHaveLength(12); expect(new Set(amounts.slice(0, 4)).size).toBe(1); expect(new Set(amounts.slice(4, 8)).size).toBe(1); expect(new Set(amounts.slice(8, 12)).size).toBe(1); expect(Math.abs(amounts[4]! - 2 * amounts[0]!)).toBeLessThanOrEqual(2); expect(Math.abs(amounts[8]! - 3 * amounts[0]!)).toBeLessThanOrEqual(3); }); it("uses Seed's DBC threshold and rank-matched area explosion on damage", () => { const source = WOW335_ABILITIES_BY_CLASS.warlock.find((ability) => ability.name === "Seed of Corruption"); expect(source?.ranks?.map((rank) => rank.spellId)).toEqual([27_243, 47_835, 47_836]); addNearbyMob("seed-neighbor", 8); const completeAt = castAndFinish("Seed of Corruption", 1_000); expect(useCombatStore.getState().effects.find((effect) => effect.kind === "dot" && effect.targetId === "warlock-target")) .toMatchObject({ warlockSeed: { threshold: 1_518, explosionSpellId: 47_834, accumulatedDamage: 0 } }); const neighborHealth = useCombatStore.getState().mobs["seed-neighbor"]!.health; useCombatStore.getState().damageMob("warlock-target", 1_517, completeAt + 100, undefined, "shadow"); expect(useCombatStore.getState().mobs["seed-neighbor"]!.health).toBe(neighborHealth); useCombatStore.getState().damageMob("warlock-target", 1, completeAt + 200, undefined, "shadow"); expect(useCombatStore.getState().mobs["seed-neighbor"]!.health).toBeLessThan(neighborHealth); expect(useCombatStore.getState().effects.some((effect) => effect.kind === "dot" && effect.targetId === "warlock-target")) .toBe(false); }); it("detonates Seed when its carrier dies before the damage threshold", () => { addNearbyMob("seed-neighbor", 8); addNearbyMob("seed-carrier", 6, 1_000); useCombatStore.getState().selectMob("seed-carrier"); const completeAt = castAndFinish("Seed of Corruption", 1_000); const neighborHealth = useCombatStore.getState().mobs["seed-neighbor"]!.health; useCombatStore.getState().damageMob("seed-carrier", 1_000, completeAt + 100, undefined, "shadow"); expect(useCombatStore.getState().mobs["seed-carrier"]?.dead).toBe(true); expect(useCombatStore.getState().mobs["seed-neighbor"]!.health).toBeLessThan(neighborHealth); }); it("procs Backlash only from landed physical attacks, with its source duration and internal cooldown", () => { setTalentRank("Backlash", 3); const maxHealth = useCombatStore.getState().maxHealth; const readyTag = "wow335-warlock-backlash-ready"; const cooldownTag = "wow335-warlock-backlash-cooldown"; useCombatStore.getState().damagePlayer(1, "shadow", 80, 20_000); expect(useCombatStore.getState().auras.some((aura) => aura.definition.utilityTags?.includes(readyTag))).toBe(false); const procAt = Array.from({ length: 500 }, (_, offset) => 30_000 + offset) .find((now) => deterministicUnit( `${PLAYER_AGGRO_ID}:external:physical:${now}:${maxHealth}:wow335-warlock-backlash`, ) < 0.25); expect(procAt, "a deterministic source-rank Backlash proc seed").toBeDefined(); useCombatStore.setState({ health: maxHealth }); useCombatStore.getState().damagePlayer(1, "physical", 80, procAt!); const ready = useCombatStore.getState().auras.find((aura) => aura.definition.utilityTags?.includes(readyTag)); expect(ready?.expiresAt).toBe(procAt! + 12_000); expect(useCombatStore.getState().auras.some((aura) => aura.definition.utilityTags?.includes(cooldownTag) && aura.expiresAt === procAt! + 8_000)).toBe(true); useCombatStore.setState({ health: maxHealth }); useCombatStore.getState().damagePlayer(1, "physical", 80, procAt! + 1); expect(useCombatStore.getState().auras.find((aura) => aura.definition.utilityTags?.includes(readyTag))?.expiresAt) .toBe(procAt! + 12_000); }); it("makes Backlash's next Shadow Bolt instant, half-cost at rank 3, and consumes its proc", () => { setTalentRank("Backlash", 3); const playerId = PLAYER_AURA_ENTITY_ID; const shadowBolt = useCombatStore.getState().abilities.find((candidate) => candidate.name === "Shadow Bolt")!; const manaBefore = useCombatStore.getState().resourcePools.mana!; const normalCast = useCombatStore.getState().castAbility(shadowBolt.id, undefined, 1_000); expect(normalCast.ok, JSON.stringify(normalCast)).toBe(true); const normalActiveCast = useCombatStore.getState().activeCast; expect(normalActiveCast?.abilityId).toBe(shadowBolt.id); useCombatStore.getState().tick(0.1, normalActiveCast!.completesAt); const normalManaSpent = manaBefore - useCombatStore.getState().resourcePools.mana!; expect(normalManaSpent).toBeGreaterThan(0); const backlashCastAt = normalActiveCast!.completesAt + 1_000; const readyAura = applyAura([], { definition: { id: "wow335:warlock:backlash-ready", name: "Backlash", disposition: "buff", durationMs: 12_000, utilityTags: ["wow335-warlock-backlash-ready"], }, sourceId: playerId, targetId: playerId, now: backlashCastAt, }).auras; const afterNormal = useCombatStore.getState(); useCombatStore.setState({ auras: readyAura, activeCast: null, globalCooldownEndsAt: 0, resource: afterNormal.maxResource, resourcePools: { ...afterNormal.resourcePools, mana: afterNormal.maxResourcePools.mana! }, }); const procManaBefore = useCombatStore.getState().resourcePools.mana!; const result = useCombatStore.getState().castAbility(shadowBolt.id, undefined, backlashCastAt); expect(result.ok, JSON.stringify(result)).toBe(true); expect(useCombatStore.getState().activeCast).toBeNull(); expect(procManaBefore - useCombatStore.getState().resourcePools.mana!).toBe(Math.round(normalManaSpent * 0.5)); expect(useCombatStore.getState().auras.some((aura) => aura.definition.utilityTags?.includes( "wow335-warlock-backlash-ready", ))).toBe(false); expect(result.damage).toBeGreaterThan(0); }); });