import { beforeAll, beforeEach, describe, expect, it } from "vitest"; import type { AbilityEffect } from "./abilityCatalog"; import { abilitiesForCharacter, learnedAbilityForBuild, preloadAbilityCatalogsForClasses } from "./abilityCatalog"; import { cooldownResetCandidates } from "./cooldownReset"; import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; import { clearPartyRuntimeRegistry, registerPartyRuntimePosition, updatePartyRuntimeTargetVisibility, } from "./partyRuntimeRegistry"; import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime"; import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore"; import { createConfiguredPartyMember, usePartyStore } from "./partyStore"; import { ROM_ALL_ABILITIES } from "./romAbilityCatalog"; import { romEffectsForCast } from "./romCombatMechanics"; import { ROM_WARLOCK_STATE_TAGS, } from "./romWarlockOverrides"; const TARGET_ID = "rom-warlock-runtime-target"; function ability(spellId: number) { const result = ROM_ALL_ABILITIES.find((candidate) => candidate.dbcSpellId === spellId); expect(result, `RoM Warlock spell ${spellId}`).toBeDefined(); return result!; } function finishCast(now: number) { const cast = useCombatStore.getState().activeCast; if (cast) useCombatStore.getState().tick((cast.completesAt - now) / 1_000 + 0.01, cast.completesAt); } function prepareWarlock(secondaryClassId: "rom-warrior" | "rom-rogue" | "rom-priest") { useCombatStore.getState().initializeCharacter({ classId: "rom-warlock", secondaryClassId, raceId: "rom-human", level: 80, }); useCombatStore.setState((state) => ({ resource: 100, maxResource: 100, resourcePools: { ...state.resourcePools, focus: 100, rage: 0, energy: 100, psi: 0, }, maxResourcePools: { ...state.maxResourcePools, focus: 100, rage: 100, energy: 100, psi: 6, }, })); } function registerTarget() { useCombatStore.getState().registerMob(TARGET_ID, { name: "Warlock Test Target", maxHealth: 100_000 }); setMobPosition(TARGET_ID, [5, 0, 0]); } function prepareWarlockPriestSupportCompanion(readySpellId: number, startingPsi: number) { useCombatStore.getState().initializeCharacter({ classId: "rom-priest", raceId: "rom-dwarf", level: 80 }); usePartyStore.getState().initializeParty("rom-warlock-priest-psi-companion", 80, useCombatStore.getState().health, "healer", 2, "rom"); const companion = createConfiguredPartyMember({ name: "Warlock/Priest Psi Companion", classId: "rom-warlock", secondaryClassId: "rom-priest", raceId: "rom-dwarf", specialization: "Warlock", role: "healer", }, 80, 0, "rom-warlock-priest-psi-companion"); const ally = createConfiguredPartyMember({ name: "Warlock/Priest Heal Target", classId: "rom-warlock", secondaryClassId: "rom-priest", raceId: "rom-dwarf", specialization: "Warlock", role: "damage", }, 80, 1, "rom-warlock-priest-psi-companion"); const pairAbilities = abilitiesForCharacter("rom-warlock", "rom-priest"); const readyAbility = pairAbilities.find((candidate) => candidate.dbcSpellId === readySpellId)!; const cooldowns = Object.fromEntries(pairAbilities .filter((candidate) => candidate.id !== readyAbility.id) .map((candidate) => [candidate.id, 100_000])); usePartyStore.setState({ command: "attack", members: [{ ...companion, cooldowns, resourcePools: { ...companion.resourcePools, focus: 100, psi: startingPsi }, resource: 100, }, ally], }); useCombatStore.getState().synchronizeActors(); registerPartyRuntimePosition(companion.id, [1, 0, 0]); registerPartyRuntimePosition(ally.id, [2, 0, 0]); usePartyStore.getState().damageMember(ally.id, 500); useCombatStore.getState().damagePlayer(500); registerTarget(); useCombatStore.getState().damageMob(TARGET_ID, 100, 900); updatePartyRuntimeTargetVisibility(companion.id, TARGET_ID, true); return { companion, ally, readyAbility }; } function prepareWarlockPriestFriendlyTarget() { usePartyStore.getState().initializeParty("rom-warlock-priest-psi-player", 80, useCombatStore.getState().health, "healer", 2, "rom"); const partyMember = usePartyStore.getState().members[0]!; registerPartyRuntimePosition(partyMember.id, [2, 0, 0]); usePartyStore.getState().damageMember(partyMember.id, 500); useCombatStore.getState().damagePlayer(500); usePartyStore.getState().selectMember(partyMember.id); return partyMember; } function completeWarlockCompanionFriendlyCast( companionId: string, abilityId: string, now: number, castTimeMs: number, targetId = PLAYER_AURA_ENTITY_ID, ) { const position: [number, number, number] = [1, 0, 0]; usePartyStore.setState((state) => ({ members: state.members.map((member) => member.id === companionId ? { ...member, activeCast: { abilityId, school: "shadow", mode: "cast", startedAt: now, completesAt: now + castTimeMs, originalDurationMs: castTimeMs, pushbackHits: 0, origin: position, targetId, }, activeCastTargetId: targetId, status: "healing" as const, } : member), })); useCombatStore.getState().commitCompanionActors(usePartyStore.getState().members); advanceDungeonPartyCombat(now + castTimeMs); } describe("RuneWaker Warlock runtime", () => { beforeAll(async () => { await preloadAbilityCatalogsForClasses(["rom-warlock"]); }); beforeEach(() => { clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); resetPartyRuntimeClock(); useCombatStore.setState({ playerPosition: [0, 0, 0] }); }); it("gives the caster Ruthless Judgment's source bonus only below 20% target health", () => { const ruthlessJudgment = ability(498544); const effectsFor = (targetHealthPercent: number) => romEffectsForCast(ruthlessJudgment, { sealStacks: 0, bleedCount: 0, charged: false, mainHandDagger: false, casterMaxHealth: 10_000, targetHealthPercent, }); const bonusAuras = (targetHealthPercent: number) => effectsFor(targetHealthPercent).filter((effect) => ( effect.kind === "apply-aura" && effect.sourceAuraType === 192 && effect.miscValue === 5 )); const below = bonusAuras(0.199); expect(below).toHaveLength(1); expect(below[0]).toMatchObject({ kind: "apply-aura", recipient: "caster", aura: { disposition: "buff", durationMs: 20_000, modifiers: [expect.objectContaining({ kind: "damage", direction: "dealt", attackKind: "spell", operation: "percent", value: 0.05, })], }, }); expect(bonusAuras(0.2)).toHaveLength(0); expect(bonusAuras(0.8)).toHaveLength(0); const sourceDamage = ruthlessJudgment.effects.find((effect) => effect.kind === "damage"); expect(effectsFor(0.199).find((effect) => effect.kind === "damage")).toEqual(sourceDamage); expect(effectsFor(0.2).find((effect) => effect.kind === "damage")).toEqual(sourceDamage); }); it("applies Ruthless Judgment's low-health bonus to the player caster", () => { prepareWarlock("rom-priest"); registerTarget(); useCombatStore.getState().damageMob(TARGET_ID, 81_000, 900); const ruthlessJudgment = ability(498544); expect(useCombatStore.getState().castAbility(ruthlessJudgment.id, { targetActorId: TARGET_ID, now: 1_000, }).ok).toBe(true); const completesAt = useCombatStore.getState().activeCast?.completesAt; expect(completesAt).toBeDefined(); finishCast(1_000); const bonusAura = useCombatStore.getState().auras.find((aura) => ( aura.sourceId === PLAYER_AURA_ENTITY_ID && aura.targetId === PLAYER_AURA_ENTITY_ID && aura.definition.name.startsWith("Ruthless Judgment") )); expect(bonusAura).toMatchObject({ definition: { disposition: "buff", modifiers: [expect.objectContaining({ kind: "damage", direction: "dealt", value: 0.05 })], }, }); expect(bonusAura?.expiresAt).toBe(completesAt! + 20_000); expect(useCombatStore.getState().auras.some((aura) => ( aura.targetId === TARGET_ID && aura.definition.name.startsWith("Ruthless Judgment") ))).toBe(false); }); it("applies Ruthless Judgment's low-health bonus to a companion Warlock caster", () => { useCombatStore.getState().initializeCharacter({ classId: "rom-priest", raceId: "rom-dwarf", level: 80 }); usePartyStore.getState().initializeParty("rom-warlock-ruthless-companion", 80, useCombatStore.getState().health, "damage", 2, "rom"); const warlock = createConfiguredPartyMember({ name: "Ruthless Companion", classId: "rom-warlock", secondaryClassId: "rom-priest", raceId: "rom-dwarf", specialization: "Warlock", role: "damage", }, 80, 0, "rom-warlock-ruthless-companion"); const ruthlessJudgment = ability(498544); const pairAbilities = abilitiesForCharacter("rom-warlock", "rom-priest"); usePartyStore.setState({ command: "attack", members: [{ ...warlock, cooldowns: Object.fromEntries(pairAbilities .filter((candidate) => candidate.id !== ruthlessJudgment.id) .map((candidate) => [candidate.id, 100_000])), resourcePools: { ...warlock.resourcePools, focus: 100 }, resource: 100, }], }); useCombatStore.getState().synchronizeActors(); registerPartyRuntimePosition(warlock.id, [0, 0, 0]); registerTarget(); useCombatStore.getState().damageMob(TARGET_ID, 81_000, 900); updatePartyRuntimeTargetVisibility(warlock.id, TARGET_ID, true); advanceDungeonPartyCombat(1_000); advanceDungeonPartyCombat(3_100); const bonusAura = useCombatStore.getState().auras.find((aura) => ( aura.sourceId === warlock.id && aura.targetId === warlock.id && aura.definition.name.startsWith("Ruthless Judgment") )); expect(bonusAura).toMatchObject({ expiresAt: 23_100, definition: { disposition: "buff", modifiers: [expect.objectContaining({ kind: "damage", direction: "dealt", value: 0.05 })], }, }); expect(useCombatStore.getState().auras.some((aura) => ( aura.targetId === TARGET_ID && aura.definition.name.startsWith("Ruthless Judgment") ))).toBe(false); }); it("converts a fixed 75 Rage cost into six Psi for Mind Transference", () => { prepareWarlock("rom-warrior"); useCombatStore.setState((state) => ({ resourcePools: { ...state.resourcePools, rage: 100, psi: 1 }, })); const mindTransference = ability(498642); expect(mindTransference.costs).toEqual([{ resource: "rage", amount: 75, percentage: false }]); expect(mindTransference.cooldownMs).toBe(1_000); expect(mindTransference.effects).toEqual([{ kind: "resource", resource: "psi", amount: 6 }]); expect(useCombatStore.getState().castAbility(mindTransference.id, { now: 1_000 }).ok).toBe(true); expect(useCombatStore.getState().resourcePools.rage).toBe(25); expect(useCombatStore.getState().resourcePools.psi).toBe(6); }); it("describes current CoA Breathing Control triggers without restoring the RuneWaker cooldown reset", () => { const breathingControl = ability(498634); expect(breathingControl.description).toContain( "Psychic Arrows, Warp Charge, Weakening Weave Curse, Slash, Soul Trauma, Puzzlement, Heart Collection Strike, or Surge of Malice", ); expect(breathingControl.description).toContain("trigger Enraged automatically"); expect(breathingControl.description).toContain("does not publish the proc chance, trigger timing, or extra Focus amount"); expect(breathingControl.description).not.toContain("reset Enraged's cooldown"); expect(breathingControl.effects.some((effect) => effect.kind === "cooldown-reset")).toBe(false); }); it("grants one capped caster Psi from an accepted Ring of Comfort cast for the exact pair", () => { prepareWarlock("rom-priest"); const ringOfComfort = abilitiesForCharacter("rom-warlock", "rom-priest") .find((candidate) => candidate.dbcSpellId === 498641)!; const context = { sealStacks: 0, bleedCount: 0, charged: false, mainHandDagger: false, casterMaxHealth: 10_000, targetHealthPercent: 1, } as const; expect(romEffectsForCast(ringOfComfort, context).filter((effect) => ( effect.kind === "resource" && effect.resource === "psi" ))).toEqual([{ kind: "resource", resource: "psi", amount: 1, scope: "caster" }]); const otherPair = { ...ringOfComfort, secondaryClassId: "rom-warrior" as const }; expect(romEffectsForCast(otherPair, context).some((effect) => ( effect.kind === "resource" && effect.resource === "psi" ))).toBe(false); const partyMember = prepareWarlockPriestFriendlyTarget(); useCombatStore.setState((state) => ({ cooldowns: { ...state.cooldowns, [ringOfComfort.id]: 2_000 } })); expect(useCombatStore.getState().castAbility(ringOfComfort.id, undefined, 1_000).ok).toBe(false); expect(useCombatStore.getState().resourcePools.psi).toBe(0); useCombatStore.setState((state) => ({ cooldowns: { ...state.cooldowns, [ringOfComfort.id]: 0 } })); expect(useCombatStore.getState().castAbility(ringOfComfort.id, undefined, 3_000)).toMatchObject({ ok: true }); const healingEvents = useCombatStore.getState().combatEvents.filter((event) => event.kind === "healing" && event.abilityId === ringOfComfort.id); expect(healingEvents.length).toBeGreaterThan(1); expect(useCombatStore.getState().resourcePools.psi).toBe(1); expect(usePartyStore.getState().members.find((candidate) => candidate.id === partyMember.id)?.resourcePools.psi ?? 0).toBe(0); useCombatStore.setState((state) => ({ resourcePools: { ...state.resourcePools, psi: 5 }, cooldowns: { ...state.cooldowns, [ringOfComfort.id]: 0 }, })); expect(useCombatStore.getState().castAbility(ringOfComfort.id, undefined, 12_000)).toMatchObject({ ok: true }); expect(useCombatStore.getState().resourcePools.psi).toBe(6); }); it("grants one capped caster Psi from a generated Warlock/Priest Ring of Comfort cast", () => { const { companion, ally, readyAbility } = prepareWarlockPriestSupportCompanion(498641, 5); completeWarlockCompanionFriendlyCast(companion.id, readyAbility.id, 1_000, readyAbility.castTimeMs, ally.id); const result = usePartyStore.getState().members.find((candidate) => candidate.id === companion.id)!; expect(result.resourcePools.psi).toBe(6); expect(usePartyStore.getState().members.find((candidate) => candidate.id === ally.id)?.resourcePools.psi ?? 0).toBe(0); expect(useCombatStore.getState().combatEvents.filter((event) => event.kind === "resource" && event.abilityId === readyAbility.id && event.targetActorId === companion.id)).toHaveLength(1); }); it("routes Mind Shield's Urgent Heal Psi to the player caster, not its selected ally", () => { prepareWarlock("rom-priest"); const urgentHeal = abilitiesForCharacter("rom-warlock", "rom-priest") .find((candidate) => candidate.dbcSpellId === 491147)!; const partyMember = prepareWarlockPriestFriendlyTarget(); const firstCast = useCombatStore.getState().castAbility(urgentHeal.id, undefined, 1_000); expect(firstCast).toMatchObject({ ok: true }); finishCast(1_000); expect(useCombatStore.getState().resourcePools.psi).toBe(1); expect(usePartyStore.getState().members.find((candidate) => candidate.id === partyMember.id)?.resourcePools.psi ?? 0).toBe(0); useCombatStore.setState((state) => ({ resourcePools: { ...state.resourcePools, psi: 5 }, cooldowns: { ...state.cooldowns, [urgentHeal.id]: 0 }, })); const secondCast = useCombatStore.getState().castAbility(urgentHeal.id, undefined, 10_000); expect(secondCast).toMatchObject({ ok: true }); finishCast(10_000); expect(useCombatStore.getState().resourcePools.psi).toBe(6); }); it("routes Mind Shield's companion Urgent Heal Psi to its Warlock/Priest caster", () => { const { companion, ally, readyAbility } = prepareWarlockPriestSupportCompanion(491147, 5); completeWarlockCompanionFriendlyCast(companion.id, readyAbility.id, 1_000, readyAbility.castTimeMs, ally.id); const result = usePartyStore.getState().members.find((candidate) => candidate.id === companion.id)!; expect(result.resourcePools.psi).toBe(6); expect(usePartyStore.getState().members.find((candidate) => candidate.id === ally.id)?.resourcePools.psi ?? 0).toBe(0); expect(useCombatStore.getState().combatEvents.filter((event) => event.kind === "resource" && event.abilityId === readyAbility.id && event.targetActorId === companion.id)).toHaveLength(1); }); it("uses current Warlock/Priest passive effects and pair upgrades", () => { const context = { sealStacks: 0, bleedCount: 0, charged: false, mainHandDagger: false, casterMaxHealth: 10_000, targetHealthPercent: 1, learnedPassiveSpellIds: [498633, 498643, 498648, 498668], } as const; const divineAssistance = ability(498668); expect(divineAssistance.description).toContain("magical damage and healing by 16%"); expect(divineAssistance.effects).toContainEqual(expect.objectContaining({ kind: "apply-aura", aura: expect.objectContaining({ durationMs: null, modifiers: expect.arrayContaining([ expect.objectContaining({ kind: "damage", direction: "dealt", school: "shadow", value: 0.16 }), expect.objectContaining({ kind: "healing", direction: "done", value: 0.16 }), ]), }), })); const healingFortune = romEffectsForCast(ability(497957), context) .find((effect) => effect.kind === "apply-aura"); expect(healingFortune).toMatchObject({ kind: "apply-aura", recipient: "caster", requiresLandedHit: true, aura: { durationMs: 20_000, modifiers: expect.arrayContaining([ expect.objectContaining({ kind: "healing", direction: "done", value: 0.12 }), expect.objectContaining({ kind: "stat", stat: "wisdom", value: 0.12 }), ]), }, }); const pairAbilities = abilitiesForCharacter("rom-warlock", "rom-priest"); const risingTide = pairAbilities.find((candidate) => candidate.dbcSpellId === 490256)!; expect(learnedAbilityForBuild(risingTide, 29, {})).toMatchObject({ castTimeMs: 2_000, cooldownMs: 0 }); const upgradedRisingTide = learnedAbilityForBuild(risingTide, 30, {})!; expect(upgradedRisingTide).toMatchObject({ castMode: "instant", castTimeMs: 0, cooldownMs: 5_000 }); expect(upgradedRisingTide.description).toContain("cannot redirect an ally-targeted cast"); expect(romEffectsForCast(upgradedRisingTide, context)).toContainEqual({ kind: "resource", resource: "psi", amount: 1, }); expect(romEffectsForCast(upgradedRisingTide, context).find((effect) => ( effect.kind === "apply-aura" && effect.requiresCriticalHit ))).toBeDefined(); const warpCharge = pairAbilities.find((candidate) => candidate.dbcSpellId === 497958)!; expect(learnedAbilityForBuild(warpCharge, 14, {})?.target).toBe("hostile"); const upgradedWarpCharge = learnedAbilityForBuild(warpCharge, 15, {})!; expect(upgradedWarpCharge.target).toBe("any"); const friendlyEffects = romEffectsForCast(upgradedWarpCharge, { ...context, selectedTargetRelationship: "friendly", }); expect(friendlyEffects.some((effect) => effect.kind === "heal")).toBe(true); expect(friendlyEffects.some((effect) => effect.kind === "damage")).toBe(false); expect(romEffectsForCast(upgradedWarpCharge, { ...context, selectedTargetRelationship: "hostile", }).some((effect) => effect.kind === "damage")).toBe(true); expect(romEffectsForCast(ability(491147), context)).toContainEqual({ kind: "resource", resource: "psi", amount: 1, scope: "caster", }); expect(ability(498633).description).toContain("12% Wisdom for 20 seconds"); expect(ability(498648).description).toContain("Ring of Comfort's reset chance is not specified"); }); it("applies Healing Fortune and Divine Assistance for the player and generated companion", () => { prepareWarlock("rom-priest"); expect(useCombatStore.getState().auras.some((aura) => aura.sourceId === PLAYER_AURA_ENTITY_ID && aura.targetId === PLAYER_AURA_ENTITY_ID && aura.definition.id === "rom-warlock-divine-assistance-498668")).toBe(true); registerTarget(); const psychicArrows = ability(497957); expect(useCombatStore.getState().castAbility(psychicArrows.id, { targetActorId: TARGET_ID, now: 1_000, }).ok).toBe(true); finishCast(1_000); expect(useCombatStore.getState().auras.some((aura) => aura.sourceId === PLAYER_AURA_ENTITY_ID && aura.targetId === PLAYER_AURA_ENTITY_ID && aura.definition.id === "rom-warlock-healing-fortune-498633")).toBe(true); useCombatStore.getState().initializeCharacter({ classId: "rom-priest", raceId: "rom-dwarf", level: 80 }); usePartyStore.getState().initializeParty("rom-warlock-priest-companion", 80, useCombatStore.getState().health, "damage", 2, "rom"); const companion = createConfiguredPartyMember({ name: "Warlock/Priest Companion", classId: "rom-warlock", secondaryClassId: "rom-priest", raceId: "rom-dwarf", specialization: "Warlock", role: "damage", }, 80, 0, "rom-warlock-priest-companion"); const pairAbilities = abilitiesForCharacter("rom-warlock", "rom-priest"); usePartyStore.setState({ command: "attack", members: [{ ...companion, cooldowns: Object.fromEntries(pairAbilities .filter((candidate) => candidate.dbcSpellId !== 497957) .map((candidate) => [candidate.id, 100_000])), resourcePools: { ...companion.resourcePools, focus: 100 }, resource: 100, }], }); useCombatStore.getState().synchronizeActors(); registerPartyRuntimePosition(companion.id, [0, 0, 0]); registerTarget(); useCombatStore.getState().damageMob(TARGET_ID, 1, 900); updatePartyRuntimeTargetVisibility(companion.id, TARGET_ID, true); advanceDungeonPartyCombat(1_000); advanceDungeonPartyCombat(3_100); expect(useCombatStore.getState().auras.some((aura) => aura.sourceId === companion.id && aura.targetId === companion.id && aura.definition.id === "rom-warlock-divine-assistance-498668")).toBe(true); expect(useCombatStore.getState().auras.some((aura) => aura.sourceId === companion.id && aura.targetId === companion.id && aura.definition.id === "rom-warlock-healing-fortune-498633")).toBe(true); }); it("turns friendly Warp Charge into a heal for a Warlock/Priest player", () => { prepareWarlock("rom-priest"); useCombatStore.setState((state) => ({ health: Math.max(1, state.maxHealth - 100), })); usePartyStore.getState().selectSelf(); const warpCharge = abilitiesForCharacter("rom-warlock", "rom-priest") .find((candidate) => candidate.dbcSpellId === 497958)!; const result = useCombatStore.getState().castAbility(warpCharge.id, { now: 1_000, }); expect(result.ok).toBe(true); expect(useCombatStore.getState().health).toBeGreaterThan(useCombatStore.getState().maxHealth - 100); }); it("applies the pair upgrade when the player casts Rising Tide", () => { prepareWarlock("rom-priest"); registerTarget(); const risingTide = abilitiesForCharacter("rom-warlock", "rom-priest") .find((candidate) => candidate.dbcSpellId === 490256)!; expect(useCombatStore.getState().castAbility(risingTide.id, { targetActorId: TARGET_ID, now: 1_000, }).ok).toBe(true); expect(useCombatStore.getState().activeCast).toBeNull(); expect(useCombatStore.getState().resourcePools.psi).toBe(1); expect(useCombatStore.getState().cooldowns[risingTide.id]).toBe(6_000); }); it("consumes all Rage for Heart Fence and extends its duration by one second per 20 Rage", () => { prepareWarlock("rom-warrior"); usePartyStore.getState().initializeParty( "rom-warlock-heart-fence", 80, useCombatStore.getState().health, "damage", 3, "rom", ); const member = usePartyStore.getState().members[0]!; registerPartyRuntimePosition(member.id, [4, 0, 0]); useCombatStore.getState().synchronizeActors(); useCombatStore.setState((state) => ({ resourcePools: { ...state.resourcePools, rage: 45 } })); const heartFence = ability(498645); expect(useCombatStore.getState().castAbility(heartFence.id, { targetActorId: member.id, now: 1_000, }).ok).toBe(true); expect(useCombatStore.getState().resourcePools.rage).toBe(0); const defenseAuras = useCombatStore.getState().auras.filter((aura) => aura.targetId === member.id && aura.definition.name.startsWith("Heart Fence") && aura.definition.modifiers?.some((modifier) => modifier.kind === "damage" && modifier.direction === "taken")); expect(defenseAuras).toHaveLength(2); expect(defenseAuras.every((aura) => aura.expiresAt === 5_000)).toBe(true); }); it("only refreshes state 501577 and restores Rage when Consistent Heart Strike finds it", () => { prepareWarlock("rom-warrior"); registerTarget(); const heartStrike = ability(498646); useCombatStore.setState((state) => ({ resourcePools: { ...state.resourcePools, rage: 25 } })); expect(useCombatStore.getState().castAbility(heartStrike.id, { targetActorId: TARGET_ID, now: 500 }).ok).toBe(true); expect(useCombatStore.getState().resourcePools.rage).toBe(25); const soulTrauma = ability(498550); const traumaCast = useCombatStore.getState().castAbility(soulTrauma.id, { targetActorId: TARGET_ID, now: 2_000 }); expect(traumaCast, JSON.stringify(traumaCast)).toMatchObject({ ok: true }); const heartStatesAfterTrauma = useCombatStore.getState().auras.filter((aura) => aura.targetId === TARGET_ID && aura.definition.utilityTags?.includes(ROM_WARLOCK_STATE_TAGS.heartFence)); expect(heartStatesAfterTrauma.some((aura) => aura.expiresAt === 17_000)).toBe(true); useCombatStore.setState((state) => ({ resourcePools: { ...state.resourcePools, rage: 20 } })); expect(useCombatStore.getState().castAbility(heartStrike.id, { targetActorId: TARGET_ID, now: 5_000 }).ok).toBe(true); expect(useCombatStore.getState().resourcePools.rage).toBe(100); const heartStatesAfterStrike = useCombatStore.getState().auras.filter((aura) => aura.targetId === TARGET_ID && aura.definition.utilityTags?.includes(ROM_WARLOCK_STATE_TAGS.heartFence)); expect(heartStatesAfterStrike.length).toBeGreaterThan(0); expect(heartStatesAfterStrike.every((aura) => aura.expiresAt === 20_000)).toBe(true); }); it("scales Crime and Punishment by 25% per state after the first", () => { const crimeAndPunishment = ability(498700); const baseDamage = crimeAndPunishment.effects.find((effect) => effect.kind === "damage"); if (baseDamage?.kind !== "damage") throw new Error("Missing Crime and Punishment damage effect"); const effectsFor = (targetTags: readonly string[]) => romEffectsForCast(crimeAndPunishment, { sealStacks: 0, bleedCount: 0, charged: false, mainHandDagger: false, casterMaxHealth: 10_000, targetHealthPercent: 1, targetTags, }); const oneState = effectsFor([ ROM_WARLOCK_STATE_TAGS.perceptionExtraction, ]).find((effect) => effect.kind === "damage"); const twoStates = effectsFor([ ROM_WARLOCK_STATE_TAGS.perceptionExtraction, ROM_WARLOCK_STATE_TAGS.soulPain, ]).find((effect) => effect.kind === "damage"); const threeStates = effectsFor([ ROM_WARLOCK_STATE_TAGS.perceptionExtraction, ROM_WARLOCK_STATE_TAGS.soulPain, ROM_WARLOCK_STATE_TAGS.soulPoisonedFang, ]).find((effect) => effect.kind === "damage"); expect(oneState).toEqual(baseDamage); expect(twoStates).toMatchObject({ coefficient: baseDamage.coefficient * 1.25, basePoints: baseDamage.basePoints! * 1.25, }); expect(threeStates).toMatchObject({ coefficient: baseDamage.coefficient * 1.5, basePoints: baseDamage.basePoints! * 1.5, }); }); it("refreshes the active three Warlock state effects after Crime and Punishment", () => { prepareWarlock("rom-rogue"); registerTarget(); for (const spellId of [497960, 497964, 498670]) { const stateSpell = ability(spellId); const castAt = spellId === 497960 ? 1_000 : spellId === 497964 ? 2_500 : 4_000; expect(useCombatStore.getState().castAbility(stateSpell.id, { targetActorId: TARGET_ID, now: castAt }).ok).toBe(true); finishCast(castAt); } useCombatStore.getState().tick(2.1, 6_100); const before = useCombatStore.getState().effects.filter((effect) => effect.kind === "dot" && effect.targetId === TARGET_ID && ["497960", "497964", "498670"].some((id) => effect.tag === `rom-warlock-state-${id}`)); expect(before.map((effect) => effect.remainingTicks).every((ticks) => ticks > 0)).toBe(true); expect(before).toHaveLength(3); expect(before.every((effect) => { const sourceDot = useCombatStore.getState().abilities.find((candidate) => candidate.id === effect.sourceAbilityId) ?.effects.find((entry): entry is Extract => ( entry.kind === "dot" && entry.tag === effect.tag )); if (sourceDot?.kind !== "dot") return false; return effect.remainingTicks < sourceDot.ticks; })).toBe(true); const crimeAndPunishment = ability(498700); expect(useCombatStore.getState().castAbility(crimeAndPunishment.id, { targetActorId: TARGET_ID, now: 6_200 }).ok).toBe(true); const after = useCombatStore.getState().effects.filter((effect) => effect.kind === "dot" && effect.targetId === TARGET_ID && ["497960", "497964", "498670"].some((id) => effect.tag === `rom-warlock-state-${id}`)); expect(after).toHaveLength(before.length); expect(after.every((effect) => effect.nextTickAt > 6_200)).toBe(true); expect(after.map((effect) => effect.remainingTicks)).toEqual(before.map((effect) => { const source = useCombatStore.getState().abilities.find((candidate) => candidate.id === effect.sourceAbilityId); return source?.effects.find((entry): entry is Extract => ( entry.kind === "dot" && entry.tag === effect.tag ))?.ticks; })); }); it("applies Liquidation Suffering to its selected ally and the rest of the party", () => { prepareWarlock("rom-rogue"); usePartyStore.getState().initializeParty( "rom-warlock-liquidation-suffering", 80, useCombatStore.getState().health, "damage", 3, "rom", ); const [selectedAlly, otherAlly] = usePartyStore.getState().members; expect(selectedAlly).toBeDefined(); expect(otherAlly).toBeDefined(); registerPartyRuntimePosition(selectedAlly!.id, [4, 0, 0]); registerPartyRuntimePosition(otherAlly!.id, [6, 0, 0]); useCombatStore.getState().synchronizeActors(); const liquidation = ability(498695); expect(useCombatStore.getState().castAbility(liquidation.id, { targetActorId: selectedAlly!.id, now: 1_000, }).ok).toBe(true); const threatAuras = useCombatStore.getState().auras.filter((aura) => ( aura.definition.name.startsWith("Liquidation Suffering") && aura.definition.modifiers?.some((modifier) => modifier.kind === "stat" && modifier.stat === "threat") )); const modifierFor = (targetId: string) => threatAuras.find((aura) => aura.targetId === targetId) ?.definition.modifiers?.find((modifier) => modifier.kind === "stat" && modifier.stat === "threat"); expect(modifierFor(selectedAlly!.id)).toMatchObject({ value: 0.05 }); expect(modifierFor(PLAYER_AURA_ENTITY_ID)).toMatchObject({ value: -0.05 }); expect(modifierFor(otherAlly!.id)).toMatchObject({ value: -0.05 }); expect(threatAuras.filter((aura) => aura.definition.modifiers?.some((modifier) => ( modifier.kind === "stat" && modifier.stat === "threat" && modifier.value > 0 )))).toHaveLength(1); expect(threatAuras.some((aura) => aura.targetId === selectedAlly!.id && aura.definition.modifiers?.some((modifier) => modifier.kind === "stat" && modifier.stat === "threat" && modifier.value < 0))).toBe(false); }); it("grants Defense Net's source-described control immunity to its friendly target", () => { prepareWarlock("rom-warrior"); usePartyStore.getState().initializeParty( "rom-warlock-defense-net", 80, useCombatStore.getState().health, "damage", 3, "rom", ); const member = usePartyStore.getState().members[0]!; registerPartyRuntimePosition(member.id, [4, 0, 0]); usePartyStore.setState((party) => ({ members: party.members.map((candidate) => candidate.id === member.id ? { ...candidate, controlMechanic: "stun" as const, controlledUntil: 5_000 } : candidate), })); useCombatStore.getState().synchronizeActors(); const defenseNet = ability(498549); expect(useCombatStore.getState().castAbility(defenseNet.id, { targetActorId: member.id, now: 1_000, }).ok).toBe(true); finishCast(1_000); const updatedMember = usePartyStore.getState().members.find((candidate) => candidate.id === member.id); expect(updatedMember).toMatchObject({ controlMechanic: null, controlledUntil: 0 }); const aura = useCombatStore.getState().auras.find((candidate) => candidate.targetId === member.id && candidate.definition.name.startsWith("Defense Net")); expect(aura).toMatchObject({ expiresAt: 6_000, definition: { controlImmunities: ["fear", "stun", "root", "slow"], modifiers: expect.arrayContaining([expect.objectContaining({ direction: "taken", value: -0.5 })]), }, }); }); it("reduces Defense Net to four seconds only for the Warlock/Priest Mind Shield pair", () => { prepareWarlock("rom-priest"); usePartyStore.getState().initializeParty( "rom-warlock-mind-shield", 80, useCombatStore.getState().health, "damage", 3, "rom", ); const member = usePartyStore.getState().members[0]!; registerPartyRuntimePosition(member.id, [4, 0, 0]); useCombatStore.getState().synchronizeActors(); const defenseNet = ability(498549); expect(useCombatStore.getState().castAbility(defenseNet.id, { targetActorId: member.id, now: 1_000, }).ok).toBe(true); finishCast(1_000); expect(useCombatStore.getState().auras.find((candidate) => candidate.targetId === member.id && candidate.definition.name.startsWith("Defense Net"))?.expiresAt).toBe(5_000); }); it("resolves Warlock paired named resets to only their source-named spells", () => { const expectations = new Map([ [498744, [491156]], // Static Resonance -> Electrostatic Charge [498758, [490225, 490251]], // Deep Inspiration -> Intensification and Energy Well [498662, [490256]], // Touch of Faith -> Rising Tide [498710, [498697]], // Soul Brand Sting -> Flaming Heart Strike ]); for (const [sourceSpellId, expectedTargets] of expectations) { const source = ability(sourceSpellId); const activePair = abilitiesForCharacter(source.classId, source.secondaryClassId); const targets = source.effects.flatMap((effect) => effect.kind === "cooldown-reset" ? cooldownResetCandidates(activePair, source.id, effect.abilityName).map((candidate) => candidate.dbcSpellId) : []); expect(targets.sort((left, right) => left - right), `source ${sourceSpellId}`).toEqual( [...expectedTargets].sort((left, right) => left - right), ); } for (const sourceSpellId of [498634, 498648, 498668]) { expect(ability(sourceSpellId).effects.some((effect) => effect.kind === "cooldown-reset"), `source ${sourceSpellId}`) .toBe(false); } expect(ROM_ALL_ABILITIES.filter((candidate) => candidate.classId === "rom-warlock") .flatMap((candidate) => candidate.effects.filter((effect) => effect.kind === "cooldown-reset" && effect.abilityName === undefined).map((effect) => candidate.dbcSpellId))).toEqual([]); }); });