import { beforeAll, beforeEach, describe, expect, it } from "vitest"; import { useCombatStore, type EnemyActiveCast } from "./combatStore"; import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; import { clearPartyRuntimeRegistry, registerPartyRuntimePosition, updatePartyRuntimeAttackCorridor, updatePartyRuntimeTargetVisibility, beginPartyRuntimeRejoin, finishPartyRuntimeRejoin, } from "./partyRuntimeRegistry"; import { activePartyTargetId, advanceDungeonPartyCombat, applyHealerSupport, partyAttackDamage, partyCombatRangeBand, partyCommandTargetId, partyHunterPetRecipientIds, partyUsesRangedAttacks, partyAbilityTargetIsValid, resetPartyRuntimeClock, } from "./partyRuntime"; import { createConfiguredPartyMember, usePartyStore } from "./partyStore"; import { partyAbilityAmount } from "./partyAbilityAi"; import { createDeathKnightRunes } from "./combatActors"; import { EMPTY_ITEM_STATS } from "./itemStats"; import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation"; import { subscribeCombatEvents, type StructuredCombatEvent } from "./combatEvents"; import { abilitiesForCharacter, abilitiesForClass, abilityAtLevel, abilityTalentNodeId, learnedAbilityForBuild, preloadAbilityCatalogsForClasses } from "./abilityCatalog"; import { ROM_ALL_ABILITIES } from "./romAbilityCatalog"; import { runtimePartyTalentRanksForLevel } from "./partyTalentRuntime"; import { channelPulseAt, channelPulseCount } from "./combatChannelAmounts"; import { baseClassFor } from "../app/characterCatalog"; import { WOW335_GLYPH_OF_SWIFTMEND_ID } from "./wow335GlyphCatalog"; import { ROM_ROGUE_OFF_HAND_MASTERY_TAG } from "./romRogueMechanics"; import { WOW335_HUNTER_PET_RANGED_AP_TO_SPELL_DAMAGE, wow335HunterPetBaseStatsForLevel, } from "./wow335HunterPetRuntime"; import { WOW335_DEATH_KNIGHT_DANCING_RUNE_WEAPON_ABILITY_ID, } from "./wow335DeathKnightDancingRuneWeaponRuntime"; function registerEngagedMob(id = "pack:a", maxHealth = 200): void { useCombatStore.getState().registerMob(id, { name: id, maxHealth, xpReward: 0 }); setMobPosition(id, [2, 0, 0]); useCombatStore.getState().damageMob(id, 1, 100); for (const member of usePartyStore.getState().members) { updatePartyRuntimeTargetVisibility(member.id, id, true); } } function stableUnit(seed: string): number { let hash = 2166136261; for (const character of seed) { hash ^= character.charCodeAt(0); hash = Math.imul(hash, 16777619); } return (hash >>> 0) / 0x1_0000_0000; } function partyHealCriticalAt(sourceId: string, abilityId: string, targetId: string, chance: number): number { for (let now = 10_000; now < 1_000_000; now += 1) { if (stableUnit(`${sourceId}:${abilityId}:${targetId}:${now}:heal`) < chance) return now; } throw new Error("Could not find a seeded companion healing crit."); } function createFrostbitePartyMage(seed: string) { const mage = createConfiguredPartyMember({ name: "Frostbite Mage", classId: "mage", specialization: "Frost", specializationId: "mage:frost", talentTreeId: "mage-frost", talentRanks: { "talent-38": 3 }, role: "damage", }, 80, 0, seed); usePartyStore.getState().initializeParty(seed, 80, useCombatStore.getState().health, "damage", 2); usePartyStore.setState({ members: [{ ...mage, gearStats: EMPTY_ITEM_STATS, resource: 10_000, maxResource: 10_000, resourcePools: { ...mage.resourcePools, mana: 10_000 }, }] }); registerPartyRuntimePosition(mage.id, [0, 0, 0]); useCombatStore.getState().synchronizeActors(); return mage; } function registerFrostbiteTarget(id: string): void { useCombatStore.getState().registerMob(id, { name: id, level: 1, maxHealth: 100_000, xpReward: 0, }); setMobPosition(id, [2, 0, 0]); useCombatStore.getState().damageMob(id, 1, 100); for (const member of usePartyStore.getState().members) { updatePartyRuntimeTargetVisibility(member.id, id, true); } usePartyStore.setState({ command: "attack", activeMobId: id }); } describe("party combat runtime", () => { beforeAll(async () => { await preloadAbilityCatalogsForClasses(["rom-druid"]); }); beforeEach(() => { clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); resetPartyRuntimeClock(); useCombatStore.getState().initializeCharacter({ classId: "priest", level: 5 }); usePartyStore.getState().initializeParty("runtime-party", 5, useCombatStore.getState().health); }); it("prefers the selected engaged target, then a living engaged pack member", () => { registerEngagedMob("pack:a"); registerEngagedMob("pack:b"); const mobs = useCombatStore.getState().mobs; expect(activePartyTargetId(mobs, "pack:b")).toBe("pack:b"); expect(activePartyTargetId(mobs, null)).toBe("pack:a"); }); it("extends a companion Druid's cast-time damage range with Nature's Reach", () => { const seed = "druid-natures-reach-party"; const druid = createConfiguredPartyMember({ name: "Balance Druid", classId: "druid", specialization: "Balance", specializationId: "druid:balance", talentTreeId: "druid-balance", talentRanks: { "talent-764": 3 }, role: "damage", }, 80, 0, seed); const wrath = abilitiesForCharacter("druid", null).find((ability) => ability.name === "Wrath"); if (!wrath) throw new Error("Druid Wrath is missing from the learned ability catalog."); const learnedWrath = abilityAtLevel(wrath, 80); usePartyStore.getState().initializeParty(seed, 80, useCombatStore.getState().health, "damage", 2); usePartyStore.setState({ members: [druid] }); registerPartyRuntimePosition(druid.id, [0, 0, 0]); useCombatStore.getState().registerMob("druid-natures-reach-party-target", { name: "Nature's Reach party target", maxHealth: 100_000, xpReward: 0, }); setMobPosition("druid-natures-reach-party-target", [35, 0, 0]); updatePartyRuntimeTargetVisibility(druid.id, "druid-natures-reach-party-target", true); useCombatStore.getState().synchronizeActors(); expect(partyAbilityTargetIsValid(druid, learnedWrath, "druid-natures-reach-party-target")).toBe(true); expect(partyAbilityTargetIsValid({ ...druid, talentRanks: {} }, learnedWrath, "druid-natures-reach-party-target")).toBe(false); }); it("spreads a companion Druid/Rogue's current Corrosive Poison with Necrotic Wound", () => { const seed = "druid-rogue-necrotic-wound-party"; const druid = createConfiguredPartyMember({ name: "Druid/Rogue", classId: "rom-druid", secondaryClassId: "rom-rogue", raceId: "rom-elf", specialization: "Druid/Rogue", specializationId: "rom-druid:rom-rogue", talentTreeId: "rom-druid-rom-rogue", role: "damage", }, 80, 0, seed); const findPairAbility = (spellId: number) => { const found = ROM_ALL_ABILITIES.find((candidate) => candidate.dbcSpellId === spellId && candidate.classId === "rom-druid" && candidate.secondaryClassId === "rom-rogue"); if (!found) throw new Error(`Missing Druid/Rogue spell ${spellId}.`); return found; }; const wound = learnedAbilityForBuild(findPairAbility(494321), 80, druid.talentRanks)!; const poison = findPairAbility(494024); const poisonDot = poison.effects.find((effect) => effect.kind === "dot"); const sinisterRequest = findPairAbility(494023); if (!poisonDot || poisonDot.kind !== "dot") throw new Error("Corrosive Poison has no current DoT."); if (!wound) throw new Error("Companion Druid/Rogue did not learn Necrotic Wound."); usePartyStore.getState().initializeParty(seed, 80, useCombatStore.getState().health, "damage", 2); usePartyStore.setState({ members: [{ ...druid, resourcePools: { ...druid.resourcePools, mana: 960, energy: 75, "nature-power": 0 }, cooldowns: { ...druid.cooldowns, [sinisterRequest.id]: 20_000 }, activeCast: { abilityId: wound.id, school: "shadow", mode: "channel", startedAt: 1_000, completesAt: 2_500, originalDurationMs: 1_500, pushbackHits: 0, origin: [0, 0, 0], targetId: "druid-rogue-wound-primary", channelTicksCompleted: 0, }, activeCastTargetId: "druid-rogue-wound-primary", status: "attacking", }] }); registerPartyRuntimePosition(druid.id, [0, 0, 0]); useCombatStore.getState().synchronizeActors(); const primary = "druid-rogue-wound-primary"; const nearby = "druid-rogue-wound-nearby"; const outside = "druid-rogue-wound-outside"; for (const [id, position] of [ [primary, [4, 0, 0]], [nearby, [11, 0, 0]], [outside, [13, 0, 0]], ] as const) { useCombatStore.getState().registerMob(id, { name: id, maxHealth: 1_000_000, xpReward: 0 }); setMobPosition(id, position); useCombatStore.getState().damageMob(id, 1, 100); } updatePartyRuntimeTargetVisibility(druid.id, primary, true); expect(partyAbilityTargetIsValid(druid, wound, primary)).toBe(false); expect(useCombatStore.getState().applyTimedActorEffect(druid.id, primary, poison, poisonDot, 1_000)).toBe(true); expect(partyAbilityTargetIsValid(druid, wound, primary)).toBe(true); usePartyStore.setState((state) => ({ members: state.members.map((member) => member.id === druid.id ? { ...member, cooldowns: { ...member.cooldowns, [sinisterRequest.id]: 20_000 } } : member) })); advanceDungeonPartyCombat(2_500); const poisonTargets = useCombatStore.getState().effects.filter((effect) => ( effect.kind === "dot" && effect.tag === "rom-druid-corrosive-poison" && effect.remainingTicks === 12 )).map((effect) => effect.targetId).sort(); expect(poisonTargets).toEqual([nearby, primary].sort()); expect(useCombatStore.getState().mobs[primary]!.health).toBeLessThan(999_999); expect(useCombatStore.getState().mobs[nearby]!.health).toBeLessThan(999_999); expect(useCombatStore.getState().mobs[outside]!.health).toBe(999_999); expect(usePartyStore.getState().members[0]!.cooldowns[sinisterRequest.id]).toBe(19_000); expect(useCombatStore.getState().mobs[primary]!.statuses.some((status) => status.kind === "root")).toBe(false); }); it("extends a companion Death Knight's diseases with Epidemic", () => { const seed = "death-knight-epidemic-party"; const deathKnight = createConfiguredPartyMember({ name: "Epidemic DK", classId: "death-knight", specialization: "Frost", specializationId: "death-knight:frost", talentTreeId: "death-knight-frost", talentRanks: { "talent-1963": 2 }, role: "damage", }, 80, 0, seed); const abilities = abilitiesForCharacter("death-knight", null); const icyTouch = abilities.find((candidate) => candidate.name === "Icy Touch"); if (!icyTouch) throw new Error("Missing Death Knight Icy Touch."); const learnedIcyTouch = learnedAbilityForBuild(icyTouch, 80, deathKnight.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: [{ ...deathKnight, gearStats: { ...EMPTY_ITEM_STATS, hitRating: 10_000, expertiseRating: 10_000 }, resource: 100, maxResource: 100, resourcePools: { ...deathKnight.resourcePools, "runic-power": 100 }, runes: createDeathKnightRunes(1_000), globalCooldownEndsAt: 0, nextActionAt: 0, status: "ready", }], command: "attack", activeMobId: "party-dk-epidemic-target", }); registerPartyRuntimePosition(deathKnight.id, [0, 0, 0]); useCombatStore.getState().synchronizeActors(); registerEngagedMob("party-dk-epidemic-target", 1_000_000); for (const now of [1_000, 2_500, 4_000, 5_500]) advanceDungeonPartyCombat(now); expect(useCombatStore.getState().effects.find((effect) => ( effect.kind === "dot" && effect.sourceActorId === deathKnight.id && effect.tag === "frost-fever" && effect.targetId === "party-dk-epidemic-target" ))).toMatchObject({ remainingTicks: 7, intervalMs: 3_000 }); }); it("activates Dancing Rune Weapon for a companion and mirrors its direct spell and melee hits", () => { useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 }); const seed = "death-knight-dancing-rune-weapon-party"; const deathKnight = createConfiguredPartyMember({ name: "Rune Weapon DK", classId: "death-knight", specialization: "Blood", specializationId: "death-knight:blood", talentTreeId: "death-knight-blood", talentRanks: { "talent-1961": 1 }, role: "damage", }, 80, 0, seed); const abilities = abilitiesForCharacter("death-knight", null); const dancingRuneWeapon = abilities.find((ability) => ability.id === WOW335_DEATH_KNIGHT_DANCING_RUNE_WEAPON_ABILITY_ID); const learnedDancingRuneWeapon = dancingRuneWeapon ? learnedAbilityForBuild(dancingRuneWeapon, 80, deathKnight.talentRanks) : null; const deathCoil = abilities.find((ability) => ability.name === "Death Coil"); if (!learnedDancingRuneWeapon || !deathCoil) { throw new Error("Companion Death Knight is missing Dancing Rune Weapon or Death Coil."); } const learnedDeathCoil = learnedAbilityForBuild(deathCoil, 80, deathKnight.talentRanks); if (!learnedDeathCoil) throw new Error("Companion Death Knight did not learn Death Coil."); const targetId = "party-dk-dancing-rune-weapon-target"; usePartyStore.getState().initializeParty(seed, 80, useCombatStore.getState().health, "damage", 2); usePartyStore.setState({ members: [{ ...deathKnight, talentRanks: { ...deathKnight.talentRanks, "talent-1961": 1 }, gearStats: EMPTY_ITEM_STATS, resource: 100, maxResource: 100, resourcePools: { ...deathKnight.resourcePools, "runic-power": 100 }, runes: createDeathKnightRunes(1_000), activeCast: { abilityId: learnedDancingRuneWeapon.id, school: "shadow", mode: "cast", startedAt: 1_000, completesAt: 1_000, originalDurationMs: 0, pushbackHits: 0, origin: [0, 0, 0], targetId: deathKnight.id, }, activeCastTargetId: deathKnight.id, status: "attacking", }] }); registerPartyRuntimePosition(deathKnight.id, [0, 0, 0]); useCombatStore.getState().synchronizeActors(); registerEngagedMob(targetId, 1_000_000); advanceDungeonPartyCombat(1_000); expect(useCombatStore.getState().auras).toContainEqual(expect.objectContaining({ sourceId: deathKnight.id, targetId: deathKnight.id, definition: expect.objectContaining({ name: "Dancing Rune Weapon", durationMs: 12_000 }), appliedAt: 1_000, expiresAt: 13_000, })); expect(useCombatStore.getState().summons.some((summon) => ( summon.ownerActorId === deathKnight.id && summon.sourceAbilityId === WOW335_DEATH_KNIGHT_DANCING_RUNE_WEAPON_ABILITY_ID ))).toBe(false); usePartyStore.setState((state) => ({ members: state.members.map((member) => member.id === deathKnight.id ? { ...member, activeCast: { abilityId: learnedDeathCoil.id, school: "shadow", mode: "cast", startedAt: 3_000, completesAt: 3_000, originalDurationMs: 0, pushbackHits: 0, origin: [0, 0, 0], targetId, }, activeCastTargetId: targetId, status: "attacking" as const, } : member), })); useCombatStore.getState().commitCompanionActors(usePartyStore.getState().members); advanceDungeonPartyCombat(3_000); const spellHits = useCombatStore.getState().combatEvents.filter((event) => ( event.kind === "damage" && event.sourceActorId === deathKnight.id && event.targetActorId === targetId && event.abilityId === learnedDeathCoil.id && event.occurredAt === 3_000 )); expect(spellHits).toHaveLength(2); expect(spellHits[1].rawAmount).toBe(Math.floor(spellHits[0].rawAmount * 0.5)); usePartyStore.setState((state) => ({ members: state.members.map((member) => member.id === deathKnight.id ? { ...member, activeCast: null, activeCastTargetId: null, nextActionAt: 100_000, status: "attacking" as const, } : member), })); useCombatStore.getState().commitCompanionActors(usePartyStore.getState().members); advanceDungeonPartyCombat(5_000); const meleeHits = useCombatStore.getState().combatEvents.filter((event) => ( event.kind === "damage" && event.sourceActorId === deathKnight.id && event.targetActorId === targetId && event.abilityId === "party-auto-attack" && event.occurredAt === 5_000 )); expect(meleeHits).toHaveLength(2); expect(meleeHits[1].rawAmount).toBe(Math.floor(meleeHits[0].rawAmount * 0.5)); }); it("gives a RoM Rogue a separate off-hand swing and activates learned Off Hand Mastery", () => { useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 }); const rogue = createConfiguredPartyMember({ name: "RoM Rogue", classId: "rom-rogue", secondaryClassId: "rom-warrior", raceId: "rom-human", specialization: "Rogue/Warrior", role: "damage", }, 80, 0, "rom-rogue-offhand-runtime"); const targetId = "rom-rogue-offhand-target"; usePartyStore.getState().initializeParty("rom-rogue-offhand-runtime", 80, useCombatStore.getState().health, "damage", 2); usePartyStore.setState({ members: [{ ...rogue, gearStats: EMPTY_ITEM_STATS, nextActionAt: 100_000 }], command: "attack", activeMobId: targetId, }); registerPartyRuntimePosition(rogue.id, [0, 0, 0]); useCombatStore.getState().synchronizeActors(); registerEngagedMob(targetId, 1_000_000); const presentations: CombatPresentationEvent[] = []; const unsubscribe = subscribeCombatPresentation((message) => { if (message.kind === "event") presentations.push(message.event); }); advanceDungeonPartyCombat(1_000); unsubscribe(); expect(presentations).toContainEqual(expect.objectContaining({ sourceActorId: rogue.id, abilityId: "party-auto-attack-offhand", name: "Off-hand Auto Attack", phase: "impact", })); expect(useCombatStore.getState().auras.some((aura) => ( aura.targetId === rogue.id && aura.definition.utilityTags?.includes(ROM_ROGUE_OFF_HAND_MASTERY_TAG) ))).toBe(true); }); it("adds hand-specific CoA Chaos Dagger Fire packets to generated Rogue/Mage autos", () => { useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 }); const rogue = createConfiguredPartyMember({ name: "RoM Rogue/Mage", classId: "rom-rogue", secondaryClassId: "rom-mage", raceId: "rom-human", specialization: "Rogue/Mage", role: "damage", }, 80, 0, "rom-rogue-mage-chaos-dagger-party"); const mainHandWeapon = { damageMin: 100, damageMax: 100, damageSchool: "physical" as const, speedMs: 2_000 }; const offHandWeapon = { damageMin: 40, damageMax: 40, damageSchool: "physical" as const, speedMs: 2_000 }; const offHandId = "rom-chaos-dagger-party-offhand"; const offHandItem = { instanceId: offHandId, itemId: 1, itemKey: offHandId, name: "Chaos Dagger Test Off-hand", quality: "uncommon" as const, inventorySlot: "off-hand", armorType: null, weaponType: "dagger", itemLevel: 1, sourceItemLevel: 1, stats: { ...EMPTY_ITEM_STATS, meleeWeapon: offHandWeapon }, quantity: 1, sourceBossKey: "test", sourceBossName: "Test", acquiredAt: 1, }; usePartyStore.getState().initializeParty("rom-rogue-mage-chaos-dagger-party", 80, useCombatStore.getState().health, "damage", 2, "rom"); usePartyStore.setState({ members: [{ ...rogue, gearStats: { ...EMPTY_ITEM_STATS, hitRating: 1_000_000, meleeWeapon: mainHandWeapon }, nextActionAt: 100_000, }] }); useCombatStore.setState((state) => ({ inventory: [...state.inventory, offHandItem], equipment: { ...state.equipment, [rogue.id]: { "off-hand": offHandId } }, })); registerPartyRuntimePosition(rogue.id, [0, 0, 0]); useCombatStore.getState().synchronizeActors(); const targetId = "rom-rogue-mage-chaos-dagger-target"; useCombatStore.getState().registerMob(targetId, { name: "Chaos Dagger party target", maxHealth: 1_000_000, level: 80, schoolImmunities: ["fire"], }); setMobPosition(targetId, [2, 0, 0]); useCombatStore.getState().damageMob(targetId, 1, 100); updatePartyRuntimeTargetVisibility(rogue.id, targetId, true); usePartyStore.setState({ command: "attack", activeMobId: targetId }); advanceDungeonPartyCombat(1_000); const damage = useCombatStore.getState().combatEvents.filter((event) => ( event.kind === "damage" && event.targetActorId === targetId )); const firePackets = damage.filter((event) => event.school === "fire"); expect(firePackets.map((event) => event.rawAmount).sort((a, b) => a - b)).toEqual([34, 85]); expect(firePackets.every((event) => event.effectiveAmount === 0)).toBe(true); expect(damage.some((event) => event.school === "physical" && event.effectiveAmount > 0)).toBe(true); }); it("routes companion pet-targeted auras to that Hunter's live summoned pet", () => { const callPet = abilitiesForClass("hunter").find((ability) => ability.name === "Call Pet")!; const summons = [ { id: "hunter-one-pet", ownerActorId: "hunter-one", health: 1, expiresAt: 10_000, kind: "summon" as const, sourceAbilityId: callPet.id }, { id: "hunter-two-pet", ownerActorId: "hunter-two", health: 1, expiresAt: 10_000, kind: "summon" as const, sourceAbilityId: callPet.id }, { id: "hunter-one-dead-pet", ownerActorId: "hunter-one", health: 0, expiresAt: 10_000, kind: "summon" as const, sourceAbilityId: callPet.id }, { id: "hunter-one-expired-pet", ownerActorId: "hunter-one", health: 1, expiresAt: 1_000, kind: "summon" as const, sourceAbilityId: callPet.id }, { id: "hunter-one-totem", ownerActorId: "hunter-one", health: 1, expiresAt: 10_000, kind: "totem" as const, sourceAbilityId: callPet.id }, ]; expect(partyHunterPetRecipientIds(summons, "hunter-one", 2_000)).toEqual(["hunter-one-pet"]); }); it("maintains HoTs on separate injured allies without resetting pending ticks", () => { useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 8 }); usePartyStore.getState().initializeParty("party-hot-coverage", 8, useCombatStore.getState().health, "damage"); const healer = usePartyStore.getState().members.find(member => member.role === "healer")!; const abilities = abilitiesForClass("druid"); const hot = abilities.find(ability => ability.name === "Rejuvenation")!; usePartyStore.setState(state => ({ members: state.members.map(member => member.id === healer.id ? { ...member, classId: "druid", resourceType: "mana", resource: 10_000, maxResource: 10_000, resourcePools: { mana: 10_000 }, talentRanks: { "talent-830": 3 }, cooldowns: Object.fromEntries(abilities.filter(a => a.id !== hot.id).map(a => [a.id, 60_000])) } : { ...member, health: member.maxHealth * .5 }) })); for (const member of usePartyStore.getState().members) registerPartyRuntimePosition(member.id, [0, 0, 0]); useCombatStore.getState().synchronizeActors(); registerEngagedMob("hot-boss", 100_000); advanceDungeonPartyCombat(1_000); const first = useCombatStore.getState().effects.find(effect => effect.kind === "hot" && effect.sourceActorId === healer.id)!; expect(first).toBeDefined(); const rejuvenationHot = hot.effects.find((effect) => effect.kind === "hot")!; const baselineAmount = partyAbilityAmount({ ...healer, classId: "druid", talentRanks: {} }, hot, rejuvenationHot); expect(first.fixedAmount).toBe(Math.round(baselineAmount * 1.2)); advanceDungeonPartyCombat(3_000); const effects = useCombatStore.getState().effects.filter(effect => effect.kind === "hot" && effect.sourceActorId === healer.id); expect(new Set(effects.map(effect => effect.targetId)).size).toBe(2); expect(effects.find(effect => effect.targetId === first.targetId)?.nextTickAt).toBe(first.nextTickAt); }); it.each([false, true])("companion Swiftmend preserves the HoT only with the configured glyph (%s)", (glyphActive) => { useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 }); const druidAbilities = abilitiesForClass("druid"); const swiftmendBase = druidAbilities.find((ability) => ability.name === "Swiftmend")!; const swiftmendNodeId = abilityTalentNodeId(swiftmendBase)!; const swiftmend = abilityAtLevel(swiftmendBase, 80); const rejuvenation = abilityAtLevel(druidAbilities.find((ability) => ability.name === "Rejuvenation")!, 80); const rejuvenationHot = rejuvenation.effects.find((effect) => effect.kind === "hot")!; const druid = createConfiguredPartyMember({ name: "Configured Druid", classId: "druid", specialization: "Restoration", specializationId: "druid:restoration", talentTreeId: "druid-restoration", talentRanks: { [swiftmendNodeId]: 1 }, glyphIds: glyphActive ? [WOW335_GLYPH_OF_SWIFTMEND_ID] : [], role: "healer", }, 80, 0, "party-swiftmend-glyph"); const target = createConfiguredPartyMember({ name: "Injured Tank", classId: "warrior", specialization: "Protection", specializationId: "warrior:protection", talentTreeId: "warrior-protection", role: "tank", }, 80, 1, "party-swiftmend-glyph"); const startedAt = 1_000; usePartyStore.getState().initializeParty("party-swiftmend-glyph", 80, useCombatStore.getState().health, "damage"); usePartyStore.setState({ members: [ { ...target, health: 100 }, { ...druid, activeCast: { abilityId: swiftmend.id, school: "nature", mode: "cast", startedAt, completesAt: startedAt, originalDurationMs: 0, pushbackHits: 0, origin: [0, 0, 0], targetId: target.id, }, activeCastTargetId: target.id, status: "healing", }, ] }); registerPartyRuntimePosition(druid.id, [0, 0, 0]); registerPartyRuntimePosition(target.id, [0, 0, 0]); useCombatStore.getState().synchronizeActors(); expect(useCombatStore.getState().applyTimedActorEffect( druid.id, target.id, rejuvenation, rejuvenationHot, startedAt, )).toBe(true); const originalHot = useCombatStore.getState().effects.find((effect) => ( effect.kind === "hot" && effect.sourceActorId === druid.id && effect.targetId === target.id ))!; advanceDungeonPartyCombat(startedAt); expect(useCombatStore.getState().actors[target.id]!.health).toBeGreaterThan(100); expect(useCombatStore.getState().effects.some((effect) => effect.id === originalHot.id)).toBe(glyphActive); expect(usePartyStore.getState().members.find((member) => member.id === druid.id)?.glyphIds) .toEqual(glyphActive ? [WOW335_GLYPH_OF_SWIFTMEND_ID] : []); }); it("refreshes a companion Priest's Shadow Word: Pain from its own completed Mind Blast", () => { useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 }); const priest = createConfiguredPartyMember({ name: "Shadow Priest", classId: "priest", specialization: "Shadow", specializationId: "priest:shadow", talentTreeId: "priest-shadow", role: "damage", }, 80, 0, "pain-and-suffering-companion"); const priestAbilities = abilitiesForClass("priest"); const shadowWordPainBase = priestAbilities.find((ability) => ability.name === "Shadow Word: Pain")!; const shadowWordPain = abilityAtLevel(shadowWordPainBase, 80); const shadowWordPainDot = shadowWordPain.effects.find((effect) => effect.kind === "dot")!; const mindBlast = abilityAtLevel(priestAbilities.find((ability) => ability.name === "Mind Blast")!, 80); const targetId = "pain-and-suffering-companion-target"; usePartyStore.getState().initializeParty("pain-and-suffering-companion", 80, useCombatStore.getState().health, "damage"); usePartyStore.setState({ members: [{ ...priest, talentRanks: { "talent-1909": 3 }, gearStats: EMPTY_ITEM_STATS, activeCast: { abilityId: mindBlast.id, school: "shadow", mode: "cast", startedAt: 10_000, completesAt: 10_000 + mindBlast.castTimeMs, originalDurationMs: mindBlast.castTimeMs, pushbackHits: 0, origin: [0, 0, 0], targetId, }, activeCastTargetId: targetId, status: "attacking", }] }); registerPartyRuntimePosition(priest.id, [0, 0, 0]); useCombatStore.getState().synchronizeActors(); registerEngagedMob(targetId, 100_000); expect(useCombatStore.getState().applyTimedActorEffect( priest.id, targetId, shadowWordPain, shadowWordPainDot, 1_000, )).toBe(true); const before = useCombatStore.getState().effects.find((effect) => ( effect.kind === "dot" && effect.sourceActorId === priest.id && effect.targetId === targetId ))!; advanceDungeonPartyCombat(mindBlast.castTimeMs + 10_000); const after = useCombatStore.getState().effects.find((effect) => ( effect.kind === "dot" && effect.sourceActorId === priest.id && effect.targetId === targetId ))!; expect(after.remainingTicks).toBe(shadowWordPainDot.ticks); expect(after.nextTickAt).toBe(10_000 + mindBlast.castTimeMs + after.intervalMs); expect(after.fixedAmount).toBe(before.fixedAmount); expect(after.intervalMs).toBe(before.intervalMs); }); it("lets a party Mage Frostbite freeze after landed direct Frostbolt damage", () => { useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 }); const seed = "party-mage-frostbite-direct"; const mage = createFrostbitePartyMage(seed); const frostbolt = abilityAtLevel(abilitiesForClass("mage").find((ability) => ability.name === "Frostbolt")!, 80); const targetId = "party-mage-frostbite-direct-target"; const impactAt = Array.from({ length: 100_000 }, (_, index) => 10_000 + index) .find((now) => stableUnit(`${mage.id}:${frostbolt.id}:${targetId}:${now}:wow335-mage-frostbite`) < 0.15); if (impactAt === undefined) throw new Error("Could not find a seeded party Frostbite proc time."); usePartyStore.setState({ members: [{ ...usePartyStore.getState().members[0]!, activeCast: { abilityId: frostbolt.id, school: "frost", mode: "cast", startedAt: impactAt - frostbolt.castTimeMs, completesAt: impactAt, originalDurationMs: frostbolt.castTimeMs, pushbackHits: 0, origin: [0, 0, 0], targetId, }, activeCastTargetId: targetId, status: "attacking", }] }); useCombatStore.getState().synchronizeActors(); registerFrostbiteTarget(targetId); const beforeHealth = useCombatStore.getState().mobs[targetId]!.health; advanceDungeonPartyCombat(impactAt - frostbolt.castTimeMs); advanceDungeonPartyCombat(impactAt); expect(useCombatStore.getState().mobs[targetId]!.health).toBeLessThan(beforeHealth); expect(useCombatStore.getState().mobs[targetId]!.statuses).toContainEqual(expect.objectContaining({ kind: "freeze", sourceAbilityId: frostbolt.id, sourceActorId: mage.id, endsAt: impactAt + 4_000, })); }); it.each([ { rank: 0, shouldSlow: false }, { rank: 2, shouldSlow: true }, ])("applies generated Warlock Aftermath to landed companion Conflagrate at rank $rank", ({ rank, shouldSlow }) => { useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 }); const seed = `party-warlock-aftermath-rank-${rank}`; const warlock = createConfiguredPartyMember({ name: "Destruction Warlock", classId: "warlock", specialization: "Destruction", specializationId: "warlock:destruction", talentTreeId: "warlock-destruction", talentRanks: { "talent-968": 1, "talent-982": rank }, role: "damage", }, 80, 0, seed); const conflagrate = abilityAtLevel( abilitiesForClass("warlock").find((ability) => ability.name === "Conflagrate")!, 80, ); expect(learnedAbilityForBuild( abilitiesForClass("warlock").find((ability) => ability.name === "Conflagrate")!, 80, warlock.talentRanks, )).toMatchObject({ id: conflagrate.id }); const targetId = `party-warlock-aftermath-target-${rank}`; const completionAt = 10_000; usePartyStore.getState().initializeParty(seed, 80, useCombatStore.getState().health, "damage", 2); usePartyStore.setState({ members: [{ ...warlock, gearStats: EMPTY_ITEM_STATS, resource: 10_000, maxResource: 10_000, resourcePools: { ...warlock.resourcePools, mana: 10_000 }, activeCast: { abilityId: conflagrate.id, school: "fire", mode: "cast", startedAt: completionAt - conflagrate.castTimeMs, completesAt: completionAt, originalDurationMs: conflagrate.castTimeMs, pushbackHits: 0, origin: [0, 0, 0], targetId, }, activeCastTargetId: targetId, status: "attacking", }] }); registerPartyRuntimePosition(warlock.id, [0, 0, 0]); useCombatStore.getState().synchronizeActors(); expect(useCombatStore.getState().actors[warlock.id]?.cast).toMatchObject({ abilityId: conflagrate.id, targetId, }); registerFrostbiteTarget(targetId); const immolate = abilityAtLevel( abilitiesForClass("warlock").find((ability) => ability.name === "Immolate")!, 80, ); const immolateDot = immolate.effects.find((effect) => effect.kind === "dot"); if (!immolateDot || immolateDot.kind !== "dot") throw new Error("Missing generated Immolate DoT."); expect(useCombatStore.getState().applyTimedActorEffect( warlock.id, targetId, immolate, immolateDot, 1_000, )).toBe(true); expect(partyAbilityTargetIsValid(warlock, conflagrate, targetId, [0, 0, 0], completionAt)).toBe(true); const immolateEffectId = useCombatStore.getState().effects.find((effect) => ( effect.kind === "dot" && effect.sourceActorId === warlock.id && effect.targetId === targetId ))!.id; expect(useCombatStore.getState().actors[warlock.id]?.cast).toMatchObject({ abilityId: conflagrate.id, targetId, }); const beforeHealth = useCombatStore.getState().mobs[targetId]!.health; const aftermathEvents: StructuredCombatEvent[] = []; const stopAftermathEvents = subscribeCombatEvents((event) => aftermathEvents.push(event)); const aftermathPresentation: CombatPresentationEvent[] = []; const stopAftermathPresentation = subscribeCombatPresentation((message) => { if (message.kind === "event") aftermathPresentation.push(message.event); }); advanceDungeonPartyCombat(completionAt); stopAftermathEvents(); stopAftermathPresentation(); const mob = useCombatStore.getState().mobs[targetId]!; expect(aftermathPresentation).toContainEqual(expect.objectContaining({ phase: "impact", sourceActorId: warlock.id, abilityId: conflagrate.id, })); const conflagrateDamage = aftermathEvents.find((event) => ( event.kind === "damage" && event.sourceActorId === warlock.id && event.targetActorId === targetId && event.abilityId === conflagrate.id )); expect(conflagrateDamage).toMatchObject({ rawAmount: 628, effectiveAmount: expect.any(Number), }); expect(mob.health).toBeLessThan(beforeHealth); expect(useCombatStore.getState().effects.some((effect) => effect.id === immolateEffectId)).toBe(false); const aftermath = mob.statuses.find((status) => ( status.kind === "slow" && status.sourceAbilityId === conflagrate.id && status.sourceActorId === warlock.id )); if (shouldSlow) { expect(aftermath).toMatchObject({ kind: "slow", sourceAbilityId: conflagrate.id, sourceActorId: warlock.id, endsAt: completionAt + 4_000, magnitude: 0.5, }); } else { expect(aftermath).toBeUndefined(); } }); it("lets a party Mage Frostbite proc on a landed Blizzard channel pulse", () => { useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 }); const seed = "party-mage-frostbite-blizzard"; const mage = createFrostbitePartyMage(seed); const blizzard = abilityAtLevel(abilitiesForClass("mage").find((ability) => ability.name === "Blizzard")!, 80); const targetId = "party-mage-frostbite-blizzard-target"; const durationMs = blizzard.castTimeMs; expect(blizzard.castMode).toBe("channel"); expect(durationMs).toBeGreaterThan(0); let startedAt = 0; let procAt = 0; for (let start = 10_000; start < 20_000 && procAt === 0; start += 1) { const pulses = Array.from({ length: channelPulseCount(blizzard) }, (_, index) => ( channelPulseAt(blizzard, start, durationMs, index) )); const proc = pulses.find((pulseAt) => ( stableUnit(`${mage.id}:${blizzard.id}:${targetId}:${pulseAt}:wow335-mage-frostbite`) < 0.15 )); if (proc !== undefined) { startedAt = start; procAt = proc; } } if (procAt === 0) throw new Error("Could not find a seeded party Blizzard Frostbite pulse."); usePartyStore.setState({ members: [{ ...usePartyStore.getState().members[0]!, activeCast: { abilityId: blizzard.id, school: "frost", mode: "channel", startedAt, completesAt: startedAt + durationMs, originalDurationMs: durationMs, pushbackHits: 0, origin: [0, 0, 0], targetId, channelTicksCompleted: 0, }, activeCastTargetId: targetId, status: "attacking", }] }); useCombatStore.getState().synchronizeActors(); registerFrostbiteTarget(targetId); const beforeHealth = useCombatStore.getState().mobs[targetId]!.health; advanceDungeonPartyCombat(startedAt); advanceDungeonPartyCombat(procAt); expect(useCombatStore.getState().mobs[targetId]!.health).toBeLessThan(beforeHealth); expect(useCombatStore.getState().mobs[targetId]!.statuses).toContainEqual(expect.objectContaining({ kind: "freeze", sourceAbilityId: blizzard.id, sourceActorId: mage.id, endsAt: procAt + 4_000, })); }); it("lets a party Mage proc its learned Eruption passive on Fireball", () => { useCombatStore.getState().initializeCharacter({ classId: "rom-mage", level: 80 }); const seed = "party-rom-mage-eruption"; const mage = createConfiguredPartyMember({ name: "Eruption Mage", classId: "rom-mage", secondaryClassId: "rom-druid", specialization: "Primary", role: "damage", }, 80, 0, seed); const fireball = abilitiesForClass("rom-mage").find((ability) => ability.name === "Fireball")!; const targetId = "party-rom-mage-eruption-target"; const impactAt = Array.from({ length: 100_000 }, (_, index) => 10_000 + index) .find((now) => stableUnit(`${mage.id}:${fireball.id}:${targetId}:${now}:rom-eruption`) < 0.1); if (impactAt === undefined) throw new Error("Could not find a seeded party Eruption proc time."); usePartyStore.getState().initializeParty(seed, 80, useCombatStore.getState().health, "damage", 2, "rom"); usePartyStore.getState().setCommand("attack"); usePartyStore.setState({ members: [{ ...mage, resource: 10_000, maxResource: 10_000, resourcePools: { ...mage.resourcePools, mana: 10_000 }, activeCast: { abilityId: fireball.id, school: "fire", mode: "cast", startedAt: impactAt, completesAt: impactAt, originalDurationMs: 0, pushbackHits: 0, origin: [0, 0, 0], targetId, }, activeCastTargetId: targetId, status: "attacking", }] }); registerPartyRuntimePosition(mage.id, [0, 0, 0]); registerFrostbiteTarget(targetId); updatePartyRuntimeTargetVisibility(mage.id, targetId, true); useCombatStore.getState().synchronizeActors(); advanceDungeonPartyCombat(impactAt); expect(useCombatStore.getState().mobs[targetId]!.health).toBeLessThan(99_999); expect(useCombatStore.getState().auras.some((aura) => aura.targetId === mage.id && aura.definition.id === "rom-mage-druid-control-flame-buff")).toBe(true); }); it("procs a party Paladin's source-owned Vindication from its completed Holy spell", () => { useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 }); const seed = "vindication-paladin-party"; const paladin = createConfiguredPartyMember({ name: "Vindication Paladin", classId: "paladin", specialization: "Retribution", specializationId: "paladin:retribution", talentTreeId: "paladin-retribution", talentRanks: { "talent-1633": 3 }, role: "damage", }, 80, 0, seed); const exorcism = abilityAtLevel(abilitiesForClass("paladin").find((ability) => ability.name === "Exorcism")!, 80); const targetId = "vindication-paladin-party-target"; const completionAt = Array.from({ length: 100_000 }, (_, index) => 10_000 + index) .find((now) => stableUnit(`${paladin.id}:${exorcism.id}:${targetId}:${now}:wow335-paladin-vindication`) < 0.65); if (completionAt === undefined) throw new Error("Could not find a seeded party Vindication proc time."); usePartyStore.getState().initializeParty(seed, 80, useCombatStore.getState().health, "damage", 2); usePartyStore.setState({ members: [{ ...paladin, gearStats: EMPTY_ITEM_STATS, activeCast: { abilityId: exorcism.id, school: "holy", mode: "cast", startedAt: completionAt - exorcism.castTimeMs, completesAt: completionAt, originalDurationMs: exorcism.castTimeMs, pushbackHits: 0, origin: [0, 0, 0], targetId, }, activeCastTargetId: targetId, status: "attacking", }] }); registerPartyRuntimePosition(paladin.id, [0, 0, 0]); useCombatStore.getState().synchronizeActors(); registerEngagedMob(targetId, 100_000); advanceDungeonPartyCombat(completionAt); expect(useCombatStore.getState().mobs[targetId].auras).toEqual(expect.arrayContaining([ expect.objectContaining({ id: "wow335:paladin:vindication", sourceActorId: paladin.id, sourceAbilityId: exorcism.id, endsAt: completionAt + 10_000, magnitude: 357, }), ])); }); it("grants a party Rogue Improved Kick only after it interrupts an active cast", () => { useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 }); const seed = "improved-kick-party-runtime"; const rogue = createConfiguredPartyMember({ name: "Combat Rogue", classId: "rogue", specialization: "Combat", specializationId: "rogue:combat", talentTreeId: "rogue-combat", talentRanks: { "talent-206": 2 }, role: "damage", }, 80, 0, seed); const kick = abilitiesForClass("rogue").find((ability) => ability.name === "Kick"); if (!kick) throw new Error("Missing generated Rogue Kick"); usePartyStore.getState().initializeParty(seed, 80, useCombatStore.getState().health, "damage", 2); usePartyStore.setState({ members: [{ ...rogue, resource: 0, resourcePools: { ...rogue.resourcePools, energy: 0 }, activeCast: { abilityId: kick.id, school: "physical", mode: "cast", startedAt: 900, completesAt: 900, originalDurationMs: 0, pushbackHits: 0, origin: [0, 0, 0], targetId: "improved-kick-party-target", }, activeCastTargetId: "improved-kick-party-target", status: "attacking", }] }); registerPartyRuntimePosition(rogue.id, [0, 0, 0]); useCombatStore.getState().synchronizeActors(); const targetId = "improved-kick-party-target"; useCombatStore.getState().registerMob(targetId, { name: targetId, maxHealth: 100_000, xpReward: 0 }); setMobPosition(targetId, [2, 0, 0]); useCombatStore.getState().damageMob(targetId, 1, 100); updatePartyRuntimeTargetVisibility(rogue.id, targetId, true); const hostileCast: EnemyActiveCast = { attackId: "party-test-shadow-bolt", name: "Shadow Bolt", spellId: 686, school: "shadow", interruptible: true, targetActorId: null, sourcePosition: [2, 0, 0], targetPosition: [0, 0, 0], sourceYaw: 0, startedAt: 500, completesAt: 2_500, }; useCombatStore.setState((state) => ({ mobs: { ...state.mobs, [targetId]: { ...state.mobs[targetId]!, activeCast: hostileCast } }, })); advanceDungeonPartyCombat(1_000); expect(useCombatStore.getState().mobs[targetId]?.activeCast).toBeNull(); expect(useCombatStore.getState().auras).toContainEqual(expect.objectContaining({ sourceId: rogue.id, targetId: rogue.id, appliedAt: 1_000, expiresAt: 7_000, definition: expect.objectContaining({ utilityTags: ["wow335-rogue-improved-kick"], modifiers: [ { kind: "stat", stat: "melee-haste", operation: "flat", value: 35 }, { kind: "stat", stat: "ranged-haste", operation: "flat", value: 35 }, ], }), })); expect(usePartyStore.getState().members[0]?.resourcePools.energy).toBe(40); useCombatStore.getState().selectMob(targetId); usePartyStore.setState((state) => ({ members: state.members.map((member) => ({ ...member, nextActionAt: 10_000, gearStats: { ...member.gearStats, hasteRating: 0 }, })), })); useCombatStore.getState().synchronizeActors(); const autoAttackTimes: number[] = []; const unsubscribe = subscribeCombatPresentation((message) => { if (message.kind === "event" && message.event.sourceActorId === rogue.id && message.event.abilityId === "party-auto-attack" && message.event.phase === "impact") { autoAttackTimes.push(message.event.occurredAt); } }); advanceDungeonPartyCombat(2_200); advanceDungeonPartyCombat(3_400); unsubscribe(); expect(autoAttackTimes).toEqual([2_200, 3_400]); }); it("precasts Earth Shield on the tank while the Restoration Shaman is at full health", () => { useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 }); const tank = createConfiguredPartyMember({ name: "Tank", classId: "warrior", specialization: "Protection", specializationId: "warrior:protection", talentTreeId: "warrior-protection", role: "tank", }, 80, 0, "earth-shield-runtime"); const healer = createConfiguredPartyMember({ name: "Healer", classId: "shaman", specialization: "Restoration", specializationId: "shaman:restoration", talentTreeId: "shaman-restoration", role: "healer", }, 80, 1, "earth-shield-runtime"); usePartyStore.getState().initializeParty("earth-shield-runtime", 80, useCombatStore.getState().health, "damage"); usePartyStore.setState({ members: [tank, healer] }); registerPartyRuntimePosition(tank.id, [0, 0, 0]); registerPartyRuntimePosition(healer.id, [-12, 0, 0]); useCombatStore.getState().synchronizeActors(); useCombatStore.getState().registerMob("earth-shield-boss", { name: "Earth Shield boss", boss: true, maxHealth: 100_000, xpReward: 0, }); setMobPosition("earth-shield-boss", [2, 0, 0]); useCombatStore.getState().damageMob("earth-shield-boss", 1, 100); updatePartyRuntimeTargetVisibility(tank.id, "earth-shield-boss", true); updatePartyRuntimeTargetVisibility(healer.id, "earth-shield-boss", true); for (const now of [1_000, 2_500, 4_000]) advanceDungeonPartyCombat(now); expect(useCombatStore.getState().auras.some((aura) => ( aura.sourceId === healer.id && aura.targetId === tank.id && aura.definition.exclusiveGroup === "shaman:earth-shield" ))).toBe(true); }); it("turns Feral Spirit into two live Enhancement summons against a boss", () => { useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 }); const shaman = createConfiguredPartyMember({ name: "Enhancement", classId: "shaman", specialization: "Enhancement", specializationId: "shaman:enhancement", talentTreeId: "shaman-enhancement", role: "damage", }, 80, 0, "feral-spirit-runtime"); usePartyStore.getState().initializeParty("feral-spirit-runtime", 80, useCombatStore.getState().health, "damage"); usePartyStore.setState({ members: [shaman] }); registerPartyRuntimePosition(shaman.id, [0, 0, 0]); useCombatStore.getState().synchronizeActors(); useCombatStore.getState().registerMob("feral-spirit-boss", { name: "Feral Spirit boss", boss: true, maxHealth: 100_000, xpReward: 0, }); setMobPosition("feral-spirit-boss", [2, 0, 0]); useCombatStore.getState().damageMob("feral-spirit-boss", 1, 100); updatePartyRuntimeTargetVisibility(shaman.id, "feral-spirit-boss", true); for (const now of [1_000, 2_500, 4_000, 5_500, 7_000]) advanceDungeonPartyCombat(now); const feralSpirit = abilitiesForClass("shaman").find((ability) => ability.name === "Feral Spirit")!; expect(useCombatStore.getState().summons.filter((summon) => ( summon.ownerActorId === shaman.id && summon.sourceAbilityId === feralSpirit.id ))).toHaveLength(2); const beforeSummonAttack = useCombatStore.getState().mobs["feral-spirit-boss"].health; useCombatStore.getState().tick(0.1, 10_000); expect(useCombatStore.getState().mobs["feral-spirit-boss"].health).toBeLessThan(beforeSummonAttack); }); it("lets an Assassination companion stack poisons and scale Envenom from them", () => { useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 }); const rogue = createConfiguredPartyMember({ name: "Assassination", classId: "rogue", specialization: "Assassination", specializationId: "rogue:assassination", talentTreeId: "rogue-assassination", talentRanks: runtimePartyTalentRanksForLevel("rogue", "Assassination", 80), role: "damage", }, 80, 0, "assassination-poison-runtime"); usePartyStore.getState().initializeParty( "assassination-poison-runtime", 80, useCombatStore.getState().health, "damage", ); usePartyStore.setState({ members: [rogue] }); registerPartyRuntimePosition(rogue.id, [0, 0, 0]); useCombatStore.getState().synchronizeActors(); useCombatStore.getState().applyPrecombatPreparation(500); expect(useCombatStore.getState().auras.filter((aura) => ( aura.targetId === rogue.id && aura.definition.utilityTags?.includes("weapon-enchant") ))).toHaveLength(2); registerEngagedMob("poison-runtime-boss", 10_000_000); const events: StructuredCombatEvent[] = []; const stop = subscribeCombatEvents((event) => events.push(event)); for (let now = 1_000; now <= 60_000; now += 100) { useCombatStore.getState().tick(0.1, now); advanceDungeonPartyCombat(now); } stop(); expect(events.some((event) => ( event.sourceActorId === rogue.id && event.abilityId === "rogue-instant-poison" ))).toBe(true); expect(events.some((event) => ( event.sourceActorId === rogue.id && event.abilityId === "rogue-deadly-poison" ))).toBe(true); expect(events.some((event) => ( event.sourceActorId === rogue.id && event.abilityId === "wow335-rogue-envenom" && event.school === "nature" && event.effectiveAmount > 0 ))).toBe(true); expect(events.some((event) => ( event.sourceActorId === rogue.id && event.abilityId === "wow335-rogue-shiv" ))).toBe(true); expect(events.some((event) => ( event.sourceActorId === rogue.id && event.abilityId === "wow335-rogue-rupture" ))).toBe(false); expect(useCombatStore.getState().auras.find((aura) => ( aura.sourceId === rogue.id && aura.targetId === "poison-runtime-boss" && aura.definition.utilityTags?.includes("rogue-deadly-poison-dose") ))?.stacks).toBe(5); }); it("gives companion Hunter and Warlock pets a hostile target when summoned from self", () => { useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 }); const hunter = createConfiguredPartyMember({ name: "Beast Mastery", classId: "hunter", specialization: "Beast Mastery", specializationId: "hunter:beast-mastery", talentTreeId: "hunter-beast-mastery", role: "damage", }, 80, 0, "pet-target-runtime"); const warlock = createConfiguredPartyMember({ name: "Demonology", classId: "warlock", specialization: "Demonology", specializationId: "warlock:demonology", talentTreeId: "warlock-demonology", talentRanks: runtimePartyTalentRanksForLevel("warlock", "Demonology", 80), role: "damage", }, 80, 1, "pet-target-runtime"); usePartyStore.getState().initializeParty("pet-target-runtime", 80, useCombatStore.getState().health, "damage"); usePartyStore.setState({ members: [hunter, warlock] }); for (const member of usePartyStore.getState().members) registerPartyRuntimePosition(member.id, [-14.5, 0, 0]); useCombatStore.getState().synchronizeActors(); registerEngagedMob("pet-runtime-boss", 100_000); const events: StructuredCombatEvent[] = []; const stop = subscribeCombatEvents((event) => events.push(event)); let growlConfigured = false; for (let now = 1_000; now <= 30_000; now += 100) { useCombatStore.getState().tick(0.1, now); advanceDungeonPartyCombat(now); const hunterPet = useCombatStore.getState().summons.find((summon) => summon.ownerActorId === hunter.id && summon.hunterPet); if (hunterPet && !growlConfigured) { expect(hunterPet).toMatchObject({ hunterPetCommand: "follow", hunterPetReactMode: "defensive", hunterPetGrowlAutocastEnabled: false, }); expect(useCombatStore.getState().setHunterPetGrowlAutocast(hunterPet.id, true)).toBe(true); growlConfigured = true; } } stop(); const pets = useCombatStore.getState().summons.filter((summon) => ( summon.ownerActorId === hunter.id || summon.ownerActorId === warlock.id )); expect(new Set(pets.map((summon) => summon.ownerActorId))).toEqual(new Set([hunter.id, warlock.id])); const baseHunterPetStats = wow335HunterPetBaseStatsForLevel(hunter.level); expect(pets.find((summon) => summon.ownerActorId === hunter.id)).toMatchObject({ hunterPet: { baseStats: { strength: 192, attackPower: 364 }, effectiveStats: { inheritedStamina: expect.any(Number), inheritedRangedAttackPower: expect.any(Number), inheritedSpellDamage: expect.any(Number), }, }, }); const hunterPet = pets.find((summon) => summon.ownerActorId === hunter.id)!; expect(growlConfigured).toBe(true); expect(hunterPet.hunterPetGrowlReadyAt).toBeGreaterThan(0); expect(useCombatStore.getState().mobs["pet-runtime-boss"]?.threatByActor[hunterPet.id]).toBeGreaterThanOrEqual(1_234); expect(useCombatStore.getState().mobs["pet-runtime-boss"]?.forcedTarget).toBeNull(); expect(hunterPet.hunterPet!.effectiveStats.inheritedSpellDamage).toBeGreaterThan(0); expect(hunterPet.hunterPet!.effectiveStats.inheritedSpellDamage).toBeCloseTo( hunterPet.hunterPet!.effectiveStats.inheritedRangedAttackPower / 0.22 * WOW335_HUNTER_PET_RANGED_AP_TO_SPELL_DAMAGE, ); expect(hunterPet.maxHealth).toBe(hunterPet.hunterPet!.effectiveStats.maxHealth); expect(hunterPet.attackDamage).toBe(hunterPet.hunterPet!.effectiveStats.averageAutoAttackDamage); expect(hunterPet.maxHealth).toBeGreaterThan(baseHunterPetStats.maxHealth); expect(hunterPet.attackDamage).toBeGreaterThan(baseHunterPetStats.averageAutoAttackDamage); expect(pets.every((summon) => summon.targetId === "pet-runtime-boss")).toBe(true); expect(events.filter((event) => event.kind === "damage" && [hunter.id, warlock.id].includes(event.sourceActorId))).toEqual(expect.arrayContaining([ expect.objectContaining({ abilityId: "wow335-hunter-call-pet" }), expect.objectContaining({ abilityId: "wow335-warlock-summon-felguard" }), ])); }); it("casts Power Word: Shield on an injured ally and grants absorb capacity", () => { useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 }); const tank = createConfiguredPartyMember({ name: "Tank", classId: "warrior", specialization: "Protection", specializationId: "warrior:protection", talentTreeId: "warrior-protection", role: "tank", }, 80, 0, "discipline-shield-runtime"); const healer = createConfiguredPartyMember({ name: "Discipline", classId: "priest", specialization: "Discipline", specializationId: "priest:discipline", talentTreeId: "priest-discipline", role: "healer", }, 80, 1, "discipline-shield-runtime"); const priestAbilities = abilitiesForClass("priest"); const shield = priestAbilities.find((ability) => ability.name === "Power Word: Shield")!; usePartyStore.getState().initializeParty("discipline-shield-runtime", 80, useCombatStore.getState().health, "damage"); usePartyStore.setState({ members: [ { ...tank, health: Math.round(tank.maxHealth * 0.5) }, { ...healer, resource: 100_000, maxResource: 100_000, resourcePools: { mana: 100_000 }, cooldowns: Object.fromEntries(priestAbilities.filter((ability) => ability.id !== shield.id) .map((ability) => [ability.id, 60_000])), }, ], }); for (const member of usePartyStore.getState().members) registerPartyRuntimePosition(member.id, [0, 0, 0]); useCombatStore.getState().synchronizeActors(); registerEngagedMob("discipline-shield-boss", 100_000); advanceDungeonPartyCombat(1_000); const applied = useCombatStore.getState().auras.find((aura) => ( aura.sourceId === healer.id && aura.targetId === tank.id && aura.definition.name.includes("Power Word: Shield") )); expect(applied?.absorbs.reduce((total, absorb) => total + absorb.remaining, 0)).toBeGreaterThan(0); }); it("casts Inner Focus before an emergency direct heal and makes that heal free", () => { useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 }); const tank = createConfiguredPartyMember({ name: "Tank", classId: "warrior", specialization: "Protection", specializationId: "warrior:protection", talentTreeId: "warrior-protection", role: "tank", }, 80, 0, "inner-focus-runtime"); const healer = createConfiguredPartyMember({ name: "Discipline", classId: "priest", specialization: "Discipline", specializationId: "priest:discipline", talentTreeId: "priest-discipline", talentRanks: runtimePartyTalentRanksForLevel("priest", "Discipline", 80), role: "healer", }, 80, 1, "inner-focus-runtime"); const priestAbilities = abilitiesForClass("priest"); const innerFocus = priestAbilities.find((ability) => ability.name === "Inner Focus")!; const greaterHeal = priestAbilities.find((ability) => ability.name === "Greater Heal")!; const startingHealth = Math.round(tank.maxHealth * 0.3); usePartyStore.getState().initializeParty("inner-focus-runtime", 80, useCombatStore.getState().health, "damage"); usePartyStore.setState({ members: [ { ...tank, health: startingHealth }, { ...healer, resource: 0, maxResource: 0, resourcePools: { mana: 0 }, cooldowns: Object.fromEntries(priestAbilities .filter((ability) => ![innerFocus.id, greaterHeal.id].includes(ability.id)) .map((ability) => [ability.id, 60_000])), }, ], }); for (const member of usePartyStore.getState().members) registerPartyRuntimePosition(member.id, [0, 0, 0]); useCombatStore.getState().synchronizeActors(); registerEngagedMob("inner-focus-boss", 100_000); const events: CombatPresentationEvent[] = []; const stop = subscribeCombatPresentation((message) => { if (message.kind === "event" && message.event.sourceActorId === healer.id) events.push(message.event); }); for (const now of [1_000, 2_500, 6_000]) advanceDungeonPartyCombat(now); stop(); const releases = events.filter((event) => event.phase === "release").map((event) => event.name); expect(releases).toEqual(expect.arrayContaining(["Inner Focus", "Greater Heal"])); expect(releases.indexOf("Inner Focus")).toBeLessThan(releases.indexOf("Greater Heal")); expect(usePartyStore.getState().members.find((member) => member.id === healer.id)?.resource).toBe(0); expect(usePartyStore.getState().members.find((member) => member.id === tank.id)?.health) .toBeGreaterThan(startingHealth); }); it("applies a Shaman owner's Elemental Oath to party-caster healing crits", () => { useCombatStore.getState().initializeCharacter({ classId: "shaman", level: 80 }); useCombatStore.setState((state) => ({ talentRanks: { ...state.talentRanks, "talent-2049": 3 }, })); const tank = createConfiguredPartyMember({ name: "Tank", classId: "warrior", specialization: "Protection", specializationId: "warrior:protection", talentTreeId: "warrior-protection", role: "tank", }, 80, 0, "elemental-oath-party-heal"); const healer = createConfiguredPartyMember({ name: "Healer", classId: "priest", specialization: "Holy", specializationId: "priest:holy", talentTreeId: "priest-holy", role: "healer", }, 80, 1, "elemental-oath-party-heal"); const greaterHeal = abilitiesForClass("priest").find((ability) => ability.name === "Greater Heal")!; const startingHealth = Math.round(tank.maxHealth * 0.3); usePartyStore.getState().initializeParty("elemental-oath-party-heal", 80, useCombatStore.getState().health, "damage"); usePartyStore.setState({ members: [ { ...tank, health: startingHealth, gearStats: EMPTY_ITEM_STATS }, { ...healer, gearStats: EMPTY_ITEM_STATS, resourceType: "mana", resourceName: "Mana", resource: 10_000, maxResource: 10_000, resourcePools: { mana: 10_000 }, cooldowns: Object.fromEntries(abilitiesForClass("priest") .filter((ability) => ability.id !== greaterHeal.id) .map((ability) => [ability.id, 60_000])), }, ], }); registerPartyRuntimePosition(tank.id, [0, 0, 0]); registerPartyRuntimePosition(healer.id, [0, 0, 0]); useCombatStore.getState().synchronizeActors(); registerEngagedMob("elemental-oath-party-heal-boss", 100_000); const impactAt = partyHealCriticalAt(healer.id, greaterHeal.id, tank.id, 0.03); const castStartAt = impactAt - greaterHeal.castTimeMs; advanceDungeonPartyCombat(castStartAt); const activeCast = usePartyStore.getState().members.find((member) => member.id === healer.id)?.activeCast; expect(activeCast?.abilityId).toBe(greaterHeal.id); expect(activeCast?.completesAt).toBe(impactAt); advanceDungeonPartyCombat(impactAt); expect(useCombatStore.getState().combatEvents).toContainEqual(expect.objectContaining({ kind: "healing", sourceActorId: healer.id, targetActorId: tank.id, abilityId: greaterHeal.id, critical: true, })); }); it("keeps companion Penance active for three healing pulses", () => { useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 }); const tank = createConfiguredPartyMember({ name: "Tank", classId: "warrior", specialization: "Protection", specializationId: "warrior:protection", talentTreeId: "warrior-protection", role: "tank", }, 80, 0, "penance-runtime"); const healer = createConfiguredPartyMember({ name: "Discipline", classId: "priest", specialization: "Discipline", specializationId: "priest:discipline", talentTreeId: "priest-discipline", talentRanks: runtimePartyTalentRanksForLevel("priest", "Discipline", 80), role: "healer", }, 80, 1, "penance-runtime"); const priestAbilities = abilitiesForClass("priest"); const penance = priestAbilities.find((ability) => ability.name === "Penance")!; const startingHealth = Math.round(tank.maxHealth * 0.2); usePartyStore.getState().initializeParty("penance-runtime", 80, useCombatStore.getState().health, "damage"); usePartyStore.setState({ members: [ { ...tank, health: startingHealth }, { ...healer, cooldowns: Object.fromEntries(priestAbilities .filter((ability) => ability.id !== penance.id) .map((ability) => [ability.id, 60_000])) }, ] }); for (const member of usePartyStore.getState().members) registerPartyRuntimePosition(member.id, [0, 0, 0]); useCombatStore.getState().synchronizeActors(); registerEngagedMob("penance-boss", 100_000); const visuals: CombatPresentationEvent[] = []; const stopVisuals = subscribeCombatPresentation((message) => { if (message.kind === "event" && message.event.sourceActorId === healer.id && message.event.abilityId === penance.id) visuals.push(message.event); }); advanceDungeonPartyCombat(1_000); expect(usePartyStore.getState().members.find((member) => member.id === healer.id)?.activeCast) .toMatchObject({ mode: "channel", startedAt: 1_000, completesAt: 3_000 }); expect(usePartyStore.getState().members.find((member) => member.id === tank.id)?.health).toBe(startingHealth); advanceDungeonPartyCombat(1_001); const first = usePartyStore.getState().members.find((member) => member.id === tank.id)!.health; expect(first).toBeGreaterThan(startingHealth); advanceDungeonPartyCombat(1_999); expect(usePartyStore.getState().members.find((member) => member.id === tank.id)?.health).toBe(first); advanceDungeonPartyCombat(2_000); expect(usePartyStore.getState().members.find((member) => member.id === tank.id)?.health).toBeGreaterThan(first); advanceDungeonPartyCombat(3_000); expect(usePartyStore.getState().members.find((member) => member.id === healer.id)?.activeCast).toBeNull(); expect(usePartyStore.getState().members.find((member) => member.id === healer.id)?.nextActionAt).toBe(3_250); expect(useCombatStore.getState().effects.some((effect) => effect.sourceAbilityId === penance.id)).toBe(false); stopVisuals(); expect(visuals[0]?.durationMs).toBe(2_000); expect(visuals.map((event) => [event.phase, event.sourceSpellId])).toEqual([ ["cast-start", 47540], ...Array.from({ length: 3 }, () => [["tick", 47750], ["impact", 47750]]).flat(), ["release", 47540], ]); }); it("lets a resurrected tank rejoin independently instead of taunting or casting from its checkpoint", () => { registerEngagedMob(); const tank = usePartyStore.getState().members.find((member) => member.role === "tank")!; for (const member of usePartyStore.getState().members) registerPartyRuntimePosition(member.id, [0, 0, 0]); beginPartyRuntimeRejoin(tank.id, [1_000, 0, 0]); const beforeHealth = useCombatStore.getState().mobs["pack:a"].health; advanceDungeonPartyCombat(1_000); expect(usePartyStore.getState().members.find((member) => member.id === tank.id)) .toMatchObject({ status: "advancing", activeCast: null, activeCastTargetId: null }); expect(usePartyStore.getState().memberTargetIds[tank.id]).toBeUndefined(); expect(useCombatStore.getState().mobs["pack:a"].forcedTarget?.actorId).not.toBe(tank.id); expect(useCombatStore.getState().mobs["pack:a"].health).toBeLessThan(beforeHealth); finishPartyRuntimeRejoin(tank.id); registerPartyRuntimePosition(tank.id, [0, 0, 0]); advanceDungeonPartyCombat(2_000); expect(usePartyStore.getState().memberTargetIds[tank.id]).toBe("pack:a"); }); it("keeps returning teammates advancing during Stop, then resumes the party order when they arrive", () => { const tank = usePartyStore.getState().members.find((member) => member.role === "tank")!; usePartyStore.getState().setCommand("stop"); beginPartyRuntimeRejoin(tank.id, [0, 0, 0]); advanceDungeonPartyCombat(1_000); expect(usePartyStore.getState().members.find((member) => member.id === tank.id)?.status).toBe("advancing"); finishPartyRuntimeRejoin(tank.id); advanceDungeonPartyCombat(2_000); expect(usePartyStore.getState().command).toBe("stop"); expect(usePartyStore.getState().members.find((member) => member.id === tank.id)?.status).toBe("holding"); }); it("has the tank taunt the active mob while all damage allies attack it", () => { registerEngagedMob(); for (const member of usePartyStore.getState().members) { registerPartyRuntimePosition(member.id, [0, 0, 0]); } const beforeMobHealth = useCombatStore.getState().mobs["pack:a"].health; const fullPlayerHealth = useCombatStore.getState().health; const events: CombatPresentationEvent[] = []; const unsubscribe = subscribeCombatPresentation((message) => { if (message.kind === "event" && message.event.actorGroup === "party") events.push(message.event); }); advanceDungeonPartyCombat(1_000); const party = usePartyStore.getState(); const tank = party.members.find((member) => member.role === "tank")!; expect(useCombatStore.getState().mobs["pack:a"]).toMatchObject({ targetActorId: tank.id, forcedTarget: { actorId: tank.id, endsAt: 4_000 }, }); expect(party.memberTargetIds[tank.id]).toBe("pack:a"); expect(useCombatStore.getState().mobs["pack:a"].health).toBeLessThan(beforeMobHealth); expect(party.members.filter((member) => member.nextActionAt > 1_000)).toHaveLength(4); useCombatStore.getState().advanceMobCombat(3_000); const woundedTank = usePartyStore.getState().members.find((member) => member.id === tank.id)!; expect(woundedTank.health).toBeLessThan(woundedTank.maxHealth); expect(useCombatStore.getState().health).toBe(fullPlayerHealth); const instantReleases = events.filter((event) => event.phase === "release" && event.instantCast); expect(instantReleases.length).toBeGreaterThan(0); expect(instantReleases.every((event) => ( events.some((impact) => ( impact.phase === "impact" && impact.sourceActorId === event.sourceActorId && impact.abilityId === event.abilityId && impact.instantCast )) ))).toBe(true); const actionRevisions = party.members.map((member) => member.nextActionAt); advanceDungeonPartyCombat(1_100); expect(usePartyStore.getState().members.map((member) => member.nextActionAt)).toEqual(actionRevisions); unsubscribe(); }); it("keeps living companions in the fight while the player waits for resurrection", () => { registerEngagedMob(); for (const member of usePartyStore.getState().members) { registerPartyRuntimePosition(member.id, [0, 0, 0]); } useCombatStore.getState().damagePlayer(useCombatStore.getState().maxHealth * 10); const beforeMobHealth = useCombatStore.getState().mobs["pack:a"].health; advanceDungeonPartyCombat(1_000); expect(useCombatStore.getState().health).toBe(0); expect(useCombatStore.getState().mobs["pack:a"].health).toBeLessThan(beforeMobHealth); }); it("assigns the tank to a loose mob attacking the healer before one attacking DPS", () => { const party = usePartyStore.getState(); const tank = party.members.find((member) => member.role === "tank")!; const healerId = "__player__"; const damage = party.members.find((member) => member.role === "damage")!; for (const member of party.members) registerPartyRuntimePosition(member.id, [0, 0, 0]); useCombatStore.getState().registerMob("loose:dps", { name: "DPS add", maxHealth: 200, xpReward: 0 }); useCombatStore.getState().registerMob("loose:healer", { name: "Healer add", maxHealth: 200, xpReward: 0 }); setMobPosition("loose:dps", [8, 0, 0]); setMobPosition("loose:healer", [9, 0, 0]); useCombatStore.getState().damageMob( "loose:dps", 2, 100, { actorId: damage.id, role: "damage" }, ); useCombatStore.getState().damageMob( "loose:healer", 2, 100, { actorId: healerId, role: "healer" }, ); for (const member of party.members) { updatePartyRuntimeTargetVisibility(member.id, "loose:dps", true); updatePartyRuntimeTargetVisibility(member.id, "loose:healer", true); } useCombatStore.getState().advanceMobCombat(200); advanceDungeonPartyCombat(1_000); expect(usePartyStore.getState().memberTargetIds[tank.id]).toBe("loose:healer"); expect(useCombatStore.getState().mobs["loose:healer"].forcedTarget).toMatchObject({ actorId: tank.id, }); }); it("taunts a loose add off an ally while the rest of the party stays on the boss", () => { const party = usePartyStore.getState(); const tank = party.members.find((member) => member.role === "tank")!; const damage = party.members.find((member) => member.role === "damage")!; for (const member of party.members) registerPartyRuntimePosition(member.id, [0, 0, 0]); useCombatStore.getState().registerMob("boss-with-add", { name: "Boss with add", boss: true, maxHealth: 10_000, xpReward: 0, }); useCombatStore.getState().registerMob("boss-loose-add", { name: "Loose add", maxHealth: 1_000, xpReward: 0, }); setMobPosition("boss-with-add", [2, 0, 0]); setMobPosition("boss-loose-add", [8, 0, 0]); useCombatStore.getState().damageMob("boss-with-add", 1, 100, { actorId: tank.id, role: "tank", }); useCombatStore.getState().damageMob("boss-loose-add", 1, 100, { actorId: damage.id, role: "damage", }); useCombatStore.getState().selectMob("boss-with-add"); for (const member of party.members) { updatePartyRuntimeTargetVisibility(member.id, "boss-with-add", true); updatePartyRuntimeTargetVisibility(member.id, "boss-loose-add", true); } useCombatStore.getState().advanceMobCombat(200); advanceDungeonPartyCombat(1_000); const runtime = usePartyStore.getState(); expect(runtime.memberTargetIds[tank.id]).toBe("boss-loose-add"); expect(runtime.memberTargetIds[damage.id]).toBe("boss-with-add"); expect(useCombatStore.getState().mobs["boss-loose-add"].forcedTarget).toMatchObject({ actorId: tank.id, endsAt: 4_000, }); expect(runtime.members.find((member) => member.id === tank.id)?.tauntReadyAt).toBe(9_000); }); it("shares an eight-second tank taunt cooldown across loose mobs", () => { const party = usePartyStore.getState(); const tank = party.members.find((member) => member.role === "tank")!; const damages = party.members.filter((member) => member.role === "damage"); for (const member of party.members) registerPartyRuntimePosition(member.id, [0, 0, 0]); for (const [index, id] of ["cooldown-a", "cooldown-b"].entries()) { useCombatStore.getState().registerMob(id, { name: id, maxHealth: 200, xpReward: 0 }); setMobPosition(id, [10 + index, 0, 0]); useCombatStore.getState().damageMob(id, 2, 100, { actorId: damages[index].id, role: "damage", }); } useCombatStore.getState().advanceMobCombat(200); for (const member of party.members) updatePartyRuntimeTargetVisibility(member.id, "cooldown-a", true); advanceDungeonPartyCombat(1_000); const firstTarget = usePartyStore.getState().memberTargetIds[tank.id]; const otherTarget = firstTarget === "cooldown-a" ? "cooldown-b" : "cooldown-a"; expect(useCombatStore.getState().mobs[firstTarget].forcedTarget?.actorId).toBe(tank.id); for (const member of party.members) updatePartyRuntimeTargetVisibility(member.id, otherTarget, true); advanceDungeonPartyCombat(4_100); expect(usePartyStore.getState().memberTargetIds[tank.id]).toBe(otherTarget); expect(useCombatStore.getState().mobs[otherTarget].forcedTarget).toBeNull(); expect(usePartyStore.getState().members.find((member) => member.id === tank.id)?.tauntReadyAt).toBe(9_000); }); it("gives damage dealers a higher baseline hit than the tank", () => { const [tank, damage] = usePartyStore.getState().members; expect(partyAttackDamage(damage)).toBeGreaterThan(partyAttackDamage(tank)); const attackStat = baseClassFor(damage.classId) === "mage" ? { spellPower: 126 } : baseClassFor(damage.classId) === "hunter" ? { rangedAttackPower: 126 } : { attackPower: 126 }; expect(partyAttackDamage({ ...damage, gearStats: { ...EMPTY_ITEM_STATS, ...attackStat }, })).toBeGreaterThan(partyAttackDamage(damage)); }); it("lets a dedicated healer restore the lowest-health target on a cooldown", () => { usePartyStore.getState().initializeParty("healer-runtime", 20, 100, "damage"); const members = usePartyStore.getState().members; const healer = members.find((member) => member.role === "healer")!; const tank = members.find((member) => member.role === "tank")!; const wounded = members.map((member) => member.id === tank.id ? { ...member, health: Math.round(member.maxHealth * 0.25) } : member); const first = applyHealerSupport(wounded, 80, 100, 1_000); expect(first.targetId).toBe(tank.id); expect(first.members.find((member) => member.id === tank.id)!.health).toBeGreaterThan( wounded.find((member) => member.id === tank.id)!.health, ); expect(first.members.find((member) => member.id === healer.id)).toMatchObject({ status: "healing", nextActionAt: 3_000, }); const geared = applyHealerSupport(wounded.map((member) => member.id === healer.id ? { ...member, gearStats: { ...member.gearStats, spellPower: member.gearStats.spellPower + 1_000 } } : member), 80, 100, 1_000); expect(geared.effectiveHealing).toBeGreaterThan(first.effectiveHealing); const coolingDown = applyHealerSupport(first.members, 50, 100, 2_000); expect(coolingDown.playerHealing).toBe(0); expect(coolingDown.targetId).toBeNull(); }); it("uses an unlocked party resurrection instead of treating a defeated player as a heal target", () => { usePartyStore.getState().initializeParty("resurrection-runtime", 80, 100, "damage"); const support = applyHealerSupport(usePartyStore.getState().members, 0, 100, 1_000); expect(support).toMatchObject({ action: "resurrection", targetId: "__player__", playerHealing: 0, }); expect(support.playerResurrectionPercent).toBeGreaterThan(0); expect(support.presentation?.name.toLowerCase()).toMatch(/rebirth|redemption|resurrect|ancestral|revive/); }); it("lets the party raise the player in place while combat remains active", () => { useCombatStore.getState().initializeCharacter({ classId: "mage", level: 80 }); usePartyStore.getState().initializeParty("resurrection-runtime", 80, useCombatStore.getState().health, "damage"); useCombatStore.getState().damagePlayer(useCombatStore.getState().maxHealth * 10); expect(useCombatStore.getState().health).toBe(0); advanceDungeonPartyCombat(1_000); expect(useCombatStore.getState().health).toBeGreaterThan(0); expect(useCombatStore.getState().feedback?.message).toContain("Resurrected"); }); it("classifies ranged classes and elemental shaman into a stand-off range band", () => { const ranged = [ { classId: "hunter", specialization: "Marksmanship", role: "damage" }, { classId: "mage", specialization: "Frost", role: "damage" }, { classId: "warlock", specialization: "Affliction", role: "damage" }, { classId: "priest", specialization: "Shadow", role: "damage" }, { classId: "shaman", specialization: "Elemental", role: "damage" }, ] as const; for (const member of ranged) { expect(partyUsesRangedAttacks(member)).toBe(true); expect(partyCombatRangeBand(member)).toEqual({ minimum: 10, preferred: 14.5, maximum: 18.1, }); } expect(partyUsesRangedAttacks({ classId: "shaman", specialization: "Enhancement", role: "damage", })).toBe(false); }); it("acquires nearby idle enemies only while commanded to attack", () => { useCombatStore.getState().registerMob("idle-add", { name: "Idle add", maxHealth: 100, xpReward: 0 }); setMobPosition("idle-add", [4, 0, 0]); updatePartyRuntimeAttackCorridor([0, 0, 0], [[10, 0, 0]], 0); const combat = useCombatStore.getState(); expect(partyCommandTargetId("defend", combat.mobs, null, null, [0, 0, 0])).toBeNull(); expect(partyCommandTargetId("stop", combat.mobs, "idle-add", null, [0, 0, 0])).toBeNull(); expect(partyCommandTargetId("attack", combat.mobs, null, null, [0, 0, 0])).toBe("idle-add"); const before = combat.mobs["idle-add"].health; usePartyStore.getState().setCommand("attack"); for (const member of usePartyStore.getState().members) { updatePartyRuntimeTargetVisibility(member.id, "idle-add", true); } advanceDungeonPartyCombat(1_000); expect(useCombatStore.getState().mobs["idle-add"].health).toBeLessThan(before); }); it("ignores idle enemies on a side branch outside the approved Attack corridor", () => { useCombatStore.getState().registerMob("road-add", { name: "Road add", maxHealth: 100, xpReward: 0 }); useCombatStore.getState().registerMob("side-add", { name: "Side add", maxHealth: 100, xpReward: 0 }); setMobPosition("road-add", [12, 0, 2]); setMobPosition("side-add", [4, 0, 16]); updatePartyRuntimeAttackCorridor([0, 0, 0], [[20, 0, 0]], 0); expect(partyCommandTargetId( "attack", useCombatStore.getState().mobs, null, null, [0, 0, 0], )).toBe("road-add"); }); it("keeps a corridor-authorized add while the party switches to its pursuit route", () => { useCombatStore.getState().registerMob("road-add", { name: "Road add", maxHealth: 100, xpReward: 0 }); setMobPosition("road-add", [12, 0, 2]); updatePartyRuntimeAttackCorridor([0, 0, 0], [[20, 0, 0]], 0); const mobs = useCombatStore.getState().mobs; const acquired = partyCommandTargetId("attack", mobs, null, null, [0, 0, 0]); clearPartyRuntimeRegistry(); setMobPosition("road-add", [12, 0, 2]); expect(partyCommandTargetId("attack", mobs, null, acquired, [0, 0, 0])).toBe("road-add"); }); it("does not attack while stopped, even when a mob is engaged", () => { registerEngagedMob(); const before = useCombatStore.getState().mobs["pack:a"].health; usePartyStore.getState().setCommand("stop"); advanceDungeonPartyCombat(1_000); expect(useCombatStore.getState().mobs["pack:a"].health).toBe(before); expect(usePartyStore.getState().members.every((member) => member.status === "holding")).toBe(true); }); it("waits to deal damage until party members are in their attack range", () => { registerEngagedMob(); for (const member of usePartyStore.getState().members) { registerPartyRuntimePosition(member.id, [80, 0, 0]); } const before = useCombatStore.getState().mobs["pack:a"].health; advanceDungeonPartyCombat(1_000); expect(useCombatStore.getState().mobs["pack:a"].health).toBe(before); expect(usePartyStore.getState().members.every((member) => ( member.status === "advancing" || ((member.status === "attacking" || member.status === "guarding") && member.nextActionAt > 1_000) ))).toBe(true); }); it("does not deal remote damage when a target has no world position", () => { useCombatStore.getState().registerMob("missing", { name: "Missing", maxHealth: 100, xpReward: 0 }); useCombatStore.getState().damageMob("missing", 1, 100); for (const member of usePartyStore.getState().members) { updatePartyRuntimeTargetVisibility(member.id, "missing", true); } const before = useCombatStore.getState().mobs.missing.health; advanceDungeonPartyCombat(1_000); expect(useCombatStore.getState().mobs.missing.health).toBe(before); }); });