import { describe, expect, it } from "vitest"; import { WOW335_ABILITIES_BY_CLASS } from "./wow335AbilityCatalog"; import { PLAYER_AURA_ENTITY_ID, useCombatStore, type CombatSummon } from "./combatStore"; import { setMobPosition } from "./mobRuntimeRegistry"; import { applyAura } from "./combatAuras"; import { usePartyStore } from "./partyStore"; import { registerPartyRuntimePosition } from "./partyRuntimeRegistry"; import { warlockSoulLinkDamageSplit, warlockSoulLinkDemon, warlockSoulLinkSharePercent, type WarlockSoulLinkSummon, } from "./wow335WarlockSoulLinkRuntime"; const demon = (overrides: Partial = {}): WarlockSoulLinkSummon => ({ sourceAbilityId: "wow335-warlock-summon-felhunter", ownerActorId: "warlock-player", health: 300, expiresAt: 10_000, controlledUntil: 0, position: [300, 0, 0], ...overrides, }); function combatDemon(overrides: Partial = {}): CombatSummon { return { id: "soul-link-felhunter", sourceAbilityId: "wow335-warlock-summon-felhunter", creatureId: 417, targetId: null, expiresAt: Number.MAX_SAFE_INTEGER, kind: "pet", name: "Felhunter", ownerActorId: PLAYER_AURA_ENTITY_ID, health: 500, maxHealth: 500, position: [10, 0, 0], nextActionAt: 0, pulseIntervalMs: 0, attackDamage: 0, abilityIds: [], resources: {}, cooldowns: {}, globalCooldownEndsAt: 0, cast: null, controlledUntil: 0, schoolLockedUntil: {}, ...overrides, }; } describe("WoW Warlock Soul Link mechanics", () => { it("uses the populated generated talent tooltip and exposes Soul Link as an active self buff", () => { const ability = WOW335_ABILITIES_BY_CLASS.warlock.find((candidate) => candidate.name === "Soul Link"); expect(ability?.target).toBe("self"); expect(ability?.description).toContain("6% of all damage taken by the caster"); expect(ability?.description).toContain("Cannot Soul Link a Summoned Imp"); expect(ability?.effects).toEqual(expect.arrayContaining([ expect.objectContaining({ kind: "apply-aura", recipient: "caster", aura: expect.objectContaining({ utilityTags: ["wow335-warlock-soul-link"], durationMs: null, }), }), ])); }); it("uses the generated Soul Link and Demonic Embrace rank descriptions", () => { expect([0, 1, 2, 3].map((rank) => warlockSoulLinkSharePercent(1, rank))) .toEqual([6, 8, 10, 12]); expect(warlockSoulLinkSharePercent(0, 3)).toBe(0); }); it("requires the caster's live, controlled, non-Imp demon within 30 yards", () => { const linked = warlockSoulLinkDemon([demon()], "warlock-player", [0, 0, 0], 1_000); expect(linked).not.toBeNull(); expect(warlockSoulLinkDemon( [demon({ sourceAbilityId: "wow335-warlock-summon-imp" })], "warlock-player", [0, 0, 0], 1_000, )).toBeNull(); expect(warlockSoulLinkDemon([demon({ ownerActorId: "another-warlock" })], "warlock-player", [0, 0, 0], 1_000)) .toBeNull(); expect(warlockSoulLinkDemon([demon({ health: 0 })], "warlock-player", [0, 0, 0], 1_000)).toBeNull(); expect(warlockSoulLinkDemon([demon({ expiresAt: 1_000 })], "warlock-player", [0, 0, 0], 1_000)).toBeNull(); expect(warlockSoulLinkDemon([demon({ controlledUntil: 1_001 })], "warlock-player", [0, 0, 0], 1_000)) .toBeNull(); expect(warlockSoulLinkDemon([demon({ position: [301, 0, 0] })], "warlock-player", [0, 0, 0], 1_000)) .toBeNull(); }); it("splits post-mitigation damage and rounds the unpreventable demon share", () => { expect(warlockSoulLinkDamageSplit(101, 6)).toEqual({ casterDamage: 95, demonDamage: 6 }); expect(warlockSoulLinkDamageSplit(5, 12)).toEqual({ casterDamage: 4, demonDamage: 1 }); expect(warlockSoulLinkDamageSplit(0, 12)).toEqual({ casterDamage: 0, demonDamage: 0 }); }); it("transfers the generated Soul Link and Demonic Embrace share during incoming damage", () => { useCombatStore.getState().initializeCharacter({ classId: "warlock", level: 80, xp: 0 }); useCombatStore.setState((state) => ({ talentRanks: { ...state.talentRanks, "talent-1282": 1, "talent-1223": 3 }, summons: [combatDemon()], playerPosition: [0, 0, 0], })); const soulLink = useCombatStore.getState().abilities.find((ability) => ability.name === "Soul Link"); expect(soulLink).toBeDefined(); expect(useCombatStore.getState().castAbility(soulLink!.id, undefined, 1_000).ok).toBe(true); const demonBefore = useCombatStore.getState().summons[0]!.health; const playerDamage = useCombatStore.getState().damagePlayer(100, "shadow", 80, 2_000); const state = useCombatStore.getState(); const playerHit = [...state.combatEvents].reverse().find((event) => ( event.kind === "damage" && event.targetActorId === PLAYER_AURA_ENTITY_ID && event.occurredAt === 2_000 )); const demonHit = [...state.combatEvents].reverse().find((event) => ( event.kind === "damage" && event.targetActorId === "soul-link-felhunter" && event.occurredAt === 2_000 )); expect(playerHit).toBeDefined(); expect(demonHit).toMatchObject({ abilityId: "wow335-warlock-soul-link", absorbed: 0 }); const expectedShare = Math.round(playerHit!.rawAmount * 0.12); expect(demonHit!.effectiveAmount).toBe(expectedShare); expect(state.summons[0]!.health).toBe(demonBefore - expectedShare); expect(playerDamage).toBe(playerHit!.rawAmount - expectedShare); }); it("shares enemy melee damage with an active demon during combat", () => { useCombatStore.getState().initializeCharacter({ classId: "warlock", level: 80, xp: 0 }); useCombatStore.setState((state) => ({ talentRanks: { ...state.talentRanks, "talent-1282": 1 }, summons: [combatDemon()], playerPosition: [0, 0, 0], })); const soulLink = useCombatStore.getState().abilities.find((ability) => ability.name === "Soul Link"); expect(useCombatStore.getState().castAbility(soulLink!.id, undefined, 1_000).ok).toBe(true); useCombatStore.getState().registerMob("soul-link-attacker", { name: "Attacker", maxHealth: 10_000, xpReward: 0 }); setMobPosition("soul-link-attacker", [2, 0, 0]); useCombatStore.getState().damageMob("soul-link-attacker", 1, 1_000); expect(useCombatStore.getState().advanceMobCombat(1_450, [0, 0, 0])).toBeGreaterThan(0); const state = useCombatStore.getState(); const demonHit = [...state.combatEvents].reverse().find((event) => ( event.kind === "damage" && event.targetActorId === "soul-link-felhunter" && event.occurredAt === 1_450 )); expect(demonHit).toMatchObject({ abilityId: "wow335-warlock-soul-link", absorbed: 0 }); expect(demonHit!.effectiveAmount).toBeGreaterThan(0); expect(state.summons[0]!.health).toBe(500 - demonHit!.effectiveAmount); }); it("shares enemy damage with a companion Warlock's linked demon", () => { useCombatStore.getState().initializeCharacter({ classId: "warlock", level: 80, xp: 0 }); usePartyStore.getState().initializeParty("soul-link-companion-test", 80, useCombatStore.getState().health); const member = usePartyStore.getState().members[0]!; usePartyStore.setState((state) => ({ members: state.members.map((candidate) => candidate.id === member.id ? { ...candidate, classId: "warlock", talentRanks: { ...candidate.talentRanks, "talent-1282": 1 } } : candidate), })); registerPartyRuntimePosition(member.id, [0, 0, 0]); const linkAbility = WOW335_ABILITIES_BY_CLASS.warlock.find((ability) => ability.name === "Soul Link")!; const linkAura = linkAbility.effects.find((effect) => effect.kind === "apply-aura")!; if (linkAura.kind !== "apply-aura") throw new Error("Soul Link must apply its caster aura"); const companionDemon = combatDemon({ id: "companion-soul-link-felhunter", ownerActorId: member.id, position: [10, 0, 0], }); useCombatStore.setState((state) => ({ playerPosition: [20, 0, 0], summons: [companionDemon], auras: [...applyAura(state.auras, { definition: linkAura.aura, sourceId: member.id, targetId: member.id, now: 1_000, }).auras], })); useCombatStore.getState().registerMob("companion-soul-link-attacker", { name: "Attacker", maxHealth: 10_000, xpReward: 0, }); setMobPosition("companion-soul-link-attacker", [2, 0, 0]); useCombatStore.getState().damageMob( "companion-soul-link-attacker", 1, 1_000, { actorId: member.id, role: "tank" }, ); let combatDamage = 0; for (let now = 3_350; now < 20_000; now += 1_900) { combatDamage += useCombatStore.getState().advanceMobCombat(now, [20, 0, 0]); if (useCombatStore.getState().combatEvents.some((event) => ( event.kind === "damage" && event.targetActorId === "companion-soul-link-felhunter" && event.occurredAt === now ))) break; } expect(combatDamage).toBeGreaterThan(0); const state = useCombatStore.getState(); const demonHit = [...state.combatEvents].reverse().find((event) => ( event.kind === "damage" && event.targetActorId === "companion-soul-link-felhunter" && event.occurredAt >= 3_350 )); expect(demonHit).toMatchObject({ abilityId: "wow335-warlock-soul-link", absorbed: 0 }); expect(demonHit!.effectiveAmount).toBeGreaterThan(0); expect(state.summons[0]!.health).toBe(500 - demonHit!.effectiveAmount); }); });