import { nativeTrainingAutoAttackProfile } from "./romNativeCombatAmount"; import { romNativePlayerHitProfile } from "./romNativeCombatResolution"; import { abilitiesForClass, abilitiesForCharacter, abilityAtLevel, abilityById, learnedAbilityForBuild, isAbilityUnlocked, resourceProfileForClass, triggeredAbilityBySpellId, type AbilityDefinition, type AbilityEffect, type ResourceType, } from "./abilityCatalog"; import { aiHintsForAbility, abilityRequiresOutOfCombat, canAbilityCastWhileMoving, targetingRulesForAbility, } from "./abilityRules"; import type { CombatSummon, MobCombatState, TimedCombatEffect } from "./combatStore"; import { PLAYER_AURA_ENTITY_ID, abilityDamageSchool, auraAdjustedCharacterStats, equipmentAdjustedCombatAmount, refreshPriestPainAndSufferingDot, stableCombatUnit, useCombatStore, } from "./combatStore"; import { raceAffixHasteMultiplier } from "./dungeonRaceAffixBuffs"; import { activeRaceOrbId } from "./dungeonRaceAffixRuntime"; import { WOW335_PALADIN_ILLUMINATION_TALENT_ID, wow335PaladinIlluminationRefundPlan, } from "./wow335PaladinIlluminationRuntime"; import { WOW335_HUNTER_GO_FOR_THE_THROAT_TALENT_ID, wow335HunterGoForTheThroatAttackKindForAbility, } from "./wow335HunterGoForTheThroatRuntime"; import { companionResourceMaximum, memberFromCompanionActor } from "./combatActorAuthority"; import type { MobVector3 } from "./mobPopulation"; import type { WeaponStats } from "./itemStats"; import { requireDungeonDefinition } from "./dungeonRegistry"; import { getMobPosition, getMobRuntimeTransform } from "./mobRuntimeRegistry"; import { getPartyRuntimePosition, partyRuntimeIsRejoining, partyRuntimeCenter, partyRuntimePositionAlongAttackCorridor, partyRuntimeTargetVisible, updatePartyRuntimePosition, } from "./partyRuntimeRegistry"; import { usePartyStore, type PartyCommand, type PartyMember } from "./partyStore"; import { baseClassFor, classById, isRomClassId } from "../app/characterCatalog"; import { useGameStore } from "./store"; import { coaBarbarianDecapitateTargetAllowed } from "./coaBarbarianExecuteRuntime"; import { useManastormStore } from "./manastormStore"; import { WOW335_GLYPH_OF_SWIFTMEND_ID } from "./wow335GlyphCatalog"; import { WOW335_ROGUE_IMPROVED_KICK_TALENT_ID, wow335RogueImprovedKickAuraEffectForRank, wow335RogueImprovedKickSilenceDurationMs, } from "./wow335RogueTalentRuntime"; import { WOW335_WARRIOR_IMPROVED_VICTORY_RUSH_TALENT_ID, WOW335_WARRIOR_IMPROVED_VICTORY_RUSH_READY_TAG, WOW335_WARRIOR_VICTORY_RUSH_KILL_READY_TAG, } from "./wow335WarriorVictoryRushRuntime"; import { WOW335_WARRIOR_UNBRIDLED_WRATH_ABILITY_ID, WOW335_WARRIOR_UNBRIDLED_WRATH_TALENT_ID, wow335WarriorUnbridledWrathProfile, wow335WarriorUnbridledWrathShouldProc, } from "./wow335WarriorUnbridledWrathRuntime"; import { activeDungeonCompletedObjectiveIds } from "./dungeonEncounterRuntime"; import { PLAYER_AGGRO_ID, type ThreatSource } from "./aggro"; import type { Wow335KillingSpreeWeaponSnapshot } from "./wow335RogueKillingSpreeRuntime"; import { applyAura as applyCombatAura, resolveDamageValue as resolveAuraDamageValue, resolveResourceValue as resolveAuraResourceValue, resolveStatValue as resolveAuraStatValue, type ActiveAura, type DispelCategory, } from "./combatAuras"; import { ROM_PRIEST_HOLY_BLESSING_TAG, romPriestHolyBlessingDamage, romPriestHolyBlessingStats, } from "./romPriestHolyBlessingRuntime"; import { romKnightGodsVengeanceShouldResetShadowStep } from "./romKnightOverrides"; import { romChampionHasForgeRankTwoAura, romChampionRunePulseUseEffect, } from "./romChampionForgeRuntime"; import { ROM_DRUID_MAGE_INSPIRATION_PASSIVE_ID, isRomDruidMagePair, romDruidMageInspirationApplySpendStack, romDruidMageInspirationHastePercent, romDruidMageInspirationRecipientIds, } from "./romDruidMageInspirationRuntime"; import { ROM_MAGE_DRUID_GREEN_GUARDIAN_AURA_TAG, ROM_MAGE_DRUID_GREEN_GUARDIAN_SKILL_ID, isRomMageDruidPair } from "./romMageDruidGreenGuardianRuntime"; import { romWardenRevivalBuildMatches, romWardenRevivalNormalHeal, romWardenRevivalStateIsActive, } from "./romWardenRevivalRuntime"; import { romWarlockMageSoulBrandEffects, } from "./romWarlockMageSoulBrand"; import { ROM_WARLOCK_PRIEST_SPREADING_PAIN_CRIT_TAG } from "./romWarlockPriestSpreadingPain"; import { ROM_ROGUE_POISON_AURA_TAG, ROM_ROGUE_POISON_ATTACK_SPEED_TAG, ROM_ROGUE_POISONED_TARGET_TAG, ROM_ROGUE_POISON_SKILL_ID, romRoguePoisonAttackResultForAttack, romRoguePoisonAttackSpeedTargetId, } from "./romRoguePoisonRuntime"; import { ROM_ROGUE_ENERGIZED_HANDS_COOLDOWN_REDUCTION_MS, ROM_ROGUE_ENERGIZED_HANDS_SKILL_ID, ROM_ROGUE_POISONOUS_HANDS_EXCLUSIVE_GROUP, romRogueEnergizedHandsActive, } from "./romRogueEnergizedHandsRuntime"; import { ROM_ROGUE_CHAMPION_NERVE_GEAR_PASSIVE_SPELL_ID, romRogueChampionNerveGearEffect, romRogueChampionNerveGearShouldTrigger, } from "./romRogueChampionNerveGearRuntime"; import { equipmentAttackPower, hastedDuration, ratingPercent, deriveCharacterStats, type WotlkAttackKind, } from "./wotlkStats"; import { useWailingEncounterStore } from "./wailingCavernsEncounter"; import { resolvedEquipmentLoadout } from "./heirloomEquipment"; import { emitCombatPresentation, penancePulseVisualSpellId, presentationDeliveryForAbility, presentationSchoolForAbility, type CombatPresentationDelivery, type CombatPresentationSchool, } from "./combatPresentation"; import { choosePartyAbility, choosePartyRotationDecision, commitPartyAbilityUse, partyAbilityIsReady, partyAbilityWithHunterLockAndLoad, partyHunterLockAndLoadReady, partyAbilityAmount, partyAbilityCosts, partyAbilityHasDepletedPartyResource, type PartyResourcePoolSnapshot, } from "./partyAbilityAi"; import { minimumHostileTargetsForAbility } from "./combatRotation"; import { emitRotationDecision } from "./rotationTelemetry"; import { imminentCooldownMechanic } from "./companionCooldownPolicy"; import { normalizeActorResourcePool, type DeathKnightRuneState } from "./combatActors"; import { enemyControlPreventsActions } from "./enemyAttacks"; import { advanceCombatResourcePool } from "./combatResources"; import { advanceRomNativeRecoveryClock, romNativePeriodicManaEffectDuring, romNativeRecoveryPulseAmounts, } from "./romNativeRecovery"; import { baseMissChancePercent } from "./combatResults"; import { wow335DeathKnightSuddenDoomTriggerForLandedHit } from "./wow335DeathKnightSuddenDoomRuntime"; import { wow335DeathKnightReapingApplyProcPlan, wow335DeathKnightReapingProcPlan, wow335DeathKnightReapingRestoreLandedSpend, wow335DeathKnightReapingShouldProc, wow335DeathKnightReapingStateForRunes, wow335DeathKnightSpentRuneSlots, } from "./wow335DeathKnightReapingRuntime"; import { WOW335_DEATH_KNIGHT_SCENT_OF_BLOOD_TALENT_ID, wow335DeathKnightScentOfBloodOnMeleeHit, } from "./wow335DeathKnightScentOfBloodRuntime"; import { WOW335_DEATH_KNIGHT_BLOOD_CAKED_BLADE_TALENT_ID } from "./wow335DeathKnightBloodCakedBladeRuntime"; import { WOW335_DEATH_KNIGHT_BLOODY_VENGEANCE_TALENT_ID } from "./wow335DeathKnightBloodyVengeanceRuntime"; import { WOW335_DEATH_KNIGHT_BLOOD_GORGED_TALENT_ID } from "./wow335DeathKnightBloodGorgedRuntime"; import { wow335DeathKnightSpellIdMatchesBloodGorgedMask } from "./wow335DeathKnightBloodGorgedSpellFamily"; import { WOW335_DEATH_KNIGHT_BLADE_BARRIER_TALENT_ID, wow335DeathKnightBladeBarrierAuraDefinition, wow335DeathKnightBladeBarrierProfile, wow335DeathKnightBladeBarrierShouldActivate, } from "./wow335DeathKnightBladeBarrierRuntime"; import { WOW335_DEATH_KNIGHT_EPIDEMIC_TALENT_ID, wow335DeathKnightEpidemicDotEffect, } from "./wow335DeathKnightEpidemicRuntime"; import { WOW335_DEATH_KNIGHT_MERCILESS_COMBAT_TALENT_ID, wow335DeathKnightMercilessCombatDamage, } from "./wow335DeathKnightMercilessCombatRuntime"; import { WOW335_DEATH_KNIGHT_DANCING_RUNE_WEAPON_ABILITY_ID, WOW335_DEATH_KNIGHT_DANCING_RUNE_WEAPON_AURA, WOW335_DEATH_KNIGHT_DANCING_RUNE_WEAPON_AURA_TAG, wow335DeathKnightDancingRuneWeaponCanMirrorEvent, wow335DeathKnightDancingRuneWeaponDamage, wow335DeathKnightDancingRuneWeaponExcludedUtilitySpellForName, wow335DeathKnightDancingRuneWeaponIsActive, } from "./wow335DeathKnightDancingRuneWeaponRuntime"; import { warlockAftermathDazeForRank } from "./wow335WarlockTalentRuntime"; import { WOW335_MAGE_ARCANE_CONCENTRATION_AURA, WOW335_MAGE_ARCANE_CONCENTRATION_TAG, } from "./wow335MageTalentRuntime"; import { WOW335_MAGE_FROST_WARDING_TALENT_ID, wow335MageFrostWardingArmorAuraEffect, } from "./wow335MageFrostWardingRuntime"; import { WOW335_SHAMAN_ELEMENTAL_DEVASTATION_TALENT_ID } from "./wow335ShamanTalentRuntime"; import { WOW335_SHAMAN_ELEMENTAL_FOCUS_AURA_TAG, wow335ShamanElementalFocusProfileForRank, wow335ShamanElementalFocusShouldProc, } from "./wow335ShamanElementalFocusRuntime"; import { WOW335_SHAMAN_LIGHTNING_OVERLOAD_PROC_TAG, WOW335_SHAMAN_LIGHTNING_OVERLOAD_TALENT_ID, wow335ShamanLightningOverloadDamageAmount, wow335ShamanLightningOverloadProfileForRank, wow335ShamanLightningOverloadShouldProc, } from "./wow335ShamanLightningOverloadRuntime"; import { WOW335_MAGE_FROSTBITE_TALENT_ID, wow335MageFrostbiteProcDurationMs, } from "./wow335MageFrostbiteRuntime"; import { WOW335_MAGE_ARCANE_POTENCY_AURA, WOW335_MAGE_ARCANE_POTENCY_TAG, WOW335_MAGE_ARCANE_POTENCY_TALENT_ID, WOW335_MAGE_MASTER_OF_ELEMENTS_TALENT_ID, wow335MageArcanePotencyCanProc, wow335MageArcanePotencyCastProfile, wow335MageMasterOfElementsManaRefund, } from "./wow335MageConditionalTalentRuntime"; import { WOW335_MAGE_NETHERWIND_PRESENCE_TALENT_ID, wow335MageNetherwindPresenceAurasAfterCast, wow335MageNetherwindPresenceHastePercent, } from "./wow335MageNetherwindPresenceRuntime"; import { WOW335_MAGE_EMPOWERED_FIRE_TALENT_ID, wow335MageEmpoweredFireEffect, } from "./wow335MageEmpoweredFireRuntime"; import { channelAmountEffects, channelPulseAt, channelPulseCount } from "./combatChannelAmounts"; import { WOW335_DRUID_NATURES_REACH_TALENT_ID, wow335DruidNaturesReachRangeBonusYards, } from "./wow335DruidTalentRuntime"; import { healingAbilityCanAidOthers, healingAbilityHasPendingHot, healingRecipientIds } from "./healingAbilityRules"; import { romPriestWarriorStanceRequired, romPriestWarriorStanceUseAllowed, } from "./romPriestKnightOverrides"; import { WOW335_MAGE_FLAME_THROWING_TALENT_ID, wow335MageFlameThrowingProfile, } from "./wow335MageFlameThrowingRuntime"; import { WOW335_MAGE_TORMENT_THE_WEAK_TALENT_ID, wow335MageTormentTheWeakDamage, } from "./wow335MageTormentTheWeakRuntime"; import { WOW335_MAGE_MOLTEN_FURY_TALENT_ID, wow335MageMoltenFuryDamage, } from "./wow335MageMoltenFuryRuntime"; import { WOW335_MAGE_IMPROVED_COUNTERSPELL_TALENT_ID, wow335MageImprovedCounterspellSilenceDurationMs, } from "./wow335MageImprovedCounterspellRuntime"; import { consumeLockAndLoadCharge, WOW335_HUNTER_LOCK_AND_LOAD_READY_TAG, } from "./wow335HunterRuntime"; import { WOW335_WARRIOR_ANGER_MANAGEMENT_CHARGE_AURA_TAG, WOW335_WARRIOR_ANGER_MANAGEMENT_TALENT_ID, wow335WarriorAngerManagementProfile, wow335WarriorChargeRageProfile, wow335WarriorChargeRank, } from "./wow335WarriorAngerManagementRuntime"; import { combatHasLineOfSight, combatSpatialActor, isPositionInFacingArc, isSourceBehindTarget, } from "./combatSpatial"; import { useDungeonSimulationStore } from "./dungeonSimulationStore"; import { cooldownResetCandidates } from "./cooldownReset"; import { matchingAuraStacks, rogueEnvenomPreservesDeadlyPoison, ROGUE_DEADLY_POISON_DOSE_TAG, ROGUE_ENVENOM_BUFF_TAG, } from "./roguePoison"; import { WOW335_WARLOCK_METAMORPHOSIS_AURA_TAG, warlockMetamorphosisCanCastAbilityWhileActive, warlockMetamorphosisFormActionReady, } from "./wow335WarlockMetamorphosisRuntime"; import { wow335ShamanImprovedWaterShieldShouldProc, wow335ShamanWaterShieldOrbManaAtLevel, } from "./wow335ShamanImprovedWaterShieldRuntime"; import { WOW335_SHAMAN_TIDAL_WAVES_AURA_TAG, WOW335_SHAMAN_TIDAL_WAVES_DURATION_MS, wow335ShamanTidalWavesBonusHealingPercent, wow335ShamanTidalWavesCastTimeMultiplier, wow335ShamanTidalWavesChargesForTrigger, wow335ShamanTidalWavesCriticalChanceBonusPercent, wow335ShamanTidalWavesProcChance, } from "./wow335ShamanTidalWavesRuntime"; import { wow335ShamanFlurryIsActive } from "./wow335ShamanFlurryRuntime"; import { WOW335_SHAMAN_NATURES_GUARDIAN_NEXT_NATURE_CRIT_TAG, wow335ShamanNaturesGuardianNextCritIsReady, } from "./wow335ShamanNaturesGuardianRuntime"; import { WOW335_SHAMAN_MAELSTROM_WEAPON_AURA_TAG, WOW335_SHAMAN_MAELSTROM_WEAPON_ELIGIBLE_SPELLS, WOW335_SHAMAN_MAELSTROM_WEAPON_MAX_STACKS, wow335ShamanMaelstromWeaponCastTimeReductionPercent, wow335ShamanMaelstromWeaponPvpDamageReductionPercent, wow335ShamanMaelstromWeaponProfile, } from "./wow335ShamanMaelstromWeaponRuntime"; import { WARLOCK_BACKDRAFT_READY_TAG, warlockBackdraftDestructionSpellEligible, warlockBackdraftEffectsForRank, warlockBackdraftTriggerEligible, } from "./wow335WarlockTalentRuntime"; import { romAbilityNeedsRangedWeapon, romAbilityNeedsShield, romAbilityRequiredTargetStateTag, romEffectsForCast, romSelfDrainPercent, romTargetBleedCount } from "./romCombatMechanics"; import { romCreatureFamilyTargetTags } from "./romCreatureFamilies"; import { romScoutRogueWhirlwindBonusEffect } from "./romScoutRogueOverrides"; import { ROM_ROGUE_OFF_HAND_MASTERY_ACCURACY_PERCENT, ROM_ROGUE_OFF_HAND_MASTERY_DAMAGE_PERCENT, ROM_ROGUE_OFF_HAND_MASTERY_TAG, } from "./romRogueMechanics"; import { ROM_CHAMPION_RUNE_SIPHON_ID, romChampionDarkEnergyStrikeGroup0Survives, romChampionDarkEnergyStrikeMatchesBuild, romChampionDarkEnergyStrikePacketGroupAtDamageIndex, romChampionDarkEnergyStrikeShouldResetRuneSiphon, } from "./romChampionDarkEnergyStrikeRuntime"; import { romPriestChampionRageControlGain, romPriestVowOfSilenceDurationMs, } from "./romPriestChampionOverrides"; import { ROM_PRIEST_CHAMPION_BEACON_OF_REGENERATION_ID, ROM_PRIEST_CHAMPION_BEACON_OF_REGENERATION_TAG, resolveRomPriestChampionBeaconFromAuras, } from "./romPriestChampionBeaconRuntime"; import { ROM_COA_WARRIOR_ROGUE_EXHAUSTION_ID, ROM_COA_WARRIOR_ROGUE_PROBING_ATTACK_ID, romCoaWarriorRogueExhaustionProc, } from "./romWarriorRogueExhaustion"; import { ROM_FIRE_LIGHTNING_BURST_RESET_GUARD_TAG, romFireLightningBurstReset } from "./romMageWarlockOverrides"; import { ROM_MAGE_KNIGHT_LIGHT_OF_DAY, ROM_MAGE_MEDITATIVE_CURRENT, ROM_MAGE_MEDITATIVE_CURRENT_COOLDOWN_EFFECT, romMageMeditativeCurrentProc, romMageMeditativeCurrentReadyAt, } from "./romMageRuntime"; import { ROM_SCOUT_PRIEST_DRUMS_OF_WAR } from "./romScoutPriestOverrides"; import { ROM_MAGE_WARRIOR_MEDITATION_PATH_ACTIVE_EFFECT, ROM_MAGE_WARRIOR_MEDITATION_PATH_LOCKOUT_EFFECT, ROM_MAGE_WARRIOR_MEDITATION_PATH_PASSIVE_SPELL_ID, ROM_MAGE_WARRIOR_MEDITATION_PATH_ACTIVE_TAG, ROM_MAGE_WARRIOR_MEDITATION_PATH_LOCKOUT_TAG, romMageWarriorMeditationPathAllowsMovement, romMageWarriorMeditationPathCanProc, romMageWarriorMeditationPathHasState, } from "./romMageWarriorRuntime"; const OUT_OF_COMBAT_REGEN_FRACTION = 0.06; const ATTACK_ACQUISITION_RANGE = 30; const HEALER_ACTION_INTERVAL_MS = 2_000; const TANK_TAUNT_RANGE = 30; const TANK_TAUNT_COOLDOWN_MS = 8_000; const TANK_TAUNT_DURATION_MS = 3_000; const PARTY_SHAMAN_RIPTIDE_ABILITY_ID = "wow335-shaman-riptide"; const PARTY_SHAMAN_RIPTIDE_CHAIN_HEAL_MULTIPLIER = 1.25; function stablePartyUnit(seed: string): number { let hash = 2_166_136_261; for (const character of seed) { hash ^= character.charCodeAt(0); hash = Math.imul(hash, 16_777_619); } return (hash >>> 0) / 0x1_0000_0000; } interface PartyShamanTidalWavesCast { readonly ability: AbilityDefinition; readonly auraInstanceId: string; readonly chargesConsumed: number; } function partyShamanTidalWavesCast( member: PartyMember, ability: AbilityDefinition, now: number, ): PartyShamanTidalWavesCast | null { if (member.classId !== "shaman" || ability.source === "runewaker" || !["Healing Wave", "Lesser Healing Wave"].includes(ability.name) || wow335ShamanTidalWavesProcChance(member.talentRanks["talent-2063"] ?? 0) <= 0) return null; const aura = useCombatStore.getState().auras.find((candidate) => ( candidate.sourceId === member.id && candidate.targetId === member.id && candidate.stacks > 0 && candidate.definition.utilityTags?.includes(WOW335_SHAMAN_TIDAL_WAVES_AURA_TAG) && (candidate.expiresAt === null || candidate.expiresAt > now) )); if (!aura) return null; return { ability: ability.name === "Healing Wave" ? { ...ability, castTimeMs: Math.max(0, Math.round( ability.castTimeMs * wow335ShamanTidalWavesCastTimeMultiplier(aura.stacks), )) } : ability, auraInstanceId: aura.instanceId, chargesConsumed: aura.stacks, }; } interface PartyShamanMaelstromCast { readonly ability: AbilityDefinition; readonly auraInstanceId: string; readonly rank: number; readonly stacksConsumed: number; } function partyShamanMaelstromCast( member: PartyMember, ability: AbilityDefinition, now: number, ): PartyShamanMaelstromCast | null { if (member.classId !== "shaman" || !WOW335_SHAMAN_MAELSTROM_WEAPON_ELIGIBLE_SPELLS.has(ability.name)) return null; const rank = Math.min(3, Math.max(0, Math.trunc(member.talentRanks["talent-2057"] ?? 0))); const profile = wow335ShamanMaelstromWeaponProfile(rank); if (!profile) return null; const aura = useCombatStore.getState().auras.find((candidate) => ( candidate.sourceId === member.id && candidate.targetId === member.id && candidate.stacks > 0 && candidate.definition.utilityTags?.includes(WOW335_SHAMAN_MAELSTROM_WEAPON_AURA_TAG) && (candidate.expiresAt === null || candidate.expiresAt > now) )); if (!aura) return null; const reductionPercent = wow335ShamanMaelstromWeaponCastTimeReductionPercent(profile, aura.stacks); return { ability: { ...ability, castTimeMs: Math.max(0, Math.round(ability.castTimeMs * (1 - reductionPercent / 100))), }, auraInstanceId: aura.instanceId, rank, stacksConsumed: Math.min(aura.stacks, profile.maxStacks), }; } function consumePartyShamanMaelstromWeapon(memberId: string, auraInstanceId: string): void { useCombatStore.setState((state) => ({ auras: state.auras.filter((aura) => aura.instanceId !== auraInstanceId || aura.sourceId !== memberId || aura.targetId !== memberId), })); } function maybeProcPartyShamanMaelstromWeapon( member: PartyMember, targetId: string, abilityId: string, weaponHand: "main-hand" | "off-hand", weaponSpeedMs: number, now: number, ): void { if (member.classId !== "shaman") return; const rank = Math.min(3, Math.max(0, Math.trunc(member.talentRanks["talent-2057"] ?? 0))); const profile = wow335ShamanMaelstromWeaponProfile(rank, weaponSpeedMs); if (!profile || stablePartyUnit( `${member.id}:${abilityId}:${targetId}:${now}:${weaponHand}:wow335-shaman-maelstrom-weapon`, ) >= profile.procChancePerWeaponHit) return; useCombatStore.setState((state) => ({ auras: [...applyCombatAura(state.auras, { definition: { id: "wow335:shaman:maelstrom-weapon", name: "Maelstrom Weapon", description: "Your next listed spell has its cast time reduced by 20% per stack.", disposition: "buff", durationMs: profile.durationMs, maxStacks: Math.min(WOW335_SHAMAN_MAELSTROM_WEAPON_MAX_STACKS, profile.maxStacks), initialStacks: 1, stackBehavior: "add", utilityTags: [WOW335_SHAMAN_MAELSTROM_WEAPON_AURA_TAG], }, sourceId: member.id, targetId: member.id, now, }).auras], })); } function consumePartyShamanTidalWaves(memberId: string, auraInstanceId: string): void { useCombatStore.setState((state) => ({ auras: state.auras.flatMap((aura) => { if (aura.instanceId !== auraInstanceId || aura.sourceId !== memberId || aura.targetId !== memberId) return [aura]; const remaining = Math.max(0, aura.stacks - 1); return remaining > 0 ? [{ ...aura, stacks: remaining }] : []; }), })); } function partyShamanElementalFocusAuraForCast( member: PartyMember, ability: AbilityDefinition, now: number, ) { if (member.classId !== "shaman" || ability.source === "runewaker" || ability.passive || !wow335ShamanElementalFocusProfileForRank(member.talentRanks["talent-574"] ?? 0) || !(ability.costs?.length ? ability.costs : [ability.cost]).some((cost) => cost.resource === "mana") || !["fire", "frost", "nature"].includes(abilityDamageSchool(member.classId, ability))) return null; return useCombatStore.getState().auras.find((aura) => ( aura.sourceId === member.id && aura.targetId === member.id && aura.stacks > 0 && aura.definition.utilityTags?.includes(WOW335_SHAMAN_ELEMENTAL_FOCUS_AURA_TAG) && (aura.expiresAt === null || aura.expiresAt > now) )) ?? null; } function consumePartyShamanElementalFocus(memberId: string, auraInstanceId: string): void { useCombatStore.setState((state) => ({ auras: state.auras.flatMap((aura) => { if (aura.instanceId !== auraInstanceId || aura.sourceId !== memberId || aura.targetId !== memberId) return [aura]; const remaining = Math.max(0, aura.stacks - 1); return remaining > 0 ? [{ ...aura, stacks: remaining }] : []; }), })); } function maybeProcPartyShamanElementalFocus( member: PartyMember, ability: AbilityDefinition, school: string, critical: boolean, periodic: boolean, targetId: string, now: number, ): void { if (member.classId !== "shaman" || ability.source === "runewaker") return; const profile = wow335ShamanElementalFocusProfileForRank(member.talentRanks["talent-574"] ?? 0); if (!wow335ShamanElementalFocusShouldProc( profile, school, critical, periodic, stablePartyUnit(`${member.id}:${ability.id}:${targetId}:${now}:wow335-shaman-elemental-focus`), ) || !profile) return; useCombatStore.setState((state) => ({ auras: [...applyCombatAura(state.auras, { definition: { id: "wow335:shaman:elemental-focus-clearcasting", name: "Clearcasting", description: `Reduces the mana cost of your next two Fire, Frost, or Nature spells and abilities by ${profile.costReductionPercent}%.`, disposition: "buff", durationMs: profile.durationMs, maxStacks: profile.charges, initialStacks: profile.charges, stackBehavior: "replace", utilityTags: [WOW335_SHAMAN_ELEMENTAL_FOCUS_AURA_TAG], modifiers: [{ kind: "resource", resource: "mana", property: "cost", operation: "percent", value: -profile.costReductionPercent / 100, perStack: false, }], }, sourceId: member.id, targetId: member.id, now, }).auras], })); } function partyShamanTidalWavesBonusHealing(member: PartyMember, ability: AbilityDefinition, now: number): number { if (member.classId !== "shaman" || ability.source === "runewaker" || !["Healing Wave", "Lesser Healing Wave"].includes(ability.name)) return 0; const rank = member.talentRanks["talent-2063"] ?? 0; const bonusPercent = wow335ShamanTidalWavesBonusHealingPercent(rank); if (bonusPercent <= 0) return 0; const auras = useCombatStore.getState().auras.filter((aura) => ( aura.targetId === member.id && (aura.expiresAt === null || aura.expiresAt > now) )); const resolvedBonusHealing = resolveAuraStatValue(member.gearStats.spellPower, auras, "spell-power"); return Math.round(Math.max(0, resolvedBonusHealing) * bonusPercent); } function partyShamanRiptideHotOnTarget(memberId: string, targetId: string, now: number) { return useCombatStore.getState().effects.find((effect) => effect.kind === "hot" && effect.sourceActorId === memberId && effect.sourceAbilityId === PARTY_SHAMAN_RIPTIDE_ABILITY_ID && effect.targetId === targetId && effect.remainingTicks > 0 && effect.nextTickAt + Math.max(0, effect.remainingTicks - 1) * effect.intervalMs > now); } function partySwiftmendHotOnTarget(memberId: string, targetId: string, now: number) { const candidates = useCombatStore.getState().effects.filter((effect) => { if (effect.kind !== "hot" || (effect.targetId ?? PLAYER_AGGRO_ID) !== targetId || effect.remainingTicks <= 0 || effect.nextTickAt + Math.max(0, effect.remainingTicks - 1) * effect.intervalMs <= now) return false; const name = abilityById(effect.sourceAbilityId)?.name; return name === "Rejuvenation" || name === "Regrowth"; }); const own = candidates.filter((effect) => effect.sourceActorId === memberId); const pool = own.length ? own : candidates; return pool.reduce((shortest, candidate) => { if (!shortest) return candidate; const expiry = candidate.nextTickAt + Math.max(0, candidate.remainingTicks - 1) * candidate.intervalMs; const shortestExpiry = shortest.nextTickAt + Math.max(0, shortest.remainingTicks - 1) * shortest.intervalMs; return expiry < shortestExpiry ? candidate : shortest; }, undefined); } function maybeProcPartyShamanImprovedWaterShield( member: PartyMember, ability: AbilityDefinition, targetId: string, school: CombatPresentationSchool, critical: boolean, now: number, ): PartyMember { if (member.classId !== "shaman" || ability.source === "runewaker" || !wow335ShamanImprovedWaterShieldShouldProc( member.talentRanks["talent-583"] ?? 0, ability.name, school, critical, stablePartyUnit(`${member.id}:${ability.id}:${targetId}:${now}:wow335-shaman-improved-water-shield`), )) return member; const requested = wow335ShamanWaterShieldOrbManaAtLevel( member.level, member.talentRanks["talent-607"] ?? 0, ); if (requested <= 0) return member; const combat = useCombatStore.getState(); const restored = combat.restoreActorResource( member.id, "mana", requested, false, member.id, "wow335-shaman-improved-water-shield", now, ); if (restored <= 0) return member; const mirrored = memberFromCompanionActor(member, useCombatStore.getState().actors[member.id]); return { ...member, ...(member.resourceType === "mana" ? { resource: mirrored.resource, maxResource: mirrored.maxResource } : {}), resourcePools: { ...member.resourcePools, mana: mirrored.resourcePools.mana }, lastResourceSpentAt: { ...member.lastResourceSpentAt, mana: mirrored.lastResourceSpentAt.mana }, }; } function applyPartyDeathKnightBladeBarrier( memberBeforeUse: PartyMember, memberAfterUse: PartyMember, ability: AbilityDefinition, now: number, ): void { if (memberBeforeUse.classId !== "death-knight") return; const rank = memberBeforeUse.talentRanks[WOW335_DEATH_KNIGHT_BLADE_BARRIER_TALENT_ID] ?? 0; if (!wow335DeathKnightBladeBarrierShouldActivate(rank, memberBeforeUse.runes, memberAfterUse.runes, now)) return; const profile = wow335DeathKnightBladeBarrierProfile(rank); const aura = wow335DeathKnightBladeBarrierAuraDefinition(rank); if (!profile || !aura) return; useCombatStore.getState().applyActorAura(memberBeforeUse.id, memberBeforeUse.id, ability, { kind: "apply-aura", aura, recipient: "caster", sourceEffectType: 6, sourceAuraType: 42, miscValue: 0, triggerSpellId: profile.triggerSpellId, }, now); } function applyPartyRomChampionRunePulseOnUse( member: PartyMember, ability: AbilityDefinition, now: number, ): void { const combat = useCombatStore.getState(); const effect = romChampionRunePulseUseEffect(ability, combat.auras, member.id, now); if (effect) combat.applyActorAura(member.id, member.id, ability, effect, now); } function maybeProcPartyShamanTidalWaves( member: PartyMember, ability: AbilityDefinition, rawHealingAmount: number, now: number, ): void { if (member.classId !== "shaman" || ability.source === "runewaker" || rawHealingAmount <= 0) return; const charges = wow335ShamanTidalWavesChargesForTrigger(ability.name); const chance = wow335ShamanTidalWavesProcChance(member.talentRanks["talent-2063"] ?? 0); if (charges <= 0 || chance <= 0 || stablePartyUnit(`${member.id}:${ability.id}:${now}:wow335-shaman-tidal-waves`) >= chance) return; useCombatStore.getState().applyActorAura(member.id, member.id, ability, { kind: "apply-aura", recipient: "caster", sourceEffectType: -1, sourceAuraType: -1, miscValue: 0, triggerSpellId: 0, aura: { id: "wow335:shaman:tidal-waves", name: "Tidal Waves", description: "Charges reduce Healing Wave cast time or increase Lesser Healing Wave critical chance.", disposition: "buff", durationMs: WOW335_SHAMAN_TIDAL_WAVES_DURATION_MS, maxStacks: 3, initialStacks: charges, stackBehavior: "add", utilityTags: [WOW335_SHAMAN_TIDAL_WAVES_AURA_TAG], }, }, now); } function grantPartyImprovedKickCapstone(member: PartyMember, ability: AbilityDefinition, now: number): PartyMember { if (member.classId !== "rogue" || ability.name !== "Kick") return member; const effect = wow335RogueImprovedKickAuraEffectForRank( member.talentRanks[WOW335_ROGUE_IMPROVED_KICK_TALENT_ID] ?? 0, ); if (!effect) return member; const combat = useCombatStore.getState(); combat.applyActorAura(member.id, member.id, ability, effect, now); combat.restoreActorResource(member.id, "energy", 40, false, member.id, ability.id, now); const refreshed = usePartyStore.getState().members.find((candidate) => candidate.id === member.id); return refreshed ? { ...member, resource: refreshed.resource, resourcePools: refreshed.resourcePools, lastResourceSpentAt: refreshed.lastResourceSpentAt, } : member; } function partyRogueImprovedKickSilenceDurationMs( member: PartyMember, ability: AbilityDefinition, targetId: string, now: number, ): number { if (member.classId !== "rogue" || ability.name !== "Kick") return 0; return wow335RogueImprovedKickSilenceDurationMs( member.talentRanks[WOW335_ROGUE_IMPROVED_KICK_TALENT_ID] ?? 0, stablePartyUnit(`${member.id}:${ability.id}:${targetId}:${now}:wow335-rogue-improved-kick-silence`), ); } const WOW335_MAGE_ARCANE_CONCENTRATION_TALENT_ID = "talent-75"; function partyMageArcanePotencyBonus( member: PartyMember, ability: AbilityDefinition, auraActive: boolean, ): number { if (member.classId !== "mage") return 0; const damagingSpell = presentationSchoolForAbility(ability) !== "physical" && ability.effects.some((effect) => ["damage", "dot", "finisher-damage"].includes(effect.kind)); return wow335MageArcanePotencyCastProfile( member.talentRanks[WOW335_MAGE_ARCANE_POTENCY_TALENT_ID] ?? 0, auraActive, damagingSpell, ).criticalChanceBonusPercent; } function partyMageNetherwindPresenceHastePercent(member: PartyMember, now: number): number { return member.classId === "mage" ? wow335MageNetherwindPresenceHastePercent(useCombatStore.getState().auras, member.id, now) : 0; } function partyCastingHastePercent(member: PartyMember, now: number): number { const auras = useCombatStore.getState().auras; return partyMageNetherwindPresenceHastePercent(member, now) + romDruidMageInspirationHastePercent(auras, member.id, now); } function applyPartyMageNetherwindPresenceCast( member: PartyMember, ability: AbilityDefinition, now: number, ): void { if (member.classId !== "mage") return; const current = useCombatStore.getState().auras; const next = wow335MageNetherwindPresenceAurasAfterCast( current, member.id, member.id, member.talentRanks[WOW335_MAGE_NETHERWIND_PRESENCE_TALENT_ID] ?? 0, ability.name, now, ); if (next !== current) useCombatStore.setState({ auras: [...next] }); } function partyCastTimeMsWithHaste( ability: AbilityDefinition, now: number, hastePercent: number, ): number { return partyDurationMsWithHaste(ability.castTimeMs, now, hastePercent); } function partyDurationMsWithHaste( baseDurationMs: number, now: number, hastePercent: number, ): number { return Math.round(baseDurationMs / (raceAffixHasteMultiplier(now) * (1 + Math.max(0, hastePercent) / 100))); } function consumePartyMageCastAuras(member: PartyMember, ability: AbilityDefinition): void { if (member.classId !== "mage" || ability.passive) return; useCombatStore.setState((state) => ({ auras: state.auras.filter((aura) => !(aura.targetId === member.id && (aura.definition.utilityTags?.includes(WOW335_MAGE_ARCANE_CONCENTRATION_TAG) || aura.definition.utilityTags?.includes(WOW335_MAGE_ARCANE_POTENCY_TAG)))), })); } function consumePartyHunterLockAndLoad(memberId: string): void { const aura = useCombatStore.getState().auras.find((candidate) => candidate.targetId === memberId && candidate.stacks > 0 && candidate.definition.utilityTags?.includes(WOW335_HUNTER_LOCK_AND_LOAD_READY_TAG)); if (!aura) return; useCombatStore.setState((state) => ({ auras: [...consumeLockAndLoadCharge(state.auras, aura.instanceId)], })); } function applyPartyMageArcanePotency( member: PartyMember, ability: AbilityDefinition, trigger: "presence-of-mind" | "arcane-concentration-clearcasting", now: number, ): void { if (member.classId !== "mage" || !wow335MageArcanePotencyCanProc(member.talentRanks[WOW335_MAGE_ARCANE_POTENCY_TALENT_ID] ?? 0, trigger)) return; useCombatStore.getState().applyActorAura(member.id, member.id, ability, { kind: "apply-aura", recipient: "caster", sourceEffectType: -1, sourceAuraType: -1, miscValue: 0, triggerSpellId: 0, aura: WOW335_MAGE_ARCANE_POTENCY_AURA, }, now); } function maybeProcPartyMageArcaneConcentration( member: PartyMember, ability: AbilityDefinition, targetId: string, now: number, ): void { if (member.classId !== "mage" || ability.source === "runewaker" || presentationSchoolForAbility(ability) === "physical") return; const rank = Math.min(3, Math.max(0, Math.trunc( member.talentRanks[WOW335_MAGE_ARCANE_CONCENTRATION_TALENT_ID] ?? 0, ))); const chance = rank * 0.05; if (chance <= 0 || stablePartyUnit( `${member.id}:${ability.id}:${targetId}:${now}:wow335-mage-arcane-concentration`, ) >= chance) return; useCombatStore.getState().applyActorAura(member.id, member.id, ability, { kind: "apply-aura", recipient: "caster", sourceEffectType: -1, sourceAuraType: -1, miscValue: 0, triggerSpellId: 0, aura: WOW335_MAGE_ARCANE_CONCENTRATION_AURA, }, now); applyPartyMageArcanePotency(member, ability, "arcane-concentration-clearcasting", now); } interface PartyWarlockBackdraftCast { readonly ability: AbilityDefinition; readonly auraInstanceId: string; readonly castTimeMs: number; readonly gcdMs: number; } function partyWarlockBackdraftCast( member: PartyMember, ability: AbilityDefinition, now: number, ): PartyWarlockBackdraftCast | null { if (member.classId !== "warlock" || !warlockBackdraftDestructionSpellEligible(ability.name)) return null; const rank = Math.min(3, Math.max(0, Math.trunc(member.talentRanks["talent-1888"] ?? 0))); const effects = warlockBackdraftEffectsForRank(rank); if (effects.rank <= 0) return null; const aura = useCombatStore.getState().auras.find((candidate) => ( candidate.sourceId === member.id && candidate.targetId === member.id && candidate.stacks > 0 && candidate.definition.utilityTags?.includes(WARLOCK_BACKDRAFT_READY_TAG) && (candidate.expiresAt === null || candidate.expiresAt > now) )); if (!aura) return null; const haste = raceAffixHasteMultiplier(now); const reduction = effects.reductionPercent / 100; const castTimeMs = Math.max(0, Math.round(Math.round(ability.castTimeMs / haste) * (1 - reduction))); const gcdMs = ability.gcdMs > 0 ? Math.max(1_000, Math.round(Math.round(ability.gcdMs / haste) * (1 - reduction))) : 0; return { ability: { ...ability, castTimeMs, gcdMs }, auraInstanceId: aura.instanceId, castTimeMs, gcdMs, }; } function partyScoutPriestSniperAbility(member: PartyMember, ability: AbilityDefinition): AbilityDefinition { if (member.classId !== "rom-scout" || member.secondaryClassId !== "rom-priest" || ability.source !== "runewaker" || ability.dbcSpellId !== 490450) return ability; const passiveSource = abilitiesForCharacter(member.classId, member.secondaryClassId).find((candidate) => ( candidate.source === "runewaker" && candidate.classId === "rom-scout" && candidate.secondaryClassId === "rom-priest" && candidate.passive && candidate.dbcSpellId === 492344 )); if (!passiveSource || !learnedAbilityForBuild(passiveSource, member.level, member.talentRanks)) return ability; return { ...ability, castTimeMs: Math.max(0, ability.castTimeMs - 1_000) }; } function consumePartyWarlockBackdraftCharge(memberId: string, auraInstanceId: string): void { useCombatStore.setState((state) => ({ auras: state.auras.flatMap((aura) => { if (aura.instanceId !== auraInstanceId || aura.sourceId !== memberId || aura.targetId !== memberId) return [aura]; return aura.stacks > 1 ? [{ ...aura, stacks: aura.stacks - 1 }] : []; }), })); } function applyPartyWarlockBackdraft(member: PartyMember, ability: AbilityDefinition, now: number): void { if (member.classId !== "warlock" || !warlockBackdraftTriggerEligible(ability.name)) return; const rank = Math.min(3, Math.max(0, Math.trunc(member.talentRanks["talent-1888"] ?? 0))); const effects = warlockBackdraftEffectsForRank(rank); if (effects.rank <= 0) return; useCombatStore.getState().applyActorAura(member.id, member.id, ability, { kind: "apply-aura", recipient: "caster", sourceEffectType: -1, sourceAuraType: -1, miscValue: 0, triggerSpellId: 0, aura: { id: "wow335:warlock:backdraft-ready", name: "Backdraft", description: "Your next three Destruction spells cast within 15 seconds have reduced cast time and global cooldown.", disposition: "buff", durationMs: effects.durationMs, maxStacks: effects.charges, initialStacks: effects.charges, stackBehavior: "replace", utilityTags: [WARLOCK_BACKDRAFT_READY_TAG], }, }, now); } function partyAbilityAmountWithHolyBlessing( member: PartyMember, ability: AbilityDefinition, effect: AbilityEffect, poisonDoses = 0, now = Date.now(), nativeTargetContext?: import("./romNativeCombatAmount").RomNativeDamageTargetContext, ): number { const state = useCombatStore.getState(); const base = deriveCharacterStats( member.classId, member.level, member.gearStats, member.raceId, {}, member.secondaryClassId, member.gender, member.statRuleset ?? "wow", ); const auras = state.auras.filter((aura) => aura.targetId === member.id && (aura.expiresAt === null || aura.expiresAt > now)); const intellect = ["intellect", "all-primary", "all-primary-attributes"].reduce( (value, stat) => resolveAuraStatValue(value, auras, stat), base.intellect, ); if (member.statRuleset === "rom") { return partyAbilityAmount(member, ability, effect, poisonDoses, intellect, auraAdjustedCharacterStats(base, state.auras, member.id, now).romNative, nativeTargetContext); } if (member.classId !== "rom-priest") { return partyAbilityAmount(member, ability, effect, poisonDoses, intellect); } const bonus = romPriestHolyBlessingStats(base, auras).spellPowerBonus; if (bonus <= 0) return partyAbilityAmount(member, ability, effect, poisonDoses, intellect); return partyAbilityAmount({ ...member, gearStats: { ...member.gearStats, spellPower: member.gearStats.spellPower + bonus }, }, ability, effect, poisonDoses, intellect); } function partyWarlockAbilityAmount( member: PartyMember, ability: AbilityDefinition, effect: AbilityEffect, poisonDoses = 0, targetId?: string, now = Date.now(), nativeTargetContext?: import("./romNativeCombatAmount").RomNativeDamageTargetContext, ): number { const amount = partyAbilityAmountWithHolyBlessing(member, ability, effect, poisonDoses, now, nativeTargetContext); if (member.classId === "warlock" && ability.name === "Conflagrate" && effect.kind === "damage") { const conflagrateDot = targetId ? activePartyWarlockConflagrateDot(useCombatStore.getState().effects, member.id, targetId, now) : null; const sourceAbility = conflagrateDot ? abilityById(conflagrateDot.sourceAbilityId) : null; if (!conflagrateDot || !sourceAbility) return 0; const burstSeconds = sourceAbility.name === "Immolate" ? 12 : 8; const tickAmount = conflagrateDot.fixedAmount ?? equipmentAdjustedCombatAmount( conflagrateDot, member.level, member.gearStats, member.classId, sourceAbility, 0, undefined, member.raceId, member.gender, member.statRuleset ?? "wow", member.secondaryClassId, ); return Math.round(tickAmount * burstSeconds * 1_000 / Math.max(1, conflagrateDot.intervalMs)) + (effect.bonusFlat ?? 0); } if (member.classId !== "warlock" || ability.name !== "Shadowburn" || effect.kind !== "damage") return amount; const rank = Math.min(3, Math.max(0, Math.trunc(member.talentRanks["talent-1888"] ?? 0))); return Math.round(amount * (1 + warlockBackdraftEffectsForRank(rank).shadowburnDamagePercent / 100)); } function activePartyWarlockConflagrateDot( effects: readonly TimedCombatEffect[], sourceActorId: string, targetId: string, now: number, ): TimedCombatEffect | null { const active = effects.filter((effect) => { if (effect.kind !== "dot" || effect.targetId !== targetId || effect.sourceActorId !== sourceActorId || effect.remainingTicks <= 0 || effect.nextTickAt + (effect.remainingTicks - 1) * effect.intervalMs < now) { return false; } const name = abilityById(effect.sourceAbilityId)?.name; return name === "Immolate" || name === "Shadowflame"; }); return active.find((effect) => abilityById(effect.sourceAbilityId)?.name === "Immolate") ?? active.find((effect) => abilityById(effect.sourceAbilityId)?.name === "Shadowflame") ?? null; } let lastAdvanceAt = 0; let nextRegenAt = 0; const nextWeaponAttackAt = new Map(); const nextShamanWeaponAttackAt = new Map(); const nextRogueOffhandAttackAt = new Map(); const pendingInnerFocus = new Set(); export interface PartyPresentationIdentity { readonly spellId: number; readonly name: string; readonly school: CombatPresentationSchool; readonly delivery: CombatPresentationDelivery; } function partyAttackPresentation(member: PartyMember): PartyPresentationIdentity { if (!partyUsesRangedAttacks(member)) { const spellId = member.classId === "rogue" ? 1752 : member.classId === "death-knight" ? 45462 : member.classId === "paladin" ? 20271 : 78; return { spellId, name: "Weapon Attack", school: "physical", delivery: "melee" }; } if (member.classId === "hunter") return { spellId: 56641, name: "Steady Shot", school: "physical", delivery: "projectile" }; if (member.classId === "mage") return { spellId: 116, name: "Frostbolt", school: "frost", delivery: "projectile" }; if (member.classId === "warlock") return { spellId: 686, name: "Shadow Bolt", school: "shadow", delivery: "projectile" }; if (member.classId === "priest") return { spellId: 585, name: "Smite", school: "holy", delivery: "projectile" }; if (member.classId === "druid") return { spellId: 5176, name: "Wrath", school: "nature", delivery: "projectile" }; return { spellId: 403, name: "Lightning Bolt", school: "nature", delivery: "projectile" }; } function partyHealPresentation(member: PartyMember): PartyPresentationIdentity { if (member.classId === "paladin") return { spellId: 635, name: "Holy Light", school: "holy", delivery: "projectile" }; if (member.classId === "shaman") return { spellId: 331, name: "Healing Wave", school: "nature", delivery: "projectile" }; if (member.classId === "druid") return { spellId: 5185, name: "Healing Touch", school: "nature", delivery: "projectile" }; return { spellId: 2061, name: "Flash Heal", school: "holy", delivery: "projectile" }; } function partyTauntSpellId(member: PartyMember): number { if (member.classId === "paladin") return 62124; if (member.classId === "death-knight") return 56222; if (member.classId === "druid") return 6795; return 355; } export interface PartyBossObjective { readonly id: string; readonly name: string; readonly position: MobVector3; } export function activePartyBossObjectives(): readonly PartyBossObjective[] { if (useGameStore.getState().gameMode === "manastorm") { const encounter = useManastormStore.getState().currentEncounter; return encounter ? [{ id: encounter.bossRuntimeId, name: encounter.bossName, position: encounter.bossPosition, }] : []; } const game = useGameStore.getState(); const simulation = useDungeonSimulationStore.getState(); if ( simulation.sessionMode === "spectate-live" && simulation.mode === "navigation-only" && simulation.objectiveId ) { const objective = requireDungeonDefinition(game.activeDungeonId).objectiveOrder .find((candidate) => candidate.id === simulation.objectiveId); return objective ? [objective] : []; } const completed = new Set(activeDungeonCompletedObjectiveIds(game.activeDungeonId)); const mobs = useCombatStore.getState().mobs; const objectives = requireDungeonDefinition(game.activeDungeonId).objectiveOrder .filter((objective) => !objective.optional && !completed.has(objective.id)); const wailingPhase = game.activeDungeonId === "wailing-caverns" ? useWailingEncounterStore.getState().phase : null; return objectives.filter((objective) => ( !objective.eventOnly || Boolean(mobs[objective.id]) || (objective.id === "mutanus-spawn" && (wailingPhase === "mutanus" || wailingPhase === "complete")) )); } function simulatedPartyExcludesPlayer(): boolean { return useDungeonSimulationStore.getState().sessionMode !== "play"; } export function resetPartyRuntimeClock(): void { lastAdvanceAt = 0; nextRegenAt = 0; nextWeaponAttackAt.clear(); nextShamanWeaponAttackAt.clear(); nextRogueOffhandAttackAt.clear(); pendingInnerFocus.clear(); } export function activePartyTargetId( mobs: Readonly>, selectedTargetId: string | null, ): string | null { const selected = selectedTargetId ? mobs[selectedTargetId] : null; if (selected?.engaged && !selected.dead) return selected.id; return Object.values(mobs) .filter((mob) => mob.engaged && !mob.dead) .sort((left, right) => Number(right.boss) - Number(left.boss) || left.id.localeCompare(right.id))[0]?.id ?? null; } export function partyAttackDamage( member: Pick & Partial>, now = Date.now(), ): number { const coefficient = member.role === "tank" ? 0.85 : member.role === "healer" ? 0.55 : 1.25; const gear = member.gearStats; if (!gear) return Math.max(1, Math.round(3 + member.level * coefficient)); const classId = member.classId ?? "warrior"; const kind: WotlkAttackKind = partyUsesRangedAttacks({ classId, specialization: member.specialization ?? "", role: member.role, }) ? baseClassFor(classId) === "hunter" ? "ranged" : "spell" : "melee"; const nativeStats = member.statRuleset === "rom" && isRomClassId(classId) ? auraAdjustedCharacterStats(deriveCharacterStats( classId, member.level, gear, member.raceId ?? "rom-human", {}, member.secondaryClassId, member.gender ?? "male", "rom", ), useCombatStore.getState().auras, member.id ?? "", now).romNative : undefined; const nativeAuto = kind !== "spell" ? nativeTrainingAutoAttackProfile(nativeStats, kind === "ranged" ? "ranged" : "main-hand") : undefined; if (nativeAuto) return Math.max(1, Math.round(nativeAuto.damage)); const weapon = kind === "ranged" ? gear.rangedWeapon : kind === "melee" ? gear.meleeWeapon : null; const equipmentPower = nativeStats ? kind === "spell" ? nativeStats.magicalDamage : nativeStats.physicalAttack / 14 * ((weapon?.speedMs ?? 2400) / 1_000) : kind === "spell" ? equipmentAttackPower(classId, gear, kind) : equipmentAttackPower(classId, gear, kind) / 14 * ((weapon?.speedMs ?? 2400) / 1_000) + (weapon ? (weapon.damageMin + weapon.damageMax) / 2 * 0.35 : 0); const crit = nativeStats ? 0 : ratingPercent( gear.criticalStrikeRating, kind === "spell" ? "spellCrit" : kind === "ranged" ? "rangedCrit" : "meleeCrit", member.level, classId, ); const hit = nativeStats ? 0 : ratingPercent( gear.hitRating, kind === "spell" ? "spellHit" : kind === "ranged" ? "rangedHit" : "meleeHit", member.level, classId, ); return Math.max( 1, Math.round((3 + member.level * coefficient + equipmentPower) * (1 + crit / 100) * (1 + hit / 100)), ); } export function partyHunterPetRecipientIds( summons: readonly Pick[], ownerActorId: string, now: number, ): string[] { return summons.filter((summon) => ( summon.ownerActorId === ownerActorId && summon.kind === "summon" && summon.health > 0 && summon.expiresAt > now && abilityById(summon.sourceAbilityId)?.effects.some((effect) => ( effect.kind === "pet" && ["summon", "call", "resurrect", "tame"].includes(effect.action) )) )).map((summon) => summon.id); } export function partyUsesRangedAttacks( member: Pick & Partial>, ): boolean { if (member.role === "tank") return false; if (member.role === "healer") return true; const definition = classById(member.classId); if (definition.mode !== "classic") { return (baseClassFor(member.classId) === "hunter" || ["intellect", "wisdom", "spirit"].includes(definition.primaryStat)) && partyCatalogAttackRange(member) > 5; } return ["hunter", "mage", "priest", "warlock"].includes(member.classId) || member.specialization === "Elemental" || member.specialization === "Balance"; } function partyCatalogAttackRange(member: Pick & Partial>): number { return Math.max(0, ...abilitiesForClass(member.classId) .filter((ability) => !ability.passive) .map((ability) => learnedAbilityForBuild(ability, member.level ?? 80, member.talentRanks ?? {})) .filter((ability): ability is AbilityDefinition => ability !== null) .filter((ability) => ability.effects.some((effect) => ["damage", "dot", "finisher-damage", "execute"].includes(effect.kind))) .map((ability) => ability.range.max)); } export function partyAttackRange(member: Pick): number { if (member.role === "tank") return 3.8; if (partyUsesRangedAttacks(member)) return 19; return 4.2; } export interface PartyCombatRangeBand { readonly minimum: number; readonly preferred: number; readonly maximum: number; } const RANGED_COMBAT_RANGE_BAND: PartyCombatRangeBand = Object.freeze({ minimum: 10, preferred: 14.5, maximum: 18.1, }); const TANK_COMBAT_RANGE_BAND: PartyCombatRangeBand = Object.freeze({ minimum: 0, preferred: 2.9, maximum: 3.15, }); const MELEE_DAMAGE_COMBAT_RANGE_BAND: PartyCombatRangeBand = Object.freeze({ minimum: 0, preferred: 3.3, maximum: 3.55, }); /** * Movement uses a narrower band than damage validation. Ranged attackers keep * enough room to read as ranged while melee attackers stay close to a fleeing * target instead of treating the tank's position as their own. */ export function partyCombatRangeBand( member: Pick & Partial>, ): PartyCombatRangeBand { if (partyUsesRangedAttacks(member)) { if (classById(member.classId).mode !== "classic") { const attackRange = partyCatalogAttackRange(member); if (attackRange <= 5) return RANGED_COMBAT_RANGE_BAND; const maximum = Math.min(RANGED_COMBAT_RANGE_BAND.maximum, attackRange - 0.1); const preferred = Math.min(RANGED_COMBAT_RANGE_BAND.preferred, maximum - 0.5); return { minimum: Math.min(RANGED_COMBAT_RANGE_BAND.minimum, preferred - 0.5), preferred, maximum }; } return RANGED_COMBAT_RANGE_BAND; } return member.role === "tank" ? TANK_COMBAT_RANGE_BAND : MELEE_DAMAGE_COMBAT_RANGE_BAND; } export function nextPartyBossObjective( mobs: Readonly>, ): PartyBossObjective | null { return activePartyBossObjectives().find((objective) => !mobs[objective.id]?.dead) ?? null; } function distanceSquared(left: MobVector3, right: MobVector3): number { const dx = left[0] - right[0]; const dy = left[1] - right[1]; const dz = left[2] - right[2]; return dx * dx + dy * dy + dz * dz; } function livingTargetInRange( mob: MobCombatState | null | undefined, origin: MobVector3, maximumRange: number, requireAttackCorridor = false, ): mob is MobCombatState { if (!mob || mob.dead) return false; const position = getMobPosition(mob.id); return Boolean( position && distanceSquared(position, origin) <= maximumRange * maximumRange && (!requireAttackCorridor || partyRuntimePositionAlongAttackCorridor(position)), ); } export function partyCommandTargetId( command: PartyCommand, mobs: Readonly>, selectedTargetId: string | null, currentTargetId: string | null, origin: MobVector3, acquisitionRange = ATTACK_ACQUISITION_RANGE, ): string | null { if (command === "stop") return null; const engaged = activePartyTargetId(mobs, selectedTargetId); if (engaged) return engaged; if (command === "defend") return null; const selected = selectedTargetId ? mobs[selectedTargetId] : null; if (livingTargetInRange(selected, origin, acquisitionRange * 2, true)) return selected.id; const current = currentTargetId ? mobs[currentTargetId] : null; // currentTargetId was already authorized while it intersected the objective // corridor. Keep pursuing it while its mob-specific route is active. if (livingTargetInRange(current, origin, acquisitionRange * 1.5)) return current.id; return Object.values(mobs) .filter((mob) => livingTargetInRange(mob, origin, acquisitionRange, true)) .sort((left, right) => { // Clear adds before committing to the boss at the end of a pull. const kindOrder = Number(left.boss) - Number(right.boss); if (kindOrder) return kindOrder; const leftPosition = getMobPosition(left.id)!; const rightPosition = getMobPosition(right.id)!; return distanceSquared(leftPosition, origin) - distanceSquared(rightPosition, origin) || left.id.localeCompare(right.id); })[0]?.id ?? null; } export interface HealerSupportResult { readonly members: readonly PartyMember[]; readonly playerHealing: number; readonly playerResurrectionPercent: number; readonly targetId: string | null; readonly effectiveHealing: number; readonly sourceMemberId: string | null; readonly action: "heal" | "resurrection" | "dispel" | null; readonly presentation: PartyPresentationIdentity | null; readonly dispelCategories: readonly DispelCategory[]; readonly dispelMaximum: number; } function partyHealAmount(level: number, spellPower = 0): number { return Math.max(10, Math.round(12 + Math.max(1, level) * 2.6 + Math.max(0, spellPower) * 1.25)); } function partyResurrectionAbility(member: PartyMember): AbilityDefinition | null { return abilitiesForCharacter(member.classId, member.secondaryClassId) .filter((ability) => !ability.passive && (ability.target === "friendly" || ability.target === "any")) .map((ability) => learnedAbilityForBuild(ability, member.level, member.talentRanks)) .filter((ability): ability is AbilityDefinition => ability !== null) .find((ability) => ability.effects.some((effect) => effect.kind === "resurrection")) ?? null; } function partyResurrectionPresentation( member: PartyMember, ability: AbilityDefinition, ): PartyPresentationIdentity { const nature = member.classId === "druid" || member.classId === "shaman" || member.classId === "rom-druid"; return { spellId: ability.dbcSpellId, name: ability.name, school: nature ? "nature" : "holy", delivery: "projectile", }; } function noSupport(members: readonly PartyMember[]): HealerSupportResult { return { members, playerHealing: 0, playerResurrectionPercent: 0, targetId: null, effectiveHealing: 0, sourceMemberId: null, action: null, presentation: null, dispelCategories: [], dispelMaximum: 0, }; } function commitPartyAbilityUseWithRogueCooldown( member: PartyMember, ability: AbilityDefinition, now: number, options: Parameters[3] = {}, ): PartyMember | null { const naturePowerSpent = options.ignoreResourceCosts ? 0 : partyAbilityCosts(member, ability, now) .filter((cost) => cost.resource === "nature-power" && cost.amount > 0) .reduce((total, cost) => total + cost.amount, 0); const committed = commitPartyAbilityUse(member, ability, now, options); if (committed && naturePowerSpent > 0) applyPartyDruidMageInspirationSpend(member, naturePowerSpent, now); if (!committed || !romRogueEnergizedHandsActive( useCombatStore.getState().auras, member.id, now, )) return committed; const readyAt = committed.cooldowns[ability.id]; if (readyAt === undefined || readyAt <= now) return committed; const adjustedReadyAt = Math.max(now, readyAt - ROM_ROGUE_ENERGIZED_HANDS_COOLDOWN_REDUCTION_MS); return { ...committed, cooldowns: adjustedReadyAt > now ? { ...committed.cooldowns, [ability.id]: adjustedReadyAt } : Object.fromEntries(Object.entries(committed.cooldowns).filter(([id]) => id !== ability.id)), }; } function partyDruidMageInspirationLearned(member: PartyMember): boolean { if (!isRomDruidMagePair(member)) return false; const passive = abilitiesForCharacter(member.classId, member.secondaryClassId).find((candidate) => ( candidate.source === "runewaker" && candidate.classId === "rom-druid" && candidate.secondaryClassId === "rom-mage" && candidate.passive && candidate.dbcSpellId === ROM_DRUID_MAGE_INSPIRATION_PASSIVE_ID )); return Boolean(passive && learnedAbilityForBuild(passive, member.level, member.talentRanks)); } function applyPartyDruidMageInspirationSpend( member: PartyMember, spentNaturePower: number, now: number, ): void { if (!(spentNaturePower > 0) || !partyDruidMageInspirationLearned(member)) return; const combat = useCombatStore.getState(); const sourcePosition = getPartyRuntimePosition(member.id); const candidates = [ { id: member.id, position: sourcePosition, living: member.health > 0 }, { id: PLAYER_AURA_ENTITY_ID, position: combat.playerPosition, living: combat.health > 0, }, ...usePartyStore.getState().members.filter((candidate) => candidate.id !== member.id).map((candidate) => ({ id: candidate.id, position: getPartyRuntimePosition(candidate.id), living: candidate.health > 0, })), ]; // A successful caster spend always reaches the caster, even if a test or // out-of-scene party state has not registered a world position for it. const recipients = sourcePosition ? romDruidMageInspirationRecipientIds(sourcePosition, candidates) : member.health > 0 ? [member.id] : []; if (!recipients.length) return; useCombatStore.setState((state) => ({ auras: [...romDruidMageInspirationApplySpendStack(state.auras, member.id, recipients, now)], })); } /** * Gives the party one legal support action at a time. Any living class with an * unlocked resurrection can raise a fallen target; ordinary healing remains * the dedicated healer's job. The runtime caller applies player-side changes * to the combat store after this pure party-state update. */ export function applyHealerSupport( members: readonly PartyMember[], playerHealth: number, playerMaxHealth: number, now: number, auras: readonly ActiveAura[] = [], ): HealerSupportResult { const resurrector = members .map((member, index) => ({ member, index, ability: partyResurrectionAbility(member) })) .filter((candidate): candidate is typeof candidate & { ability: AbilityDefinition } => ( candidate.member.health > 0 && ( candidate.member.controlledUntil <= now || !enemyControlPreventsActions(candidate.member.controlMechanic) ) && candidate.member.nextActionAt <= now && candidate.ability !== null )) .sort((left, right) => ( Number(right.member.role === "healer") - Number(left.member.role === "healer") || left.member.id.localeCompare(right.member.id) ))[0]; const fallen = [ ...(playerHealth <= 0 && playerMaxHealth > 0 ? [{ id: "__player__", index: -1, maxHealth: playerMaxHealth, rolePriority: -1 }] : []), ...members .map((member, index) => ({ id: member.id, index, maxHealth: member.maxHealth, rolePriority: member.role === "healer" ? 0 : member.role === "tank" ? 1 : 2, })) .filter((candidate) => members[candidate.index].health <= 0), ].sort((left, right) => left.rolePriority - right.rolePriority || left.id.localeCompare(right.id)); const fallenTarget = fallen[0]; if (resurrector && fallenTarget) { const resurrection = resurrector.ability.effects.find((effect) => effect.kind === "resurrection"); if (resurrection?.kind === "resurrection") { const restored = Math.max(1, Math.round(fallenTarget.maxHealth * resurrection.percentMaxHealth)); const next = [...members]; if (fallenTarget.index >= 0) { const target = next[fallenTarget.index]; next[fallenTarget.index] = { ...target, health: restored, status: "ready", controlledUntil: 0, controlMechanic: null, }; } const caster = next[resurrector.index]; const committed = commitPartyAbilityUseWithRogueCooldown(caster, resurrector.ability, now) ?? caster; next[resurrector.index] = { ...committed, status: "healing", nextActionAt: now + Math.max( HEALER_ACTION_INTERVAL_MS, resurrector.ability.castTimeMs, resurrector.ability.gcdMs, ), }; return { members: next, playerHealing: 0, playerResurrectionPercent: fallenTarget.index < 0 ? resurrection.percentMaxHealth : 0, targetId: fallenTarget.id, effectiveHealing: restored, sourceMemberId: resurrector.member.id, action: "resurrection", presentation: partyResurrectionPresentation(resurrector.member, resurrector.ability), dispelCategories: [], dispelMaximum: 0, }; } } const healerIndex = members.findIndex((member) => member.role === "healer" && member.health > 0); if (healerIndex < 0) return noSupport(members); const healer = members[healerIndex]; if (now < healer.nextActionAt) return noSupport(members); const dispelTarget = [ ...(playerHealth > 0 ? [PLAYER_AGGRO_ID] : []), ...members.filter((member) => member.health > 0).map((member) => member.id), ].find((id) => auras.some((aura) => ( aura.targetId === id && aura.definition.disposition === "debuff" && aura.definition.dispelCategory ))); if (dispelTarget) { const dispelAbility = choosePartyAbility(healer, { now, target: null, allyNeedsDispel: true }); const dispel = dispelAbility?.effects.find((effect) => effect.kind === "dispel" && effect.relationship !== "hostile"); if (dispelAbility && dispel?.kind === "dispel") { const next = [...members]; const committed = commitPartyAbilityUseWithRogueCooldown(healer, dispelAbility, now) ?? healer; next[healerIndex] = { ...committed, status: "healing", nextActionAt: now + Math.max(HEALER_ACTION_INTERVAL_MS, dispelAbility.castTimeMs, dispelAbility.gcdMs), }; return { members: next, playerHealing: 0, playerResurrectionPercent: 0, targetId: dispelTarget, effectiveHealing: 0, sourceMemberId: healer.id, action: "dispel", presentation: { spellId: dispelAbility.dbcSpellId, name: dispelAbility.name, school: presentationSchoolForAbility(dispelAbility), delivery: presentationDeliveryForAbility(dispelAbility), }, dispelCategories: dispel.categories, dispelMaximum: dispel.maxCount, }; } } const candidates = members .map((member, index) => ({ id: member.id, index, health: member.health, maxHealth: member.maxHealth, })) .filter((candidate) => candidate.health > 0 && candidate.health < candidate.maxHealth); if (playerHealth > 0 && playerHealth < playerMaxHealth) { candidates.push({ id: "__player__", index: -1, health: playerHealth, maxHealth: playerMaxHealth }); } candidates.sort((left, right) => ( left.health / Math.max(1, left.maxHealth) - right.health / Math.max(1, right.maxHealth) || (right.maxHealth - right.health) - (left.maxHealth - left.health) || left.id.localeCompare(right.id) )); const target = candidates[0]; if (!target) { if (healer.status !== "healing" && healer.nextActionAt === 0) { return noSupport(members); } const next = [...members]; next[healerIndex] = { ...healer, status: "ready", nextActionAt: 0 }; return noSupport(next); } const healingAbility = choosePartyAbility(healer, { now, target: null, injuredAllies: candidates.length, lowestHealthFraction: target.health / Math.max(1, target.maxHealth), }); const healingEffect = healingAbility?.effects.find((effect) => effect.kind === "heal"); if (!healingAbility || !healingEffect || healingEffect.kind !== "heal") return noSupport(members); const requested = partyAbilityAmountWithHolyBlessing(healer, healingAbility, healingEffect, 0, now) + Math.round(target.maxHealth * (healingEffect.percentMaxHealth ?? 0)); const healed = Math.min(requested, target.maxHealth - target.health); const next = [...members]; if (target.index >= 0) { const currentTarget = next[target.index]; next[target.index] = { ...currentTarget, health: currentTarget.health + healed }; } const currentHealer = commitPartyAbilityUseWithRogueCooldown(next[healerIndex], healingAbility, now) ?? next[healerIndex]; next[healerIndex] = { ...currentHealer, status: "healing", nextActionAt: now + Math.max(HEALER_ACTION_INTERVAL_MS, healingAbility.castTimeMs, healingAbility.gcdMs), }; return { members: next, playerHealing: target.index < 0 ? healed : 0, playerResurrectionPercent: 0, targetId: target.id, effectiveHealing: healed, sourceMemberId: healer.id, action: "heal", presentation: { spellId: healingAbility.dbcSpellId, name: healingAbility.name, school: presentationSchoolForAbility(healingAbility), delivery: presentationDeliveryForAbility(healingAbility), }, dispelCategories: [], dispelMaximum: 0, }; } function actorRole(actorId: string | null, members: readonly PartyMember[]): PartyMember["role"] | null { if (!actorId) return null; if (actorId === PLAYER_AGGRO_ID) return usePartyStore.getState().playerRole; return members.find((member) => member.id === actorId)?.role ?? null; } function tankPriorityTargetId( mobs: Readonly>, members: readonly PartyMember[], tankId: string, tankPosition: MobVector3, now: number, fallbackTargetId: string | null, ): string | null { const victimPriority = (mob: MobCombatState) => { const role = actorRole(mob.targetActorId, members); return role === "healer" ? 0 : role === "damage" ? 1 : role === "tank" ? 2 : 0; }; const loose = Object.values(mobs) .filter((mob) => ( mob.engaged && !mob.dead && mob.targetActorId !== tankId && !(mob.forcedTarget && mob.forcedTarget.endsAt > now) && getMobPosition(mob.id) )) .sort((left, right) => { const victimOrder = victimPriority(left) - victimPriority(right); if (victimOrder) return victimOrder; const bossOrder = Number(right.boss) - Number(left.boss); if (bossOrder) return bossOrder; return distanceSquared(getMobPosition(left.id)!, tankPosition) - distanceSquared(getMobPosition(right.id)!, tankPosition) || left.id.localeCompare(right.id); }); return loose[0]?.id ?? fallbackTargetId; } function memberSwingMs(member: PartyMember, now: number): number { const native = member.statRuleset === "rom" ? auraAdjustedCharacterStats(deriveCharacterStats( member.classId, member.level, member.gearStats, member.raceId, {}, member.secondaryClassId, member.gender, "rom", ), useCombatStore.getState().auras, member.id, now).romNative : undefined; const physicalAuto = !partyUsesRangedAttacks(member) || baseClassFor(member.classId) === "hunter"; const nativeAuto = physicalAuto ? nativeTrainingAutoAttackProfile(native, member.classId === "rom-scout" ? "ranged" : "main-hand") : undefined; const base = member.role === "tank" ? 1_800 : member.classId === "rogue" || member.specialization === "Enhancement" || member.specialization === "Feral (Cat)" ? 1_550 : 1_900; const hasteStat = partyUsesRangedAttacks(member) ? baseClassFor(member.classId) === "hunter" ? "ranged-haste" : "spell-haste" : "melee-haste"; const hasteRating = member.statRuleset === "rom" ? 0 : ratingPercent( member.gearStats.hasteRating, partyUsesRangedAttacks(member) ? baseClassFor(member.classId) === "hunter" ? "rangedHaste" : "spellHaste" : "meleeHaste", member.level, member.classId, ); const memberAuras = useCombatStore.getState().auras.filter((aura) => ( aura.targetId === member.id && (aura.expiresAt === null || aura.expiresAt > now) )); const effectiveHaste = resolveAuraStatValue(hasteRating, memberAuras, hasteStat); if (nativeAuto) return Math.max(500, Math.round(hastedDuration(nativeAuto.intervalMs, effectiveHaste) / raceAffixHasteMultiplier(now))); return Math.round(hastedDuration( base, effectiveHaste, 500, ) / raceAffixHasteMultiplier()); } function partyShamanWeapon( member: PartyMember, hand: "main-hand" | "off-hand", ): WeaponStats | null { if (member.classId !== "shaman") return null; const combat = useCombatStore.getState(); return resolvedEquipmentLoadout(combat.equipment, combat.inventory, { ownerId: member.id, ownerKind: "party", classId: member.classId, secondaryClassId: member.secondaryClassId, specializationId: member.specializationId, role: member.role, level: member.level, })[hand]?.stats.meleeWeapon ?? null; } function partyRogueOffhandWeapon(member: PartyMember): WeaponStats | null { if (member.classId !== "rom-rogue") return null; const combat = useCombatStore.getState(); return resolvedEquipmentLoadout(combat.equipment, combat.inventory, { ownerId: member.id, ownerKind: "party", classId: member.classId, secondaryClassId: member.secondaryClassId, specializationId: member.specializationId, role: member.role, level: member.level, })["off-hand"]?.stats.meleeWeapon ?? null; } function partyRogueOffHandMasteryActive(member: PartyMember, now: number): boolean { if (member.classId !== "rom-rogue") return false; const passiveSource = abilitiesForCharacter(member.classId, member.secondaryClassId).find((candidate) => ( candidate.source === "runewaker" && candidate.passive && candidate.dbcSpellId === 490143 )); const passive = passiveSource ? learnedAbilityForBuild(passiveSource, member.level, member.talentRanks) : null; const effect = passive?.effects.find((candidate) => candidate.kind === "apply-aura" && candidate.recipient === "caster" && candidate.aura.utilityTags?.includes(ROM_ROGUE_OFF_HAND_MASTERY_TAG)); if (passive && effect?.kind === "apply-aura") { const active = useCombatStore.getState().auras.some((aura) => aura.sourceId === member.id && aura.targetId === member.id && aura.definition.utilityTags?.includes(ROM_ROGUE_OFF_HAND_MASTERY_TAG) && (aura.expiresAt === null || aura.expiresAt > now)); if (!active) useCombatStore.getState().applyActorAura(member.id, member.id, passive, effect, now); } return useCombatStore.getState().auras.some((aura) => aura.targetId === member.id && aura.definition.utilityTags?.includes(ROM_ROGUE_OFF_HAND_MASTERY_TAG) && (aura.expiresAt === null || aura.expiresAt > now)); } function partyRogueOffhandAttackProfile( member: PartyMember, weapon: WeaponStats, masteryActive: boolean, now: number, ): { readonly damage: number; readonly intervalMs: number } { const handGearStats = { ...member.gearStats, meleeWeapon: weapon }; const stats = deriveCharacterStats( member.classId, member.level, handGearStats, member.raceId, {}, member.secondaryClassId, member.gender, member.statRuleset ?? "wow", ); const auras = useCombatStore.getState().auras.filter((aura) => ( aura.targetId === member.id && (aura.expiresAt === null || aura.expiresAt > now) )); const nativeAuto = nativeTrainingAutoAttackProfile(auraAdjustedCharacterStats(stats, useCombatStore.getState().auras, member.id, now).romNative, "off-hand"); const meleeHastePercent = resolveAuraStatValue(stats.meleeHastePercent, auras, "melee-haste"); if (nativeAuto) return { damage: Math.max(1, Math.round(nativeAuto.damage * (masteryActive ? 1 + ROM_ROGUE_OFF_HAND_MASTERY_DAMAGE_PERCENT / 100 : 1))), intervalMs: Math.max(500, Math.round(hastedDuration(nativeAuto.intervalMs, meleeHastePercent) / raceAffixHasteMultiplier(now))) }; const attackPower = partyShamanAttackPower(stats.attackPower, auras); const fullWeaponDamage = (weapon.damageMin + weapon.damageMax) / 2 + attackPower / 14 * (weapon.speedMs / 1_000); const masteryMultiplier = masteryActive ? 1 + ROM_ROGUE_OFF_HAND_MASTERY_DAMAGE_PERCENT / 100 : 1; return { damage: Math.max(1, Math.round(fullWeaponDamage * 0.5 * masteryMultiplier)), intervalMs: Math.max(500, Math.round( hastedDuration(weapon.speedMs, meleeHastePercent) / raceAffixHasteMultiplier(now), )), }; } function applyPartyRomRoguePoisonOnAutoAttack( member: PartyMember, targetId: string, abilityId: string, now: number, ): void { if (member.classId !== "rom-rogue") return; const poisonSkill = abilitiesForCharacter(member.classId, member.secondaryClassId) .find((ability) => ability.dbcSpellId === ROM_ROGUE_POISON_SKILL_ID); const learnedPoison = poisonSkill && learnedAbilityForBuild(poisonSkill, member.level, member.talentRanks); const combat = useCombatStore.getState(); const poisonActive = combat.auras.some((aura) => ( aura.targetId === member.id && aura.definition.utilityTags?.includes(ROM_ROGUE_POISON_AURA_TAG) && (aura.expiresAt === null || aura.expiresAt > now) )); const currentSpeedAura = combat.auras.find((aura) => aura.sourceId === member.id && aura.targetId === member.id && aura.definition.utilityTags?.includes(ROM_ROGUE_POISON_ATTACK_SPEED_TAG)); const result = learnedPoison && !combat.mobs[targetId]?.dead ? romRoguePoisonAttackResultForAttack({ classId: member.classId, sourceActorId: member.id, targetActorId: targetId, attackKind: "melee", isNormalAttack: true, attackLanded: true, poisonActive, roll: stablePartyUnit(`${member.id}:${targetId}:${abilityId}:${now}:rom-rogue-poison`), attackSpeedTargetId: currentSpeedAura ? romRoguePoisonAttackSpeedTargetId(currentSpeedAura) : null, targetPoisonStacks: combat.auras.find((aura) => aura.sourceId === member.id && aura.targetId === targetId && aura.definition.utilityTags?.includes(ROM_ROGUE_POISONED_TARGET_TAG))?.stacks ?? 0, }) : null; if (result) { useCombatStore.setState((state) => ({ auras: (() => { let auras = result.resetAttackSpeed ? state.auras.filter((aura) => !(aura.sourceId === member.id && aura.targetId === member.id && aura.definition.utilityTags?.includes(ROM_ROGUE_POISON_ATTACK_SPEED_TAG))) : state.auras; if (result.application) auras = applyCombatAura(auras, { definition: result.application.definition, sourceId: result.application.sourceId, targetId: result.application.targetId, stacks: result.application.stacks, now, }).auras; if (result.attackSpeedApplication) auras = applyCombatAura(auras, { definition: result.attackSpeedApplication.definition, sourceId: result.attackSpeedApplication.sourceId, targetId: result.attackSpeedApplication.targetId, stacks: result.attackSpeedApplication.stacks, now, }).auras; return [...auras]; })(), })); } } function partyShamanWeaponAttackProfile( member: PartyMember, weapon: WeaponStats, hand: "main-hand" | "off-hand", now: number, ): { readonly damage: number; readonly intervalMs: number } { const handGearStats = { ...member.gearStats, meleeWeapon: weapon }; const stats = deriveCharacterStats( member.classId, member.level, handGearStats, member.raceId, {}, member.secondaryClassId, member.gender, member.statRuleset ?? "wow", ); const auras = useCombatStore.getState().auras.filter((aura) => ( aura.targetId === member.id && (aura.expiresAt === null || aura.expiresAt > now) )); const attackPower = partyShamanAttackPower(stats.attackPower, auras); const meleeHastePercent = resolveAuraStatValue(stats.meleeHastePercent, auras, "melee-haste"); const fullWeaponDamage = (weapon.damageMin + weapon.damageMax) / 2 + attackPower / 14 * (weapon.speedMs / 1_000); const handMultiplier = hand === "off-hand" ? 0.5 : 1; return { damage: Math.max(1, Math.round(fullWeaponDamage * handMultiplier)), intervalMs: Math.max(500, Math.round( hastedDuration(weapon.speedMs, meleeHastePercent) / raceAffixHasteMultiplier(now), )), }; } function partyShamanAttackPower(baseAttackPower: number, auras: ReturnType["auras"]): number { return resolveAuraStatValue( resolveAuraStatValue(baseAttackPower, auras, "attack-power"), auras, "physical-attack", ); } function partyShamanWeaponHitDamage( member: PartyMember, weapon: WeaponStats, hand: "main-hand" | "off-hand", now: number, ): number { const handGearStats = { ...member.gearStats, meleeWeapon: weapon }; const stats = deriveCharacterStats( member.classId, member.level, handGearStats, member.raceId, {}, member.secondaryClassId, member.gender, member.statRuleset ?? "wow", ); const auras = useCombatStore.getState().auras.filter((aura) => ( aura.targetId === member.id && (aura.expiresAt === null || aura.expiresAt > now) )); const fullWeaponDamage = (weapon.damageMin + weapon.damageMax) / 2 + partyShamanAttackPower(stats.attackPower, auras) / 14 * (weapon.speedMs / 1_000); return Math.max(1, Math.round(fullWeaponDamage * (hand === "off-hand" ? 0.5 : 1))); } function emitPartyAbility( member: PartyMember, ability: AbilityDefinition, targetId: string, phase: "cast-start" | "cast-cancel" | "release" | "impact", now: number, instantCast = false, ): void { const penancePulse = ability.id === "wow335-priest-penance" && ability.effects.some((effect) => effect.kind === "heal" || effect.kind === "damage") && (phase === "release" || phase === "impact"); if (ability.id === "wow335-priest-penance" && phase === "impact" && !penancePulse) return; const origin = getPartyRuntimePosition(member.id) ?? useCombatStore.getState().playerPosition; const targetPosition = targetId === PLAYER_AGGRO_ID ? useCombatStore.getState().playerPosition : getMobPosition(targetId) ?? getPartyRuntimePosition(targetId) ?? origin; emitCombatPresentation({ occurredAt: now, phase: penancePulse && phase === "release" ? "tick" : phase, actorGroup: "party", sourceActorId: member.id, targetActorId: targetId, abilityId: ability.id, sourceSpellId: penancePulse ? penancePulseVisualSpellId(Boolean(useCombatStore.getState().mobs[targetId])) : ability.dbcSpellId, name: ability.name, source: ability.source === "runewaker" ? "runewaker" : ability.source?.startsWith("coa") ? "ascension" : "wow335", school: presentationSchoolForAbility(ability), delivery: penancePulse ? "projectile" : presentationDeliveryForAbility(ability), origin, targetPosition, ...(ability.radius === undefined ? {} : { radius: ability.radius }), ...(ability.id === "wow335-priest-penance" && phase === "cast-start" ? { durationMs: ability.castTimeMs } : {}), ...(instantCast ? { instantCast: true } : {}), }); } function consumePartyScentOfBloodMeleeCharge( member: PartyMember, abilityId: string, landedMeleeHit: boolean, now: number, ): PartyMember { if (member.classId !== "death-knight" || (member.talentRanks[WOW335_DEATH_KNIGHT_SCENT_OF_BLOOD_TALENT_ID] ?? 0) <= 0 || !landedMeleeHit) return member; let chargeUse = { auras: useCombatStore.getState().auras, runicPowerGain: 0, remainingCharges: 0 }; useCombatStore.setState((state) => { chargeUse = wow335DeathKnightScentOfBloodOnMeleeHit( state.auras, member.id, member.talentRanks[WOW335_DEATH_KNIGHT_SCENT_OF_BLOOD_TALENT_ID] ?? 0, true, now, ); return chargeUse.runicPowerGain > 0 ? { auras: [...chargeUse.auras] } : {}; }); if (chargeUse.runicPowerGain <= 0) return member; const combat = useCombatStore.getState(); const restored = combat.restoreActorResource( member.id, "runic-power", chargeUse.runicPowerGain, false, member.id, abilityId, now, ); if (restored <= 0) return member; const mirrored = memberFromCompanionActor(member, useCombatStore.getState().actors[member.id]); return { ...member, ...(member.resourceType === "runic-power" ? { resource: mirrored.resource } : {}), resourcePools: { ...member.resourcePools, "runic-power": mirrored.resourcePools["runic-power"] ?? member.resourcePools["runic-power"], }, }; } function partyDeathKnightDancingRuneWeaponIsActive(member: PartyMember, now: number): boolean { return member.classId === "death-knight" && useCombatStore.getState().auras.some((aura) => ( aura.targetId === member.id && aura.definition.utilityTags?.includes(WOW335_DEATH_KNIGHT_DANCING_RUNE_WEAPON_AURA_TAG) && (aura.expiresAt === null || aura.expiresAt > now) && wow335DeathKnightDancingRuneWeaponIsActive(aura.appliedAt, now) )); } function maybeMirrorPartyDeathKnightDancingRuneWeaponMelee( member: PartyMember, targetId: string, rawOwnerDamage: number, now: number, abilityId: string, ): number { if (!partyDeathKnightDancingRuneWeaponIsActive(member, now)) return 0; const combat = useCombatStore.getState(); const target = combat.mobs[targetId]; if (!wow335DeathKnightDancingRuneWeaponCanMirrorEvent({ kind: "melee", targetAlive: Boolean(target && !target.dead), targetHostile: Boolean(target), })) return 0; const amount = wow335DeathKnightDancingRuneWeaponDamage(rawOwnerDamage); if (amount <= 0) return 0; return combat.damageMob(targetId, amount, now, { actorId: member.id, role: member.role, abilityId, }, "physical").amount; } function maybeMirrorPartyDeathKnightDancingRuneWeaponSpell( member: PartyMember, ability: AbilityDefinition, targetId: string, actionTargetIsOwner: boolean, rawOwnerDamage: number, now: number, isAreaTargeted: boolean, ): number { if (!partyDeathKnightDancingRuneWeaponIsActive(member, now)) return 0; const combat = useCombatStore.getState(); const target = combat.mobs[targetId]; const event = { kind: "spell" as const, spellId: ability.dbcSpellId, ownerKnowsSpell: true, targetAlive: Boolean(target && !target.dead), targetHostile: Boolean(target), isPositiveSpell: ability.target === "friendly" || ability.target === "self", hasSummonEffect: ability.effects.some((effect) => effect.kind === "summon" || effect.kind === "pet"), isAreaTargeted, actionTargetIsOwner, excludedUtilitySpell: wow335DeathKnightDancingRuneWeaponExcludedUtilitySpellForName(ability.name), }; if (!wow335DeathKnightDancingRuneWeaponCanMirrorEvent(event)) return 0; const amount = wow335DeathKnightDancingRuneWeaponDamage(rawOwnerDamage); if (amount <= 0) return 0; return combat.damageMob(targetId, amount, now, { actorId: member.id, role: member.role, abilityId: ability.id, }, presentationSchoolForAbility(ability)).amount; } function maybeProcPartyWarriorUnbridledWrath( member: PartyMember, landedDamagingMainHandAutoAttack: boolean, weaponSpeedMs: number, seed: string, now: number, ): PartyMember { if (member.classId !== "warrior" || !landedDamagingMainHandAutoAttack) return member; const rank = member.talentRanks[WOW335_WARRIOR_UNBRIDLED_WRATH_TALENT_ID] ?? 0; const profile = wow335WarriorUnbridledWrathProfile(rank); if (!profile || !wow335WarriorUnbridledWrathShouldProc( rank, weaponSpeedMs, stableCombatUnit(`${seed}:wow335-warrior-unbridled-wrath`), )) return member; useCombatStore.getState().restoreActorResource( member.id, "rage", profile.ragePerProc, false, member.id, WOW335_WARRIOR_UNBRIDLED_WRATH_ABILITY_ID, now, ); return memberFromCompanionActor(member, useCombatStore.getState().actors[member.id]); } /** Weapon swings continue during the GCD and supply Rage for paid attacks. */ function advancePartyWeaponAttack(member: PartyMember, targetId: string, now: number): PartyMember { const hasMaelstromWeaponTalent = member.classId === "shaman" && (member.talentRanks["talent-2057"] ?? 0) > 0; if (partyUsesRangedAttacks(member) && baseClassFor(member.classId) !== "hunter" && !hasMaelstromWeaponTalent) return member; const origin = getPartyRuntimePosition(member.id) ?? useCombatStore.getState().playerPosition; const targetPosition = getMobPosition(targetId); const spatial = combatSpatialActor(member.id); if (!targetPosition || !partyRuntimeTargetVisible(member.id, targetId) || distanceSquared(origin, targetPosition) > partyAttackRange(member) ** 2 || Math.abs(origin[1] - targetPosition[1]) > 8 || !combatHasLineOfSight(origin, targetPosition) || (spatial && !isPositionInFacingArc(spatial, targetPosition, Math.PI))) return member; const rogueOffhandWeapon = partyRogueOffhandWeapon(member); if (rogueOffhandWeapon) { const masteryActive = partyRogueOffHandMasteryActive(member, now); const deadlineKey = `${member.id}:off-hand`; if (now >= (nextRogueOffhandAttackAt.get(deadlineKey) ?? 0)) { const profile = partyRogueOffhandAttackProfile(member, rogueOffhandWeapon, masteryActive, now); nextRogueOffhandAttackAt.set(deadlineKey, now + profile.intervalMs); const abilityId = "party-auto-attack-offhand"; const seed = `${member.id}:${targetId}:${abilityId}:${now}:off-hand`; const targetLevel = useCombatStore.getState().mobs[targetId]?.level ?? member.level; const rules = { isAutoAttack: true, isDualWielding: true, ...(masteryActive ? { baseMissChancePercent: Math.max(0, baseMissChancePercent("melee", member.level, targetLevel, true) - ROM_ROGUE_OFF_HAND_MASTERY_ACCURACY_PERCENT), } : {}), }; const result = useCombatStore.getState().damageMob( targetId, profile.damage, now, { actorId: member.id, role: member.role, abilityId }, "physical", { attackKind: "melee", seed, abilityId, rules, weaponSpeedMs: rogueOffhandWeapon.speedMs, }, ); if (result.amount > 0) { applyPartyRomRoguePoisonOnAutoAttack(member, targetId, abilityId, now); useCombatStore.getState().triggerRogueWeaponPoisons(member.id, targetId, abilityId, 1, now); } for (const phase of ["release", "impact"] as const) { emitCombatPresentation({ occurredAt: now, phase, actorGroup: "party", sourceActorId: member.id, targetActorId: targetId, abilityId, name: "Off-hand Auto Attack", source: "fallback", school: "physical", delivery: "melee", origin, targetPosition, instantCast: true }); } } } if (member.classId === "shaman") { const hands = (["main-hand", "off-hand"] as const).flatMap((hand) => { const weapon = partyShamanWeapon(member, hand); return weapon ? [{ hand, weapon }] : []; }); const isDualWielding = hands.some(({ hand }) => hand === "off-hand"); for (const { hand, weapon } of hands) { const deadlineKey = `${member.id}:${hand}`; if (now < (nextShamanWeaponAttackAt.get(deadlineKey) ?? 0)) continue; const flurryActiveBeforeSwing = wow335ShamanFlurryIsActive( useCombatStore.getState().auras, member.id, now, ); const profile = partyShamanWeaponAttackProfile(member, weapon, hand, now); const abilityId = hand === "main-hand" ? "party-auto-attack" : "party-auto-attack-offhand"; const seed = `${member.id}:${targetId}:${abilityId}:${now}:${hand}`; const result = useCombatStore.getState().damageMob( targetId, profile.damage, now, { actorId: member.id, role: member.role, abilityId }, "physical", { attackKind: "melee", seed, abilityId, rules: { isAutoAttack: true, isDualWielding }, }, ); // Re-read owner auras after the hit: Flurry is applied/consumed inside // damageMob, and its haste determines this hand's next swing interval. const flurryActiveAfterSwing = wow335ShamanFlurryIsActive( useCombatStore.getState().auras, member.id, now, ); const nextIntervalMs = flurryActiveBeforeSwing || flurryActiveAfterSwing ? partyShamanWeaponAttackProfile(member, weapon, hand, now).intervalMs : profile.intervalMs; nextShamanWeaponAttackAt.set(deadlineKey, now + nextIntervalMs); if ((result.combatResult?.landed ?? result.amount > 0) && result.amount > 0) { maybeProcPartyShamanMaelstromWeapon( member, targetId, abilityId, hand, weapon.speedMs, now, ); } for (const phase of ["release", "impact"] as const) { emitCombatPresentation({ occurredAt: now, phase, actorGroup: "party", sourceActorId: member.id, targetActorId: targetId, abilityId, name: "Auto Attack", source: "fallback", school: "physical", delivery: "melee", origin, targetPosition, instantCast: true }); } } return member; } if (now < (nextWeaponAttackAt.get(member.id) ?? 0)) return member; nextWeaponAttackAt.set(member.id, now + memberSwingMs(member, now)); const abilityId = "party-auto-attack"; const scentRank = member.classId === "death-knight" ? member.talentRanks[WOW335_DEATH_KNIGHT_SCENT_OF_BLOOD_TALENT_ID] ?? 0 : 0; const bloodCakedBladeRank = member.classId === "death-knight" ? member.talentRanks[WOW335_DEATH_KNIGHT_BLOOD_CAKED_BLADE_TALENT_ID] ?? 0 : 0; const bloodyVengeanceRank = member.classId === "death-knight" ? member.talentRanks[WOW335_DEATH_KNIGHT_BLOODY_VENGEANCE_TALENT_ID] ?? 0 : 0; const unbridledWrathRank = member.classId === "warrior" ? member.talentRanks[WOW335_WARRIOR_UNBRIDLED_WRATH_TALENT_ID] ?? 0 : 0; const goForTheThroatRank = member.classId === "hunter" ? member.talentRanks[WOW335_HUNTER_GO_FOR_THE_THROAT_TALENT_ID] ?? 0 : 0; const rogueDualWielding = member.classId === "rom-rogue" && Boolean(rogueOffhandWeapon); const activeAuras = useCombatStore.getState().auras; const forgeRankOneActive = member.classId === "rom-champion" && activeAuras.some((aura) => aura.targetId === member.id && aura.definition.id === "rom-champion-forge-active" && (aura.expiresAt === null || aura.expiresAt > now)); const forgeRankTwoActive = member.classId === "rom-champion" && romChampionHasForgeRankTwoAura(activeAuras, member.id, now); const forgeAutoAttackActive = forgeRankOneActive || forgeRankTwoActive; const needsAttackRoll = scentRank > 0 || bloodCakedBladeRank > 0 || bloodyVengeanceRank > 0 || unbridledWrathRank > 0 || goForTheThroatRank > 0 || rogueDualWielding || forgeAutoAttackActive; const weaponSpeedMs = member.gearStats.meleeWeapon?.speedMs ?? 2_400; const seed = `${member.id}:${targetId}:${abilityId}:${now}:${rogueDualWielding ? "rom-rogue-dual-wield-main-hand" : forgeAutoAttackActive ? "rom-champion-forge-auto-attack" : "scent-of-blood-auto-attack"}`; useCombatStore.getState().notifyOwnerHostileAction(member.id, targetId); const ownerAttackDamage = partyAttackDamage(member, now); const result = useCombatStore.getState().damageMob(targetId, ownerAttackDamage, now, { actorId: member.id, role: member.role, abilityId }, "physical", needsAttackRoll ? { attackKind: goForTheThroatRank > 0 ? "ranged" : "melee", seed, abilityId, rules: { isAutoAttack: true, ...(rogueDualWielding ? { isDualWielding: true } : {}) }, weaponSpeedMs, } : undefined); if (member.classId === "death-knight" && (result.combatResult?.landed ?? result.amount > 0) && result.amount > 0) { maybeMirrorPartyDeathKnightDancingRuneWeaponMelee( member, targetId, result.combatResult?.rawDamage ?? ownerAttackDamage, now, abilityId, ); } let runtimeMember = consumePartyScentOfBloodMeleeCharge( member, abilityId, scentRank > 0 && (result.combatResult?.landed ?? result.amount > 0), now, ); if (forgeAutoAttackActive && result.amount > 0) { // The combat event proc already grants Forge's Rage through the actor mirror. // Carry that result forward before applying this swing's ordinary +6 Rage. runtimeMember = memberFromCompanionActor(runtimeMember, useCombatStore.getState().actors[member.id]); } runtimeMember = maybeProcPartyWarriorUnbridledWrath( runtimeMember, Boolean(result.combatResult?.landed && result.amount > 0), weaponSpeedMs, seed, now, ); if (member.classId === "rom-rogue" && result.amount > 0) { applyPartyRomRoguePoisonOnAutoAttack(member, targetId, abilityId, now); } if (result.amount > 0 && baseClassFor(member.classId) === "rogue") { useCombatStore.getState().triggerRogueWeaponPoisons(member.id, targetId, abilityId, 1, now); } const identity = partyAttackPresentation(member); for (const phase of ["release", "impact"] as const) { emitCombatPresentation({ occurredAt: now, phase, actorGroup: "party", sourceActorId: member.id, targetActorId: targetId, abilityId, name: "Auto Attack", source: "fallback", school: "physical", delivery: baseClassFor(member.classId) === "hunter" ? "projectile" : identity.delivery, origin, targetPosition, instantCast: true }); } if (runtimeMember.resourcePools.rage === undefined || result.amount <= 0) return runtimeMember; const rage = Math.min(companionResourceMaximum(runtimeMember, "rage"), runtimeMember.resourcePools.rage + 6); return { ...runtimeMember, resource: runtimeMember.resourceType === "rage" ? rage : runtimeMember.resource, resourcePools: { ...runtimeMember.resourcePools, rage }, }; } function partyFriendlyPosition(actorId: string): MobVector3 | null { if (actorId === PLAYER_AGGRO_ID) return useCombatStore.getState().playerPosition; return getPartyRuntimePosition(actorId); } function partyMeditationPathPassive(member: PartyMember): AbilityDefinition | null { if (member.classId !== "rom-mage" || member.secondaryClassId !== "rom-warrior") return null; const source = abilitiesForCharacter(member.classId, member.secondaryClassId).find((candidate) => ( candidate.passive && candidate.dbcSpellId === ROM_MAGE_WARRIOR_MEDITATION_PATH_PASSIVE_SPELL_ID )); return source ? learnedAbilityForBuild(source, member.level, member.talentRanks) : null; } export function partyCastCanMoveDuringMeditationPath( member: PartyMember, ability: AbilityDefinition, now: number, ): boolean { return canAbilityCastWhileMoving(ability) || romMageWarriorMeditationPathAllowsMovement( member.classId, member.secondaryClassId, Boolean(partyMeditationPathPassive(member)), useCombatStore.getState().auras, member.id, now, ); } export function partyAbilityTargetIsValid( member: PartyMember, ability: AbilityDefinition, targetId: string, castOrigin?: MobVector3, now = Date.now(), castCanMove = false, ): boolean { const combat = useCombatStore.getState(); if (member.classId === "warrior" && ability.name === "Victory Rush" && !combat.auras.some((aura) => aura.targetId === member.id && (aura.expiresAt === null || aura.expiresAt > now) && (aura.definition.utilityTags?.includes(WOW335_WARRIOR_IMPROVED_VICTORY_RUSH_READY_TAG) || aura.definition.utilityTags?.includes(WOW335_WARRIOR_VICTORY_RUSH_KILL_READY_TAG)))) return false; if (romPriestWarriorStanceRequired(ability)) { const activeAuraTags = combat.auras .filter((aura) => aura.targetId === member.id && (aura.expiresAt === null || aura.expiresAt > now)) .flatMap((aura) => aura.definition.utilityTags ?? []); if (!romPriestWarriorStanceUseAllowed(ability, activeAuraTags)) return false; } if (member.classId === "druid" && ability.name === "Swiftmend" && !partySwiftmendHotOnTarget(member.id, targetId, now)) return false; if (member.classId === "warlock") { const metamorphosisActive = combat.auras.some((aura) => aura.targetId === member.id && aura.definition.utilityTags?.includes(WOW335_WARLOCK_METAMORPHOSIS_AURA_TAG) && (aura.expiresAt === null || aura.expiresAt > now)); if (!warlockMetamorphosisFormActionReady(ability.id, metamorphosisActive) || (metamorphosisActive && !warlockMetamorphosisCanCastAbilityWhileActive(ability))) return false; } const origin = getPartyRuntimePosition(member.id) ?? combat.playerPosition; const meditationAllowsMovement = romMageWarriorMeditationPathAllowsMovement( member.classId, member.secondaryClassId, Boolean(partyMeditationPathPassive(member)), combat.auras, member.id, now, ); if (castOrigin && !canAbilityCastWhileMoving(ability) && !castCanMove && !meditationAllowsMovement && distanceSquared(origin, castOrigin) > 0.04) return false; const mob = combat.mobs[targetId]; const hostile = Boolean(mob); if (abilityRequiresOutOfCombat(ability) && !(ability.source === "runewaker" && member.classId === "rom-rogue" && combat.auras.some((aura) => aura.targetId === member.id && aura.definition.utilityTags?.includes("rom-vanish"))) && Object.values(combat.mobs).some((candidate) => candidate.engaged && !candidate.dead)) return false; if (ability.name === "Kill Shot" && mob && mob.health / Math.max(1, mob.maxHealth) > 0.2) return false; if (!coaBarbarianDecapitateTargetAllowed(ability, mob)) return false; if (ability.name === "Power Word: Shield" && combat.auras.some((aura) => aura.targetId === targetId && aura.definition.id === "wow335:priest:weakened-soul")) return false; if (ability.name === "Enraged Regeneration" && !combat.auras.some((aura) => aura.targetId === member.id && aura.definition.disposition === "buff" && aura.definition.dispelCategory === "enrage" && !aura.definition.name.startsWith("Enraged Regeneration"))) return false; if (ability.source === "runewaker") { const requiredTargetStateTag = romAbilityRequiredTargetStateTag(ability); if (requiredTargetStateTag && !combat.auras.some((aura) => aura.targetId === targetId && (aura.expiresAt === null || aura.expiresAt > now) && aura.definition.utilityTags?.includes(requiredTargetStateTag)) && !combat.effects.some((effect) => effect.kind === "dot" && effect.targetId === targetId && effect.remainingTicks > 0 && effect.tag === requiredTargetStateTag)) return false; if (ability.dbcSpellId === 491613 && mob?.creatureFamily === "living" && !(member.classId === "rom-knight" && member.secondaryClassId === "rom-scout" && member.level >= 60)) return false; if ([490151, 499909].includes(ability.dbcSpellId ?? -1) && (member.comboTargetId !== targetId || member.comboPoints <= 0)) return false; if (ability.dbcSpellId === ROM_ROGUE_ENERGIZED_HANDS_SKILL_ID || ability.dbcSpellId === ROM_ROGUE_POISON_SKILL_ID) { const handsEffect = ability.effects.find((effect) => effect.kind === "apply-aura"); const activeHandsAura = combat.auras.find((aura) => aura.targetId === member.id && aura.definition.exclusiveGroup === ROM_ROGUE_POISONOUS_HANDS_EXCLUSIVE_GROUP && (aura.expiresAt === null || aura.expiresAt > now)); if (activeHandsAura && handsEffect?.kind === "apply-aura" && activeHandsAura.definition.id !== handsEffect.aura.id) return false; } if (ability.dbcSpellId === 493017 && !combat.auras.some((aura) => aura.targetId === member.id && aura.definition.utilityTags?.includes("rom-poisonous"))) return false; if (ability.dbcSpellId === 492920 && !combat.effects.some((effect) => effect.kind === "dot" && effect.targetId === targetId && effect.remainingTicks > 0 && effect.sourceAbilityId === "rom-rogue-rom-warrior-elite-35")) return false; if (ability.dbcSpellId === 491171 && !combat.auras.some((aura) => aura.targetId === member.id && aura.definition.utilityTags?.includes("rom-charged"))) return false; if (ability.dbcSpellId === 491172 && !combat.auras.some((aura) => aura.targetId === member.id && aura.definition.utilityTags?.includes("rom-static-field-charge"))) return false; if (ability.dbcSpellId === 490311 || romAbilityNeedsShield(ability) || romAbilityNeedsRangedWeapon(ability)) { const loadout = resolvedEquipmentLoadout(combat.equipment, combat.inventory, { ownerId: member.id, ownerKind: "party", classId: member.classId, secondaryClassId: member.secondaryClassId, specializationId: member.specializationId, role: member.role, level: member.level, }); if (ability.dbcSpellId === 490311 && loadout["main-hand"]?.weaponType !== "dagger") return false; if (romAbilityNeedsShield(ability) && loadout["off-hand"]?.armorType !== "shield") return false; if (romAbilityNeedsRangedWeapon(ability) && !loadout.ranged) return false; } } const targetPosition = hostile ? getMobPosition(targetId) : partyFriendlyPosition(targetId); if (!targetPosition || mob?.dead) return false; const targetActor = combat.actors[targetId]; if (!hostile && (!targetActor?.alive || targetActor.health <= 0) && !ability.effects.some((effect) => effect.kind === "resurrection")) return false; const distance = Math.sqrt(distanceSquared(origin, targetPosition)); const targeting = targetingRulesForAbility(ability); const catalogMaximumRange = hostile && ability.target === "self" ? targeting.radius ?? ability.radius ?? ability.range.max : ability.range.max; // Several stock melee SkillLine rows use a three-yard DBC range while the // companion formation deliberately settles just outside that distance. // Apply the same combat-reach leeway used by companion weapon swings so // builders, diseases, bleeds, and finishers remain usable in formation. const attackRange = hostile && catalogMaximumRange > 0 && catalogMaximumRange <= 5 ? Math.max(catalogMaximumRange, partyAttackRange(member)) : catalogMaximumRange; const damagingSpell = ability.target === "hostile" && ability.effects.some((effect) => ( effect.kind === "damage" || effect.kind === "finisher-damage" || effect.kind === "dot" || effect.kind === "execute" || effect.kind === "resource-drain" )); const flameThrowingBonusYards = member.classId === "mage" && damagingSpell ? wow335MageFlameThrowingProfile( member.talentRanks[WOW335_MAGE_FLAME_THROWING_TALENT_ID] ?? 0, ).damagingSpellRangeBonusYards : 0; const hasCastTimeOrChannel = ability.castTimeMs > 0 || ability.castMode === "channel" || (ability.channelTickMs ?? 0) > 0; const naturesReachBonusYards = member.classId === "druid" && damagingSpell && hasCastTimeOrChannel ? wow335DruidNaturesReachRangeBonusYards(member.talentRanks[WOW335_DRUID_NATURES_REACH_TALENT_ID] ?? 0) : 0; const maximumRange = attackRange + flameThrowingBonusYards + naturesReachBonusYards; if (distance < ability.range.min || distance > maximumRange || Math.abs(origin[1] - targetPosition[1]) > 8) return false; if (targeting.requiresLineOfSight && !combatHasLineOfSight(origin, targetPosition)) return false; if (hostile && targeting.requiresFacing) { const sourceSpatial = combatSpatialActor(member.id); if (sourceSpatial && !isPositionInFacingArc(sourceSpatial, targetPosition, targeting.attackArcRadians ?? Math.PI)) return false; } if (hostile && targeting.requiresBehindTarget) { const targetSpatial = combatSpatialActor(targetId); if (targetSpatial && !isSourceBehindTarget(origin, targetSpatial)) return false; } return true; } function partyHostileTargets( ability: AbilityDefinition, primaryTargetId: string, origin: MobVector3, sourceActorId: string, ): readonly string[] { const combat = useCombatStore.getState(); const targeting = targetingRulesForAbility(ability); const primaryPosition = getMobPosition(primaryTargetId); if (!primaryPosition && ability.target !== "self") return []; if (targeting.shape === "chain") { if (!primaryPosition) return []; const selected: string[] = []; let previous = primaryPosition; const ordered = [primaryTargetId, ...Object.keys(combat.mobs).filter((id) => id !== primaryTargetId).sort()]; for (const id of ordered) { if (selected.length >= (targeting.maximumTargets ?? 3)) break; const position = getMobPosition(id); if (!position || !combat.mobs[id]?.engaged || combat.mobs[id]?.dead) continue; if (selected.length > 0 && distanceSquared(previous, position) > (targeting.jumpRange ?? 12) ** 2) continue; if (!combatHasLineOfSight(previous, position)) continue; selected.push(id); previous = position; } return selected; } if (targeting.shape === "cone") { const memberSpatial = combatSpatialActor(sourceActorId); return Object.values(combat.mobs) .filter((mob) => { const position = getMobPosition(mob.id); return mob.engaged && !mob.dead && Boolean(position && distanceSquared(origin, position) <= (targeting.radius ?? ability.radius ?? ability.range.max) ** 2 && combatHasLineOfSight(origin, position) && (!memberSpatial || isPositionInFacingArc(memberSpatial, position, targeting.attackArcRadians ?? Math.PI / 2))); }) .map((mob) => mob.id) .sort(); } if (targeting.shape === "circle" || targeting.shape === "ground" || ability.radius !== undefined) { const radius = targeting.radius ?? ability.radius ?? 0; const center = ability.target === "self" ? origin : primaryPosition; if (!center) return []; return Object.values(combat.mobs) .filter((mob) => { const position = getMobPosition(mob.id); return mob.engaged && !mob.dead && Boolean(position && distanceSquared(center, position) <= radius * radius && combatHasLineOfSight(center, position)); }) .map((mob) => mob.id) .sort(); } return primaryPosition && combatHasLineOfSight(origin, primaryPosition) ? [primaryTargetId] : []; } function partyKillingSpreeTargetIds(primaryTargetId: string, origin: MobVector3): readonly string[] { const combat = useCombatStore.getState(); return [...new Set([primaryTargetId, ...Object.keys(combat.mobs)])] .filter((id) => { const mob = combat.mobs[id]; const position = getMobPosition(id); return Boolean(mob && mob.engaged && !mob.dead && mob.health > 0 && position && distanceSquared(origin, position) <= 10 ** 2 && combatHasLineOfSight(origin, position)); }) .sort(); } function partyHealingTargets( ability: AbilityDefinition, primaryTargetId: string, members: readonly PartyMember[], ): readonly string[] { const combat = useCombatStore.getState(); const targeting = targetingRulesForAbility(ability); const living = [ ...(simulatedPartyExcludesPlayer() ? [] : [PLAYER_AGGRO_ID]), ...members.map((member) => member.id), ].filter((id) => combat.actors[id]?.alive); const healthFraction = (id: string) => { const actor = combat.actors[id]; return actor ? actor.health / Math.max(1, actor.maxHealth) : 1; }; if (targeting.shape === "chain") { const selected: string[] = []; let previous = partyFriendlyPosition(primaryTargetId); const ordered = [primaryTargetId, ...living.filter((id) => id !== primaryTargetId) .sort((left, right) => healthFraction(left) - healthFraction(right) || left.localeCompare(right))]; for (const id of ordered) { if (selected.length >= (targeting.maximumTargets ?? 3)) break; const position = partyFriendlyPosition(id); if (!position || !previous) continue; if (selected.length > 0 && distanceSquared(previous, position) > (targeting.jumpRange ?? 12) ** 2) continue; if (!combatHasLineOfSight(previous, position)) continue; selected.push(id); previous = position; } return selected; } if (targeting.shape === "circle" || targeting.shape === "ground" || ability.radius !== undefined) { const center = partyFriendlyPosition(primaryTargetId); const radius = targeting.radius ?? ability.radius ?? 0; return center ? living.filter((id) => { const position = partyFriendlyPosition(id); return Boolean(position && distanceSquared(center, position) <= radius * radius && combatHasLineOfSight(center, position)); }) : []; } return [primaryTargetId]; } function partyResourcePoolSnapshotsInRange( ability: AbilityDefinition, caster: PartyMember, members: readonly PartyMember[], ): readonly PartyResourcePoolSnapshot[] { if (!ability.effects.some((effect) => effect.kind === "resource" && effect.scope === "party")) return []; const combat = useCombatStore.getState(); const origin = partyFriendlyPosition(caster.id); if (!origin) return []; const targeting = targetingRulesForAbility(ability); const radius = targeting.radius ?? ability.radius ?? 0; const recipientIds = [ ...(simulatedPartyExcludesPlayer() ? [] : [PLAYER_AGGRO_ID]), ...members.map((candidate) => candidate.id), ]; return recipientIds.flatMap((id) => { const actor = combat.actors[id]; const position = partyFriendlyPosition(id); return actor?.alive && position && distanceSquared(origin, position) <= radius * radius && combatHasLineOfSight(origin, position) ? [{ resources: actor.resources }] : []; }); } interface PartyAbilityExecutionResult { readonly member: PartyMember; readonly effectiveAmount: number; } interface PartySupportIntent { readonly ability: AbilityDefinition; readonly targetId: string; } function abilityHasDirectHealing(ability: AbilityDefinition): boolean { return ability.effects.some((effect) => effect.kind === "heal") || channelAmountEffects(ability, 1).some(({ effect }) => effect.kind === "heal"); } function choosePartySupportIntent( member: PartyMember, members: readonly PartyMember[], now: number, ): PartySupportIntent | null { const combat = useCombatStore.getState(); const fallenTargetId = [ ...(!simulatedPartyExcludesPlayer() && combat.health <= 0 ? [PLAYER_AGGRO_ID] : []), ...members .filter((candidate) => candidate.health <= 0) .sort((left, right) => ( Number(left.role === "healer") - Number(right.role === "healer") || Number(left.role === "tank") - Number(right.role === "tank") || left.id.localeCompare(right.id) )) .map((candidate) => candidate.id), ][0]; if (fallenTargetId) { const resurrection = partyResurrectionAbility(member); if (resurrection) return { ability: resurrection, targetId: fallenTargetId }; } if (member.classId === "hunter") { const deadPet = combat.summons.find((summon) => ( summon.ownerActorId === member.id && summon.hunterPet && summon.health <= 0 )); const petId = partyHunterPetRecipientIds(combat.summons, member.id, now)[0]; const pet = petId ? combat.summons.find((summon) => summon.id === petId) : undefined; const hunterAbilities = abilitiesForCharacter(member.classId, member.secondaryClassId) .map((ability) => learnedAbilityForBuild(ability, member.level, member.talentRanks)) .filter((ability): ability is AbilityDefinition => ability !== null); const revivePet = hunterAbilities.find((ability) => ability.name === "Revive Pet"); if (deadPet && revivePet && partyAbilityIsReady(member, revivePet, now) && partyAbilityTargetIsValid(member, revivePet, member.id, undefined, now)) { return { ability: revivePet, targetId: member.id }; } const mendPet = hunterAbilities.find((ability) => ability.name === "Mend Pet"); if (pet && mendPet && pet.health < pet.maxHealth * 0.75 && !healingAbilityHasPendingHot(mendPet, member.id, pet.id, combat.effects) && partyAbilityIsReady(member, mendPet, now) && partyAbilityTargetIsValid(member, mendPet, member.id, undefined, now)) { return { ability: mendPet, targetId: member.id }; } } if (member.classId === "rom-rogue" && member.secondaryClassId === "rom-warden" && member.health > 0 && member.health < member.maxHealth) { const catalogAbility = abilitiesForCharacter(member.classId, member.secondaryClassId) .find((ability) => ability.id === "rom-rogue-rom-warden-elite-60"); const revival = catalogAbility ? learnedAbilityForBuild(catalogAbility, member.level, member.talentRanks) : null; if (revival && partyAbilityIsReady(member, revival, now) && partyAbilityTargetIsValid(member, revival, member.id, undefined, now)) { return { ability: revival, targetId: member.id }; } } if (romWardenRevivalBuildMatches(member.classId, member.secondaryClassId)) { const catalogAbility = abilitiesForCharacter(member.classId, member.secondaryClassId) .find((ability) => ability.source === "runewaker" && ability.dbcSpellId === 499544); const stateAbility = catalogAbility ? learnedAbilityForBuild(catalogAbility, member.level, member.talentRanks) : null; if (stateAbility && !romWardenRevivalStateIsActive(combat.auras, member.id, now) && partyAbilityIsReady(member, stateAbility, now) && partyAbilityTargetIsValid(member, stateAbility, member.id, undefined, now)) { return { ability: stateAbility, targetId: member.id }; } } if (member.role !== "healer") return null; const dispelTargetId = [ ...(!simulatedPartyExcludesPlayer() && combat.health > 0 ? [PLAYER_AGGRO_ID] : []), ...members.filter((candidate) => candidate.health > 0).map((candidate) => candidate.id), ].find((id) => combat.auras.some((aura) => ( aura.targetId === id && aura.definition.disposition === "debuff" && aura.definition.dispelCategory ))); if (dispelTargetId) { const ability = choosePartyAbility(member, { now, target: null, allyNeedsDispel: true }); if (ability?.effects.some((effect) => effect.kind === "dispel" && effect.relationship !== "hostile")) { return { ability, targetId: dispelTargetId }; } } // Preventive damage-taken procs (Earth Shield / Prayer of Mending-shaped // effects) belong on the tank before damage lands. Normal HoTs and direct // heals still wait for the injured-target path below. const preventiveTarget = members .filter((candidate) => candidate.health > 0) .sort((left, right) => ( Number(right.role === "tank") - Number(left.role === "tank") || left.id.localeCompare(right.id) ))[0]; if (preventiveTarget) { const maintainedAbilityIds = new Set(combat.auras .filter((aura) => aura.sourceId === member.id && (aura.expiresAt === null || aura.expiresAt > now)) .flatMap((aura) => abilitiesForCharacter(member.classId, member.secondaryClassId) .map((ability) => learnedAbilityForBuild(ability, member.level, member.talentRanks)) .filter((ability): ability is AbilityDefinition => ability !== null) .filter((ability) => ability.effects.some((effect) => effect.kind === "apply-aura" && effect.aura.id === aura.definition.id)) .map((ability) => ability.id))); const activeAuraExclusiveGroups = new Set(combat.auras .filter((aura) => aura.sourceId === member.id && (aura.expiresAt === null || aura.expiresAt > now)) .flatMap((aura) => aura.definition.exclusiveGroup ? [aura.definition.exclusiveGroup] : [])); const preventive = choosePartyAbility(member, { now, target: null, friendlyTargets: [{ id: preventiveTarget.id, health: preventiveTarget.health, maxHealth: preventiveTarget.maxHealth, }], maintainedAbilityIds, activeAuraExclusiveGroups, canUseAbility: ability => (ability.target === "friendly" || ability.target === "any") && ability.effects.some((effect) => effect.kind === "apply-aura" && effect.aura.disposition === "buff" && effect.aura.procs?.some((proc) => proc.triggers === "damage-taken")) && partyAbilityTargetIsValid(member, ability, preventiveTarget.id, undefined, now), }); if (preventive) return { ability: preventive, targetId: preventiveTarget.id }; } const injured = [ ...(!simulatedPartyExcludesPlayer() && combat.health > 0 && combat.health < combat.maxHealth ? [{ id: PLAYER_AGGRO_ID, health: combat.health, maxHealth: combat.maxHealth, role: "healer" as const }] : []), ...members.filter((candidate) => candidate.health > 0 && candidate.health < candidate.maxHealth), ].sort((left, right) => ( left.health / Math.max(1, left.maxHealth) - right.health / Math.max(1, right.maxHealth) || (right.maxHealth - right.health) - (left.maxHealth - left.health) || left.id.localeCompare(right.id) )); const friendlyTargets = injured.map((candidate) => ({ id: candidate.id, health: candidate.health, maxHealth: candidate.maxHealth, role: candidate.role, })); const lowAllies = injured.filter((candidate) => ( candidate.health / Math.max(1, candidate.maxHealth) <= 0.6 )); const lowestHealthFraction = injured[0] ? injured[0].health / Math.max(1, injured[0].maxHealth) : 1; if (!pendingInnerFocus.has(member.id) && (lowestHealthFraction <= 0.4 || lowAllies.length >= 2)) { const innerFocus = choosePartyAbility(member, { now, target: null, injuredAllies: injured.length, lowestHealthFraction, friendlyTargets, canUseAbility: ability => ability.name === "Inner Focus" && partyAbilityTargetIsValid(member, ability, member.id, undefined, now), }); if (innerFocus) return { ability: innerFocus, targetId: member.id }; } for (const target of injured) { const maintainedAbilityIds = new Set(combat.auras .filter((aura) => aura.sourceId === member.id && aura.targetId === target.id && (aura.expiresAt === null || aura.expiresAt > now)) .flatMap((aura) => abilitiesForCharacter(member.classId, member.secondaryClassId) .map((ability) => learnedAbilityForBuild(ability, member.level, member.talentRanks)) .filter((ability): ability is AbilityDefinition => ability !== null) .filter((ability) => ability.effects.some((effect) => effect.kind === "apply-aura" && effect.aura.id === aura.definition.id)) .map((ability) => ability.id))); if (pendingInnerFocus.has(member.id)) { const empoweredHeal = choosePartyAbility(member, { now, target: null, injuredAllies: injured.length, lowestHealthFraction: target.health / Math.max(1, target.maxHealth), friendlyTargets, nextHealingSpellFree: true, canUseAbility: ability => abilityHasDirectHealing(ability) && (healingAbilityCanAidOthers(ability) || target.id === member.id) && partyAbilityTargetIsValid(member, ability, ability.target === "self" ? member.id : target.id, undefined, now), }); if (empoweredHeal) { return { ability: empoweredHeal, targetId: empoweredHeal.target === "self" ? member.id : target.id }; } } const mitigation = choosePartyAbility(member, { now, target: null, injuredAllies: injured.length, lowestHealthFraction: target.health / Math.max(1, target.maxHealth), friendlyTargets, maintainedAbilityIds, canUseAbility: ability => aiHintsForAbility(ability).categories.includes("mitigation") && (ability.target === "friendly" || ability.target === "any" || ability.target === "self") && ability.effects.some((effect) => effect.kind === "shield" || (effect.kind === "apply-aura" && ( Boolean(effect.aura.absorbs?.length) || effect.aura.modifiers?.some((modifier) => modifier.kind === "damage" && modifier.direction === "taken" && modifier.value < 0) ))) && partyAbilityTargetIsValid(member, ability, ability.target === "self" ? member.id : target.id, undefined, now) && !ability.effects.some((effect) => effect.kind === "shield" && (combat.actors[target.id]?.absorb ?? 0) > 0) && !ability.effects.some((effect) => effect.kind === "apply-aura" && combat.auras.some((aura) => aura.sourceId === member.id && aura.targetId === target.id && aura.definition.id === effect.aura.id)), }); if (mitigation) return { ability: mitigation, targetId: mitigation.target === "self" ? member.id : target.id }; const targetHealthFraction = target.health / Math.max(1, target.maxHealth); const periodic = targetHealthFraction >= 0.55 ? choosePartyAbility(member, { now, target: null, injuredAllies: injured.length, lowestHealthFraction: targetHealthFraction, friendlyTargets, maintainedAbilityIds, canUseAbility: ability => ability.effects.some((effect) => effect.kind === "hot") && (healingAbilityCanAidOthers(ability) || target.id === member.id) && !healingAbilityHasPendingHot(ability, member.id, target.id, combat.effects) && partyAbilityTargetIsValid(member, ability, ability.target === "self" ? member.id : target.id, undefined, now), }) : null; if (periodic) return { ability: periodic, targetId: periodic.target === "self" ? member.id : target.id }; const ability = choosePartyAbility(member, { now, target: null, injuredAllies: injured.length, lowestHealthFraction: target.health / Math.max(1, target.maxHealth), friendlyTargets, maintainedAbilityIds, canUseAbility: ability => ability.effects.some(effect => ["heal", "hot", "shield"].includes(effect.kind) || (effect.kind === "apply-aura" && Boolean(effect.aura.absorbs?.length))) && (healingAbilityCanAidOthers(ability) || target.id === member.id) && !(healingAbilityHasPendingHot(ability, member.id, target.id, combat.effects) && (!ability.effects.some(effect => effect.kind === "heal") || target.health / target.maxHealth > 0.5)) && partyAbilityTargetIsValid(member, ability, ability.target === "self" ? member.id : target.id, undefined, now) && !(ability.effects.some(effect => effect.kind === "shield") && (combat.actors[target.id]?.absorb ?? 0) > 0), }); if (ability) return { ability, targetId: ability.target === "self" ? member.id : target.id }; } return null; } function partyGreenGuardianIntent( member: PartyMember, now: number, inCombat: boolean, ): PartySupportIntent | null { if (!inCombat || !isRomMageDruidPair({ classId: member.classId, secondaryClassId: member.secondaryClassId ?? undefined, })) return null; const combat = useCombatStore.getState(); if (combat.auras.some((aura) => aura.targetId === member.id && aura.definition.utilityTags?.includes(ROM_MAGE_DRUID_GREEN_GUARDIAN_AURA_TAG) && (aura.expiresAt === null || aura.expiresAt > now))) return null; const source = abilitiesForCharacter(member.classId, member.secondaryClassId) .find((ability) => ability.dbcSpellId === ROM_MAGE_DRUID_GREEN_GUARDIAN_SKILL_ID); const ability = source ? learnedAbilityForBuild(source, member.level, member.talentRanks) : null; if (!ability || !partyAbilityIsReady(member, ability, now) || !partyAbilityTargetIsValid(member, ability, member.id, undefined, now)) return null; // CoA defines Green Guardian as an active self skill but gives no companion // auto-use policy. The simulator uses it once on the first available combat // action when learned; this is an AI policy, not a source-described trigger. return { ability, targetId: member.id }; } function awardPartyPriestChampionRageControl( member: PartyMember, cast: NonNullable, endedAt: number, ): PartyMember { if (cast.mode !== "channel" || member.classId !== "rom-priest" || member.secondaryClassId !== "rom-champion") return member; const passive = abilitiesForCharacter(member.classId, member.secondaryClassId).find((candidate) => ( candidate.source === "runewaker" && candidate.classId === "rom-priest" && candidate.secondaryClassId === "rom-champion" && candidate.dbcSpellId === 498610 && candidate.passive )); const learnedRageControl = Boolean(passive && learnedAbilityForBuild(passive, member.level, member.talentRanks)); const durationMs = Math.max(0, Math.min(endedAt, cast.completesAt) - cast.startedAt); const gain = romPriestChampionRageControlGain(learnedRageControl, { kind: "channel", durationMs }); if (gain <= 0) return member; useCombatStore.getState().restoreActorResource( member.id, "rage", gain, false, member.id, cast.abilityId, endedAt, ); return memberFromCompanionActor(member, useCombatStore.getState().actors[member.id]); } /** @internal Shared with focused runtime regression coverage; call only after a qualifying hit resolves. */ export function applyPartyRomCoaExhaustion( member: PartyMember, ability: AbilityDefinition, landed: boolean, seed: string, now: number, ): PartyMember { if (member.classId !== "rom-warrior" || member.secondaryClassId !== "rom-rogue" || ability.source !== "runewaker" || ability.dbcSpellId !== ROM_COA_WARRIOR_ROGUE_PROBING_ATTACK_ID) return member; const learnedPassive = abilitiesForCharacter(member.classId, member.secondaryClassId).some((candidate) => ( candidate.source === "runewaker" && candidate.classId === "rom-warrior" && candidate.secondaryClassId === "rom-rogue" && candidate.dbcSpellId === ROM_COA_WARRIOR_ROGUE_EXHAUSTION_ID && candidate.passive && learnedAbilityForBuild(candidate, member.level, member.talentRanks) !== null )); const proc = romCoaWarriorRogueExhaustionProc({ classId: member.classId, secondaryClassId: member.secondaryClassId, learnedPassiveSpellIds: learnedPassive ? [ROM_COA_WARRIOR_ROGUE_EXHAUSTION_ID] : [], triggerSpellId: ability.dbcSpellId, attackLanded: landed, roll: stablePartyUnit(seed), }); if (!proc.procced) return member; useCombatStore.getState().restoreActorResource(member.id, "energy", proc.energy, false, member.id, ability.id, now); useCombatStore.getState().restoreActorResource(member.id, "rage", proc.rage, false, member.id, ability.id, now); const latest = useCombatStore.getState().actors[member.id]; return latest ? memberFromCompanionActor(member, latest) : member; } function spreadPartyPestilenceDiseases( sourceActorId: string, anchorTargetId: string, affectedTargetIds: readonly string[], now: number, ): boolean { const diseaseTags = new Set(["blood-plague", "frost-fever"]); const combat = useCombatStore.getState(); const diseases = combat.effects.filter((effect) => ( effect.kind === "dot" && effect.targetId === anchorTargetId && effect.sourceActorId === sourceActorId && effect.tag !== undefined && diseaseTags.has(effect.tag) && effect.remainingTicks > 0 && effect.nextTickAt + (effect.remainingTicks - 1) * effect.intervalMs > now )); const recipients = affectedTargetIds.filter((targetId) => targetId !== anchorTargetId); if (!diseases.length || !recipients.length) return false; useCombatStore.setState((state) => { const effects = [...state.effects]; for (const recipientId of recipients) { for (const disease of diseases) { for (let index = effects.length - 1; index >= 0; index -= 1) { const current = effects[index]; if (current.kind === "dot" && current.targetId === recipientId && current.sourceActorId === sourceActorId && current.tag === disease.tag) effects.splice(index, 1); } effects.push({ ...disease, id: `${disease.id}:pestilence:${recipientId}:${now}`, targetId: recipientId, }); } } return { effects }; }); return true; } function executePartyAbility( member: PartyMember, ability: AbilityDefinition, targetId: string, now: number, members: readonly PartyMember[], depth = 0, castStartAlreadyEmitted = false, channelPulse = false, tidalWavesChargesConsumed = 0, arcanePotencyCriticalChanceBonusPercent = 0, manaSpent = 0, maelstromWeaponRank = 0, maelstromWeaponStacksConsumed = 0, reapingRuneUse?: { readonly runesBefore: readonly DeathKnightRuneState[]; readonly spentRuneSlots: readonly number[]; }, illuminationBaseManaCostSnapshot?: number, ): PartyAbilityExecutionResult { let combat = useCombatStore.getState(); if (ability.source === "runewaker") { for (const passive of abilitiesForCharacter(member.classId, member.secondaryClassId) .filter((candidate) => candidate.passive) .map((candidate) => learnedAbilityForBuild(candidate, member.level, member.talentRanks)) .filter((candidate): candidate is AbilityDefinition => Boolean(candidate))) { for (const effect of passive.effects) { if (effect.kind !== "apply-aura" || effect.recipient !== "caster") continue; if (combat.auras.some((aura) => aura.sourceId === member.id && aura.targetId === member.id && aura.definition.id === effect.aura.id)) continue; useCombatStore.getState().applyActorAura(member.id, member.id, passive, effect, now); combat = useCombatStore.getState(); } } } if (ability.name === "Enraged Regeneration" && !combat.consumeActorEnrage(member.id, now)) { return { member, effectiveAmount: 0 }; } const origin = getPartyRuntimePosition(member.id) ?? combat.playerPosition; const hostileTargets = combat.mobs[targetId] || ability.target === "self" ? partyHostileTargets(ability, targetId, origin, member.id) : []; if (ability.target === "hostile" && ability.name !== "Hunter's Mark" && hostileTargets.length > 0 && ability.effects.some((effect) => ["damage", "dot", "finisher-damage", "execute"].includes(effect.kind))) { useCombatStore.getState().notifyOwnerHostileAction(member.id, targetId); } const friendlyTargets = combat.mobs[targetId] ? [] : partyHealingTargets(ability, targetId, members); const consumesColdBlood = ability.name !== "Cold Blood" && hostileTargets.length > 0 && ability.effects.some((effect) => ["damage", "dot", "finisher-damage"].includes(effect.kind)) && combat.auras.some((aura) => aura.targetId === member.id && aura.definition.utilityTags?.includes("next-offensive-critical")); const spellSchool = ability.source === "runewaker" ? abilityDamageSchool(member.classId, ability) : member.classId === "shaman" && ability.source === "wow335-progression" && ["Lightning Bolt", "Chain Lightning"].includes(ability.name) ? abilityDamageSchool("shaman", ability) : presentationSchoolForAbility(ability); const consumesShamanNaturesGuardianCrit = member.classId === "shaman" && (hostileTargets.length > 0 || friendlyTargets.length > 0) && ability.effects.some((effect) => effect.kind === "damage" ? (effect.school ?? abilityDamageSchool("shaman", ability)) === "nature" : effect.kind === "heal" && abilityDamageSchool("shaman", ability) === "nature") && wow335ShamanNaturesGuardianNextCritIsReady(combat.auras, member.id, now); const usesCombustion = spellSchool === "fire" && hostileTargets.length > 0 && ability.effects.some((effect) => effect.kind === "damage") && combat.auras.some((aura) => aura.targetId === member.id && aura.definition.utilityTags?.includes("fire-crit-stacking")); const groundAreaCreated = targetingRulesForAbility(ability).shape === "ground" && !(castStartAlreadyEmitted && ability.castMode === "channel") ? Boolean(useCombatStore.getState().createActorAreaEffect( member.id, ability, ability.target === "self" ? origin : getMobPosition(targetId) ?? partyFriendlyPosition(targetId) ?? origin, now, )) : false; const consumesInnerFocus = pendingInnerFocus.has(member.id) && ability.name !== "Inner Focus" && abilityHasDirectHealing(ability); if (consumesInnerFocus) pendingInnerFocus.delete(member.id); const elementalOathRank = combat.classId === "shaman" ? Math.min(3, Math.max(0, Math.trunc(combat.talentRanks["talent-2049"] ?? 0))) : 0; const criticalChance = Math.max(0, (member.statRuleset === "rom" ? 0 : ratingPercent( member.gearStats.criticalStrikeRating, "spellCrit", member.level, member.classId, ) / 100) + elementalOathRank / 100 + (consumesInnerFocus ? 0.25 : 0) + (consumesShamanNaturesGuardianCrit ? 1 : 0) + wow335ShamanTidalWavesCriticalChanceBonusPercent(ability.name, tidalWavesChargesConsumed) / 100); const masterOfElementsCritPercent = member.classId === "mage" ? Math.min(3, Math.max(0, Math.trunc(member.talentRanks[WOW335_MAGE_MASTER_OF_ELEMENTS_TALENT_ID] ?? 0))) : 0; const frostbiteRank = member.classId === "mage" ? Math.min(3, Math.max(0, Math.trunc(member.talentRanks[WOW335_MAGE_FROSTBITE_TALENT_ID] ?? 0))) : 0; if (ability.name === "Inner Focus") pendingInnerFocus.add(member.id); const envenomDosesByTarget = new Map(); let suddenDoomTrigger: ReturnType = null; if (ability.name === "Envenom") { for (const hostileTargetId of hostileTargets) { const doses = Math.min( member.comboPoints, 5, matchingAuraStacks( combat.auras, ROGUE_DEADLY_POISON_DOSE_TAG, member.id, hostileTargetId, now, ), ); envenomDosesByTarget.set(hostileTargetId, doses); if (doses > 0 && !rogueEnvenomPreservesDeadlyPoison( member.talentRanks, `${member.id}:${hostileTargetId}:${ability.id}:${now}:master-poisoner`, )) { combat.consumeRogueDeadlyPoison(member.id, hostileTargetId, doses, now); } } } let runtimeMember = member; let effectiveAmount = 0; let directHealingRawAmount = 0; let romBeaconHealTriggeredForCast = false; let romPriestHolyCandleChargedForCast = channelPulse && (member.activeCast?.romPriestHolyCandleCharged ?? false); let deathKnightReapingProcAttemptedForCast = false; let deathKnightReapingSpendRestoreAttemptedForCast = false; const isRomChampionDarkEnergyStrike = romChampionDarkEnergyStrikeMatchesBuild( member.classId, member.secondaryClassId, ability, ); let romChampionDarkEnergyStrikeDamageIndex = 0; const romChampionDarkEnergyStrikeGroup0SurvivedTargets = new Set(); const attemptDeathKnightReapingOnLandedTrigger = (): void => { if (member.classId !== "death-knight" || deathKnightReapingProcAttemptedForCast) return; if (!deathKnightReapingSpendRestoreAttemptedForCast) { runtimeMember = { ...runtimeMember, runes: wow335DeathKnightReapingRestoreLandedSpend( runtimeMember.runes, reapingRuneUse?.spentRuneSlots ?? [], true, ), }; deathKnightReapingSpendRestoreAttemptedForCast = true; } deathKnightReapingProcAttemptedForCast = true; const reapingRank = member.talentRanks["talent-2001"] ?? 0; const shouldProc = wow335DeathKnightReapingShouldProc(reapingRank, { spellId: ability.dbcSpellId ?? -1, landed: true, roll: stablePartyUnit( `${member.id}:${ability.id}:${targetId}:${now}:wow335-death-knight-reaping`, ), }); if (!shouldProc || !reapingRuneUse) return; const reapingState = wow335DeathKnightReapingStateForRunes(runtimeMember.runes, now); const reapingPlan = wow335DeathKnightReapingProcPlan( reapingRank, reapingState, reapingRuneUse.runesBefore, runtimeMember.runes, now, ); runtimeMember = { ...runtimeMember, runes: wow335DeathKnightReapingApplyProcPlan(runtimeMember.runes, reapingPlan), }; }; const landedDirectHitTargetIdsForCast = new Set(); const criticalDirectHitTargetIdsForCast = new Set(); const empoweredFireRank = member.classId === "mage" ? member.talentRanks[WOW335_MAGE_EMPOWERED_FIRE_TALENT_ID] ?? 0 : 0; const learnedRomPriestWarriorCondensedRage = member.classId === "rom-priest" && member.secondaryClassId === "rom-warrior" && abilitiesForCharacter(member.classId, member.secondaryClassId).some((candidate) => ( candidate.source === "runewaker" && candidate.classId === "rom-priest" && candidate.secondaryClassId === "rom-warrior" && candidate.dbcSpellId === 493038 && learnedAbilityForBuild(candidate, member.level, member.talentRanks) !== null )) ? [493038] : []; const learnedRomPriestChampionPassiveSpellIds = member.classId === "rom-priest" && member.secondaryClassId === "rom-champion" ? abilitiesForCharacter(member.classId, member.secondaryClassId) .filter((candidate) => candidate.source === "runewaker" && candidate.classId === "rom-priest" && candidate.secondaryClassId === "rom-champion" && candidate.passive && learnedAbilityForBuild(candidate, member.level, member.talentRanks) !== null) .map((candidate) => candidate.dbcSpellId) : []; const romMainHand = ability.source === "runewaker" ? resolvedEquipmentLoadout(combat.equipment, combat.inventory, { ownerId: member.id, ownerKind: "party", classId: member.classId, secondaryClassId: member.secondaryClassId, specializationId: member.specializationId, role: member.role, level: member.level, })["main-hand"] : null; let castEffects = romEffectsForCast(ability, { sealStacks: member.comboTargetId === targetId ? member.comboPoints : 0, bleedCount: romTargetBleedCount(combat.effects, member.id, targetId), charged: combat.auras.some((aura) => aura.targetId === member.id && aura.definition.utilityTags?.includes("rom-charged")), mainHandDagger: member.classId === "rom-rogue" && romMainHand?.weaponType === "dagger", mainHandTwoHandHammer: romMainHand?.inventorySlot === "two-hand" && romMainHand.weaponType === "mace", casterMaxHealth: member.maxHealth, targetHealthPercent: combat.mobs[targetId] ? combat.mobs[targetId]!.health / Math.max(1, combat.mobs[targetId]!.maxHealth) : 1, selectedTargetRelationship: combat.mobs[targetId] ? "hostile" : "friendly", naturePower: member.resourcePools["nature-power"] ?? 0, defaultDamageIsPhysical: abilityDamageSchool(member.classId, ability) === "physical", learnedPassiveSpellIds: [ ...learnedRomPriestWarriorCondensedRage, ...learnedRomPriestChampionPassiveSpellIds, ...(member.classId === "rom-warlock" && member.secondaryClassId ? abilitiesForCharacter(member.classId, member.secondaryClassId) .filter((candidate) => candidate.source === "runewaker" && candidate.classId === "rom-warlock" && candidate.secondaryClassId === member.secondaryClassId && candidate.passive) .map((candidate) => learnedAbilityForBuild(candidate, member.level, member.talentRanks)) .filter((candidate): candidate is AbilityDefinition => Boolean(candidate)) .map((candidate) => candidate.dbcSpellId) : []), ], eruptionProcRoll: stableCombatUnit( `${member.id}:${ability.id}:${targetId ?? "self"}:${now}:rom-eruption`, ), passiveTags: combat.auras.filter((aura) => aura.targetId === member.id) .flatMap((aura) => aura.definition.utilityTags ?? []).concat( abilitiesForCharacter(member.classId, member.secondaryClassId) .filter((candidate) => candidate.passive) .map((candidate) => learnedAbilityForBuild(candidate, member.level, member.talentRanks)) .filter((candidate): candidate is AbilityDefinition => Boolean(candidate)) .flatMap((candidate) => candidate.effects.flatMap((effect) => effect.kind === "apply-aura" ? effect.aura.utilityTags ?? [] : [])), ), targetTags: combat.auras.filter((aura) => aura.targetId === targetId) .flatMap((aura) => aura.definition.utilityTags ?? []).concat( romCreatureFamilyTargetTags(combat.mobs[targetId]?.creatureFamily ?? "unknown"), combat.effects.filter((effect) => effect.targetId === targetId && effect.remainingTicks > 0) .flatMap((effect) => effect.tag ? [effect.tag] : []), ), }).map((effect) => wow335MageEmpoweredFireEffect(ability.name, effect, empoweredFireRank)); if (member.classId === "death-knight") { const epidemicRank = member.talentRanks[WOW335_DEATH_KNIGHT_EPIDEMIC_TALENT_ID] ?? 0; castEffects = castEffects.map((effect) => wow335DeathKnightEpidemicDotEffect(effect, epidemicRank)); } const shamanWeaponLoadout = member.classId === "shaman" ? resolvedEquipmentLoadout(combat.equipment, combat.inventory, { ownerId: member.id, ownerKind: "party", classId: member.classId, secondaryClassId: member.secondaryClassId, specializationId: member.specializationId, role: member.role, level: member.level, }) : null; if (ability.dbcSpellId === 492920 && castEffects.some((effect) => effect.kind === "dot" && effect.tag === "rom-622992")) { useCombatStore.setState((state) => ({ effects: state.effects.filter((effect) => !(effect.kind === "dot" && effect.targetId === targetId && effect.sourceAbilityId === "rom-rogue-rom-warrior-elite-35")), })); } for (const effect of castEffects) { const darkEnergyStrikeGroup = effect.kind === "damage" && isRomChampionDarkEnergyStrike ? romChampionDarkEnergyStrikePacketGroupAtDamageIndex(romChampionDarkEnergyStrikeDamageIndex++) : null; // Necrotic Wound's Sinister Request rider belongs to the completed cast. // romEffectsForCast also appends it to each synthetic channel-pulse ability, // so applying it here would reduce the cooldown once per hit plus once at // completion. Keep the three damage pulses intact and defer only this // cast-level rider to the successful completion call below. if (channelPulse && ability.source === "runewaker" && ability.dbcSpellId === 494321 && member.classId === "rom-druid" && member.secondaryClassId === "rom-rogue" && effect.kind === "cooldown-reset") continue; if ((effect.kind === "heal" || effect.kind === "apply-aura") && effect.requiresLandedHit && landedDirectHitTargetIdsForCast.size === 0) continue; if (effect.kind === "apply-aura" && effect.requiresCriticalHit && criticalDirectHitTargetIdsForCast.size === 0) continue; if (ability.name === "Penance" && combat.mobs[targetId] && (effect.kind === "heal" || effect.kind === "hot")) continue; if (ability.name === "Penance" && !combat.mobs[targetId] && (effect.kind === "damage" || effect.kind === "dot")) continue; if (effect.kind === "damage" || effect.kind === "dot" || effect.kind === "finisher-damage") { if (effect.kind === "dot" && romSelfDrainPercent(ability.dbcSpellId) > 0) { combat.applyTimedActorEffect(member.id, member.id, ability, effect, now); continue; } for (const [targetOrdinal, hostileTargetId] of hostileTargets.entries()) { if (darkEnergyStrikeGroup === "dark" && !romChampionDarkEnergyStrikeGroup0SurvivedTargets.has(hostileTargetId)) continue; if (effect.kind === "dot") { if (!groundAreaCreated) combat.applyTimedActorEffect( member.id, hostileTargetId, ability, ability.name === "Rupture" ? { ...effect, ticks: Math.max(4, Math.min(8, 3 + runtimeMember.comboPoints)) } : effect, now, arcanePotencyCriticalChanceBonusPercent, ); } else { const shamanWeaponHand = member.classId === "shaman" && effect.kind === "damage" ? effect.weaponHand ?? (ability.name === "Lava Lash" ? "off-hand" : undefined) : undefined; const shamanWeapon = shamanWeaponHand ? shamanWeaponLoadout?.[shamanWeaponHand]?.stats.meleeWeapon ?? null : null; if (effect.kind === "damage" && shamanWeaponHand && !shamanWeapon) continue; const sunderStacks = effect.kind === "damage" && effect.bonusPerSunderStack ? useCombatStore.getState().auras.find((aura) => aura.targetId === hostileTargetId && aura.definition.id === "wow335:warrior:sunder-armor")?.stacks ?? 0 : 0; const partyDamageAmount = effect.kind === "damage" && effect.weaponHand && shamanWeapon && shamanWeaponHand ? Math.max(1, Math.round( partyShamanWeaponHitDamage(runtimeMember, shamanWeapon, shamanWeaponHand, now) * effect.coefficient, ) + (effect.bonusFlat ?? 0)) : partyWarlockAbilityAmount( runtimeMember, ability, effect, envenomDosesByTarget.get(hostileTargetId) ?? 0, hostileTargetId, now, { targetCount: hostileTargets.length, targetOrdinal }, ); const targetStatuses = useCombatStore.getState().mobs[hostileTargetId]?.statuses ?? []; const mageTargetDamageAmount = member.classId === "mage" ? wow335MageTormentTheWeakDamage( partyDamageAmount, member.talentRanks[WOW335_MAGE_TORMENT_THE_WEAK_TALENT_ID] ?? 0, ability.name, targetStatuses, now, ) : partyDamageAmount; const deathKnightSuddenDoomTrigger = member.classId === "death-knight" && ["Blood Strike", "Heart Strike"].includes(ability.name); const deathKnightBloodyVengeanceRank = member.classId === "death-knight" ? member.talentRanks[WOW335_DEATH_KNIGHT_BLOODY_VENGEANCE_TALENT_ID] ?? 0 : 0; const deathKnightBloodyVengeanceHit = deathKnightBloodyVengeanceRank > 0 && member.classId === "death-knight" && ability.classId === "death-knight" && ability.source === "wow335-progression" && effect.kind === "damage"; const deathKnightBloodGorgedRank = member.classId === "death-knight" ? member.talentRanks[WOW335_DEATH_KNIGHT_BLOOD_GORGED_TALENT_ID] ?? 0 : 0; const deathKnightBloodGorgedHit = deathKnightBloodGorgedRank > 0 && !channelPulse && effect.kind === "damage" && ability.classId === "death-knight" && wow335DeathKnightSpellIdMatchesBloodGorgedMask(ability.dbcSpellId); const deathKnightScentOfBloodRank = member.classId === "death-knight" ? member.talentRanks[WOW335_DEATH_KNIGHT_SCENT_OF_BLOOD_TALENT_ID] ?? 0 : 0; const deathKnightScentOfBloodMeleeHit = deathKnightScentOfBloodRank > 0 && effect.kind === "damage" && ability.source === "wow335-progression" && spellSchool === "physical"; const hunterGoForTheThroatRank = member.classId === "hunter" ? member.talentRanks[WOW335_HUNTER_GO_FOR_THE_THROAT_TALENT_ID] ?? 0 : 0; const hunterGoForTheThroatAttackKind = hunterGoForTheThroatRank > 0 && effect.kind === "damage" && !channelPulse ? wow335HunterGoForTheThroatAttackKindForAbility(ability.id) : null; const drumsOfWarPassiveSource = member.classId === "rom-scout" && member.secondaryClassId === "rom-priest" ? abilitiesForCharacter(member.classId, member.secondaryClassId).find((candidate) => ( candidate.source === "runewaker" && candidate.passive && candidate.dbcSpellId === ROM_SCOUT_PRIEST_DRUMS_OF_WAR.passiveSpellId )) : undefined; const drumsOfWarPassive = drumsOfWarPassiveSource ? learnedAbilityForBuild(drumsOfWarPassiveSource, member.level, member.talentRanks) : null; const drumsOfWarShot = effect.kind === "damage" && member.classId === "rom-scout" && member.secondaryClassId === "rom-priest" && ability.source === "runewaker" && ability.dbcSpellId === ROM_SCOUT_PRIEST_DRUMS_OF_WAR.triggerSpellId && Boolean(drumsOfWarPassive); const lightOfDayPassiveSource = member.classId === "rom-mage" && member.secondaryClassId === "rom-knight" ? abilitiesForCharacter(member.classId, member.secondaryClassId).find((candidate) => ( candidate.source === "runewaker" && candidate.passive && candidate.dbcSpellId === ROM_MAGE_KNIGHT_LIGHT_OF_DAY.passiveSpellId )) : undefined; const lightOfDayPassive = lightOfDayPassiveSource ? learnedAbilityForBuild(lightOfDayPassiveSource, member.level, member.talentRanks) : null; const lightOfDayHit = effect.kind === "damage" && !channelPulse && member.classId === "rom-mage" && member.secondaryClassId === "rom-knight" && ability.source === "runewaker" && ROM_MAGE_KNIGHT_LIGHT_OF_DAY.triggerSpellIds.includes( ability.dbcSpellId as (typeof ROM_MAGE_KNIGHT_LIGHT_OF_DAY.triggerSpellIds)[number], ) && Boolean(lightOfDayPassive); const godsSwordPassiveSource = member.classId === "rom-knight" && member.secondaryClassId === "rom-rogue" ? abilitiesForCharacter(member.classId, member.secondaryClassId).find((candidate) => ( candidate.source === "runewaker" && candidate.classId === "rom-knight" && candidate.secondaryClassId === "rom-rogue" && candidate.passive && candidate.dbcSpellId === 491621 )) : undefined; const learnedGodsSwordPassive = godsSwordPassiveSource ? learnedAbilityForBuild(godsSwordPassiveSource, member.level, member.talentRanks) : null; // CoA Knight strikes describe their attack type as "Physical Light // Damage"; the simulator stores Light as the holy damage school. const godsSwordPhysicalAbility = spellSchool === "physical" || (ability.source === "runewaker" && /\bphysical(?:\s+light)?\s+damage\b/i.test(ability.description)); const godsSwordDirectPhysicalHit = !channelPulse && effect.kind === "damage" && godsSwordPhysicalAbility && Boolean(learnedGodsSwordPassive); const shamanWeaponMeleeHit = member.classId === "shaman" && ability.source === "wow335-progression" && !channelPulse && effect.kind === "damage" && Boolean(shamanWeapon) && Boolean(shamanWeaponHand) && (ability.name === "Lava Lash" || (effect.school ?? abilityDamageSchool(member.classId, ability)) === "physical"); const shamanElementalDevastationHit = member.classId === "shaman" && ability.source === "wow335-progression" && (member.talentRanks[WOW335_SHAMAN_ELEMENTAL_DEVASTATION_TALENT_ID] ?? 0) > 0 && !channelPulse && effect.kind === "damage" && ["fire", "frost", "nature"].includes(effect.school ?? abilityDamageSchool(member.classId, ability)); const shamanElementalFocusHit = member.classId === "shaman" && ability.source === "wow335-progression" && Boolean(wow335ShamanElementalFocusProfileForRank(member.talentRanks["talent-574"] ?? 0)) && effect.kind === "damage" && ["fire", "frost", "nature"].includes(effect.school ?? abilityDamageSchool(member.classId, ability)); const shamanLightningOverloadProfile = member.classId === "shaman" && ability.source === "wow335-progression" ? wow335ShamanLightningOverloadProfileForRank( member.talentRanks[WOW335_SHAMAN_LIGHTNING_OVERLOAD_TALENT_ID] ?? 0, ) : null; const shamanLightningOverloadHit = Boolean(shamanLightningOverloadProfile) && !channelPulse && effect.kind === "damage" && shamanLightningOverloadProfile!.eligibleAbilityNames.includes(ability.name); const partyWarriorVictoryRushRank = member.classId === "warrior" && ability.name === "Victory Rush" ? member.talentRanks[WOW335_WARRIOR_IMPROVED_VICTORY_RUSH_TALENT_ID] ?? 0 : 0; const darkEnergyStrikePhysicalHit = darkEnergyStrikeGroup === "physical"; const spreadingPainCriticalBuffActive = effect.kind === "damage" && member.classId === "rom-warlock" && member.secondaryClassId === "rom-priest" && spellSchool !== "physical" && combat.auras.some((aura) => aura.targetId === member.id && aura.definition.utilityTags?.includes(ROM_WARLOCK_PRIEST_SPREADING_PAIN_CRIT_TAG) && (aura.expiresAt === null || aura.expiresAt > now)); const partyUsesCombatRollContext = member.statRuleset === "rom" || drumsOfWarShot || lightOfDayHit || deathKnightSuddenDoomTrigger || deathKnightScentOfBloodMeleeHit || deathKnightBloodyVengeanceHit || deathKnightBloodGorgedHit || shamanWeaponMeleeHit || shamanElementalDevastationHit || shamanElementalFocusHit || shamanLightningOverloadHit || godsSwordDirectPhysicalHit || darkEnergyStrikePhysicalHit || spreadingPainCriticalBuffActive || partyWarriorVictoryRushRank > 0 || consumesShamanNaturesGuardianCrit || consumesColdBlood || usesCombustion || arcanePotencyCriticalChanceBonusPercent > 0 || masterOfElementsCritPercent > 0 || hunterGoForTheThroatAttackKind !== null || (effect.kind === "damage" && spellSchool === "frost" && frostbiteRank > 0) || (member.classId === "mage" && (member.talentRanks[WOW335_MAGE_ARCANE_CONCENTRATION_TALENT_ID] ?? 0) > 0); const partyMageMoltenFuryRank = member.classId === "mage" && ability.classId === "mage" && ability.source === "wow335-progression" ? member.talentRanks[WOW335_MAGE_MOLTEN_FURY_TALENT_ID] ?? 0 : 0; const partyDamageTargetBeforeHit = useCombatStore.getState().mobs[hostileTargetId]; const damageSchool = effect.kind === "damage" && (ability.source === "runewaker" || isRomChampionDarkEnergyStrike) ? effect.school ?? spellSchool : spellSchool; const partyRawDirectDamageBase = mageTargetDamageAmount + sunderStacks * (effect.kind === "damage" ? effect.bonusPerSunderStack ?? 0 : 0); const partyBlessingAuras = !partyUsesCombatRollContext && member.classId === "rom-priest" ? combat.auras.filter((aura) => aura.targetId === member.id && (aura.expiresAt === null || aura.expiresAt > now)) : []; const partyRawDirectDamage = effect.kind === "damage" && partyBlessingAuras.length > 0 ? romPriestHolyBlessingDamage( partyRawDirectDamageBase, partyBlessingAuras, effect.school ?? spellSchool, ) : partyRawDirectDamageBase; const partyDirectDamageWithMoltenFury = effect.kind === "damage" && partyMageMoltenFuryRank > 0 && !partyUsesCombatRollContext && partyDamageTargetBeforeHit ? wow335MageMoltenFuryDamage( partyRawDirectDamage, partyMageMoltenFuryRank, damageSchool, partyDamageTargetBeforeHit.health, partyDamageTargetBeforeHit.maxHealth, ) : partyRawDirectDamage; const mercilessCombatRank = !channelPulse && effect.kind === "damage" && member.classId === "death-knight" && ability.classId === "death-knight" ? member.talentRanks[WOW335_DEATH_KNIGHT_MERCILESS_COMBAT_TALENT_ID] ?? 0 : 0; const partyDirectDamageWithMercilessCombat = partyDamageTargetBeforeHit ? wow335DeathKnightMercilessCombatDamage( partyDirectDamageWithMoltenFury, mercilessCombatRank, ability.name, partyDamageTargetBeforeHit.health / partyDamageTargetBeforeHit.maxHealth, ) : partyDirectDamageWithMoltenFury; const partyDirectDamageWithRunewakerAuras = effect.kind === "damage" && ability.source === "runewaker" && !partyUsesCombatRollContext ? resolveAuraDamageValue( partyDirectDamageWithMercilessCombat, combat.auras.filter((aura) => aura.targetId === member.id && (aura.expiresAt === null || aura.expiresAt > now) && !aura.definition.utilityTags?.includes(ROM_PRIEST_HOLY_BLESSING_TAG)), { direction: "dealt", school: damageSchool, attackKind: effect.romNativeSource && romNativePlayerHitProfile( effect.romNativeSource.skillId, effect.romNativeSource.componentId, )?.damageChannel === "physical" ? member.classId === "rom-scout" ? "ranged" : "melee" : "spell", }, ) : partyDirectDamageWithMercilessCombat; const maelstromWeaponPvpDamageReductionPercent = partyDamageTargetBeforeHit?.characterDefender && damageSchool !== "physical" ? wow335ShamanMaelstromWeaponPvpDamageReductionPercent( maelstromWeaponRank, maelstromWeaponStacksConsumed, ability.name, ) : 0; const result = combat.damageMob( hostileTargetId, Math.round(partyDirectDamageWithRunewakerAuras * (1 - maelstromWeaponPvpDamageReductionPercent / 100)), now, { actorId: member.id, role: member.role, abilityId: ability.id, }, damageSchool, partyUsesCombatRollContext ? { attackKind: member.statRuleset === "rom" && effect.kind === "damage" && effect.romNativeSource ? romNativePlayerHitProfile(effect.romNativeSource.skillId, effect.romNativeSource.componentId)?.damageChannel === "physical" ? member.classId === "rom-scout" ? "ranged" : "melee" : "spell" : drumsOfWarShot ? "ranged" : hunterGoForTheThroatAttackKind ?? (deathKnightScentOfBloodMeleeHit || deathKnightBloodGorgedHit || shamanWeaponMeleeHit || godsSwordDirectPhysicalHit || darkEnergyStrikePhysicalHit || (deathKnightBloodyVengeanceHit && spellSchool === "physical") || consumesColdBlood || deathKnightSuddenDoomTrigger ? "melee" : "spell"), abilityId: ability.id, dbcSpellId: ability.dbcSpellId, seed: `${member.id}:${ability.id}:${hostileTargetId}:${now}:special-crit${shamanWeaponHand ? `:${shamanWeaponHand}` : ""}`, ...(deathKnightBloodyVengeanceHit && channelPulse ? { periodic: true } : {}), criticalChanceBonusPercent: (consumesColdBlood ? 100 : 0) + (consumesShamanNaturesGuardianCrit && (effect.kind === "damage" ? (effect.school ?? abilityDamageSchool("shaman", ability)) === "nature" : abilityDamageSchool("shaman", ability) === "nature") ? 100 : 0) + arcanePotencyCriticalChanceBonusPercent + masterOfElementsCritPercent, ...(godsSwordDirectPhysicalHit || spreadingPainCriticalBuffActive // Before God's Sword needed a landed-result hook, these party // hits used the deterministic no-roll damage path. Supply the // result without adding hit avoidance; Spreading Pain retains // its actual magical critical rolls. ? { rules: { canMiss: false, canCrit: spreadingPainCriticalBuffActive, canDodge: false, canParry: false, canBlock: false, } } : { rules: { ...(ability.name === "Shiv" ? { canCrit: false } : {}), romNativeSource: effect.kind === "damage" ? effect.romNativeSource : undefined, romNativeTargetOrdinal: targetOrdinal } }), } : undefined, ); if (darkEnergyStrikeGroup === "physical" && hostileTargetId === targetId && romChampionDarkEnergyStrikeGroup0Survives({ effectiveDamage: result.amount, combatResult: result.combatResult, })) { romChampionDarkEnergyStrikeGroup0SurvivedTargets.add(hostileTargetId); } effectiveAmount += result.amount; const partyHitLanded = result.combatResult?.landed ?? result.amount > 0; if (!partyUsesCombatRollContext && effect.kind === "damage" && partyHitLanded && result.amount > 0) { const learnedSoulBrandPassives = member.classId === "rom-warlock" && member.secondaryClassId === "rom-mage" ? abilitiesForCharacter(member.classId, member.secondaryClassId) .filter((candidate) => candidate.source === "runewaker" && candidate.passive && candidate.dbcSpellId === 498705 && learnedAbilityForBuild(candidate, member.level, member.talentRanks) !== null) .map((candidate) => candidate.dbcSpellId) : []; const soulBrandEffects = romWarlockMageSoulBrandEffects({ classId: member.classId, secondaryClassId: member.secondaryClassId ?? "", learnedPassiveSpellIds: learnedSoulBrandPassives, }, { school: effect.school ?? spellSchool, damageLanded: true, periodic: channelPulse, }); if (soulBrandEffects) { for (const procEffect of soulBrandEffects) { if (procEffect.kind === "apply-aura") { combat.applyActorAura(member.id, hostileTargetId, ability, procEffect, now); } else if (procEffect.kind === "resource" && procEffect.resource === "psi") { combat.restoreActorResource(member.id, "psi", procEffect.amount, false, member.id, ability.id, now); const restoredActor = useCombatStore.getState().actors[member.id]; const psiPool = restoredActor?.resources.psi; if (psiPool) { runtimeMember = { ...runtimeMember, resourcePools: { ...runtimeMember.resourcePools, psi: psiPool.current }, }; } } } combat = useCombatStore.getState(); } } if (effect.kind === "damage" && partyHitLanded) { landedDirectHitTargetIdsForCast.add(hostileTargetId); if (result.combatResult?.critical) criticalDirectHitTargetIdsForCast.add(hostileTargetId); } if (member.classId === "death-knight" && !channelPulse && effect.kind === "damage" && hostileTargetId === targetId && partyHitLanded) { attemptDeathKnightReapingOnLandedTrigger(); } if (shamanLightningOverloadHit && shamanLightningOverloadProfile && wow335ShamanLightningOverloadShouldProc( shamanLightningOverloadProfile, ability.name, partyHitLanded, Boolean(result.combatResult?.critical), channelPulse, stablePartyUnit(`${member.id}:${ability.id}:${hostileTargetId}:${now}:${WOW335_SHAMAN_LIGHTNING_OVERLOAD_PROC_TAG}`), )) { const echoAmount = wow335ShamanLightningOverloadDamageAmount( partyDirectDamageWithMoltenFury * (1 - maelstromWeaponPvpDamageReductionPercent / 100), ); if (echoAmount > 0) { const overloadResult = combat.damageMob( hostileTargetId, echoAmount, now, { actorId: member.id, role: member.role, abilityId: ability.id, threatScale: shamanLightningOverloadProfile.threatScale, }, spellSchool, { attackKind: "spell", abilityId: ability.id, seed: `${member.id}:${ability.id}:${hostileTargetId}:${now}:${WOW335_SHAMAN_LIGHTNING_OVERLOAD_PROC_TAG}:echo`, }, ); effectiveAmount += overloadResult.amount; combat = useCombatStore.getState(); } } if (effect.kind === "damage" && hostileTargetId === targetId && !channelPulse && ability.source === "runewaker" && ability.dbcSpellId === ROM_COA_WARRIOR_ROGUE_PROBING_ATTACK_ID) { runtimeMember = applyPartyRomCoaExhaustion( runtimeMember, ability, partyHitLanded, `${member.id}:${ability.id}:${hostileTargetId}:${now}:rom-coa-exhaustion`, now, ); } if (shamanElementalFocusHit && partyHitLanded && result.combatResult?.critical) { maybeProcPartyShamanElementalFocus( member, ability, effect.school ?? abilityDamageSchool(member.classId, ability), true, false, hostileTargetId, now, ); combat = useCombatStore.getState(); } if (member.classId === "warlock" && ability.name === "Conflagrate" && effect.kind === "damage" && partyHitLanded) { const consumedDot = activePartyWarlockConflagrateDot( combat.effects, member.id, hostileTargetId, now, ); const consumedName = consumedDot ? abilityById(consumedDot.sourceAbilityId)?.name : undefined; if (consumedDot) { useCombatStore.setState((state) => ({ effects: state.effects.filter((candidate) => candidate.id !== consumedDot.id), ...(consumedName ? { auras: state.auras.filter((aura) => !( aura.sourceId === member.id && aura.targetId === hostileTargetId && aura.definition.name.startsWith(consumedName) )), } : {}), })); combat = useCombatStore.getState(); } } if (member.classId === "warlock" && ability.name === "Conflagrate" && effect.kind === "damage" && !channelPulse && hostileTargetId === targetId && partyHitLanded) { const aftermath = warlockAftermathDazeForRank( member.talentRanks["talent-982"] ?? 0, stablePartyUnit(`${member.id}:${ability.id}:${hostileTargetId}:${now}:wow335-warlock-aftermath`), ); if (aftermath) { useCombatStore.getState().controlMob( hostileTargetId, ability.id, "slow", aftermath.durationMs, now, false, member.id, aftermath.movementSpeedReductionPercent / 100, ); combat = useCombatStore.getState(); } } if (shamanWeaponMeleeHit && partyHitLanded && result.amount > 0 && shamanWeapon && shamanWeaponHand) { maybeProcPartyShamanMaelstromWeapon( runtimeMember, hostileTargetId, ability.id, shamanWeaponHand, shamanWeapon.speedMs, now, ); combat = useCombatStore.getState(); } if (deathKnightScentOfBloodMeleeHit && partyHitLanded) { runtimeMember = consumePartyScentOfBloodMeleeCharge(runtimeMember, ability.id, true, now); } if (effect.kind === "damage" && spellSchool === "frost" && frostbiteRank > 0) { const freezeDurationMs = wow335MageFrostbiteProcDurationMs( frostbiteRank, spellSchool, result.combatResult?.landed === true, result.amount, stablePartyUnit(`${member.id}:${ability.id}:${hostileTargetId}:${now}:wow335-mage-frostbite`), ); if (freezeDurationMs > 0) { useCombatStore.getState().controlMob( hostileTargetId, ability.id, "freeze", freezeDurationMs, now, false, member.id, ); combat = useCombatStore.getState(); } } if (member.classId === "death-knight" && !channelPulse && effect.kind === "damage" && hostileTargetId === targetId && result.combatResult?.landed) { suddenDoomTrigger = wow335DeathKnightSuddenDoomTriggerForLandedHit( member.talentRanks["talent-1955"] ?? 0, ability.id, hostileTargetId, true, stablePartyUnit(`${member.id}:${ability.id}:${hostileTargetId}:${now}:wow335-death-knight-sudden-doom`), ); } if (member.classId === "death-knight" && !channelPulse && effect.kind === "damage" && hostileTargetId === targetId && partyHitLanded && result.amount > 0) { effectiveAmount += maybeMirrorPartyDeathKnightDancingRuneWeaponSpell( member, ability, hostileTargetId, true, result.combatResult?.rawDamage ?? partyDirectDamageWithMoltenFury, now, hostileTargets.length > 1 || Boolean(ability.radius), ); combat = useCombatStore.getState(); } if (member.classId === "rom-mage" && !channelPulse && effect.kind === "damage" && partyHitLanded) { const passiveSource = abilitiesForCharacter(member.classId, member.secondaryClassId).find((candidate) => ( candidate.source === "runewaker" && candidate.passive && candidate.dbcSpellId === ROM_MAGE_MEDITATIVE_CURRENT.passiveSpellId )); const passive = passiveSource ? learnedAbilityForBuild(passiveSource, member.level, member.talentRanks) : null; const readyAt = romMageMeditativeCurrentReadyAt( useCombatStore.getState().auras, member.id, now, ); const proc = romMageMeditativeCurrentProc( member.classId, ability, passive ? [ROM_MAGE_MEDITATIVE_CURRENT.passiveSpellId] : [], partyHitLanded, readyAt, now, ); if (proc.effect?.kind === "apply-aura") { useCombatStore.getState().applyActorAura(member.id, member.id, ability, proc.effect, now); useCombatStore.getState().applyActorAura( member.id, member.id, ability, ROM_MAGE_MEDITATIVE_CURRENT_COOLDOWN_EFFECT, now, ); combat = useCombatStore.getState(); } } if (member.classId === "mage" && effect.kind === "damage" && result.combatResult?.landed && result.amount > 0) { maybeProcPartyMageArcaneConcentration(member, ability, hostileTargetId, now); combat = useCombatStore.getState(); } if (member.classId === "mage" && !channelPulse && effect.kind === "damage" && spellSchool !== "physical" && result.combatResult?.critical) { const refund = wow335MageMasterOfElementsManaRefund( member.talentRanks[WOW335_MAGE_MASTER_OF_ELEMENTS_TALENT_ID] ?? 0, manaSpent, true, true, ); if (refund > 0) { const maximumMana = companionResourceMaximum(runtimeMember, "mana"); const currentMana = runtimeMember.resourcePools.mana ?? (runtimeMember.resourceType === "mana" ? runtimeMember.resource : 0); const mana = Math.min(maximumMana, Math.max(0, currentMana + refund)); runtimeMember = { ...runtimeMember, ...(runtimeMember.resourceType === "mana" ? { resource: mana } : {}), resourcePools: { ...runtimeMember.resourcePools, mana }, }; } } if (effect.kind === "damage" && ability.dbcSpellId === 490133) { const currentCombat = useCombatStore.getState(); const bonus = romScoutRogueWhirlwindBonusEffect( ability, currentCombat.auras.filter((aura) => aura.targetId === member.id) .flatMap((aura) => aura.definition.utilityTags ?? []), currentCombat.effects.filter((active) => active.targetId === hostileTargetId && active.remainingTicks > 0) .flatMap((active) => active.tag ? [active.tag] : []), ); if (bonus) effectiveAmount += currentCombat.damageMob( hostileTargetId, partyAbilityAmountWithHolyBlessing(runtimeMember, ability, bonus, 0, now), now, { actorId: member.id, role: member.role, abilityId: ability.id }, "physical", ).amount; } if ( result.amount > 0 && effect.kind === "damage" && baseClassFor(member.classId) === "rogue" && spellSchool === "physical" && ability.range.max <= 5 ) { combat.triggerRogueWeaponPoisons( member.id, hostileTargetId, ability.id, ability.name === "Mutilate" ? 2 : 1, now, ); } if (effect.kind === "damage" && effect.onDamageResource && result.amount > 0) { combat.restoreActorResource(member.id, effect.onDamageResource.resource, effect.onDamageResource.amount, false, member.id, ability.id, now); } } } if (effect.kind === "finisher-damage") { runtimeMember = { ...runtimeMember, comboPoints: 0, comboTargetId: null }; } } else if (effect.kind === "utility" && effect.action === "class-script" && ability.name === "Killing Spree" && baseClassFor(member.classId) === "rogue") { const targetIds = partyKillingSpreeTargetIds(targetId, origin); const loadout = resolvedEquipmentLoadout(combat.equipment, combat.inventory, { ownerId: member.id, ownerKind: "party", classId: member.classId, secondaryClassId: member.secondaryClassId, specializationId: member.specializationId, role: member.role, level: member.level, }); const hands: Wow335KillingSpreeWeaponSnapshot[] = (["main-hand", "off-hand"] as const) .flatMap((hand) => { const item = loadout[hand]; const weapon = item?.stats.meleeWeapon ?? (hand === "main-hand" ? member.gearStats.meleeWeapon : null); return weapon ? [{ hand, ...(item?.weaponType ? { weaponType: item.weaponType } : {}), damageMin: weapon.damageMin, damageMax: weapon.damageMax, speedMs: weapon.speedMs, }] : []; }); if (targetIds.length > 0) { useCombatStore.getState().beginKillingSpree(member.id, ability, targetIds, hands, member.role, now); combat = useCombatStore.getState(); } } else if (effect.kind === "utility" && effect.action === "class-script" && ability.name === "Pestilence" && member.classId === "death-knight" && hostileTargets.length > 1) { if (spreadPartyPestilenceDiseases(member.id, targetId, hostileTargets, now)) { // Pestilence has no generated damage packet. A disease transfer is its // successful resolution event, so Reaping's proc is evaluated once // here with the same cast guard used by multi-effect direct attacks. attemptDeathKnightReapingOnLandedTrigger(); combat = useCombatStore.getState(); } } else if (effect.kind === "utility" && effect.action === "class-script" && ability.id === "rom-rogue-rom-warden-elite-60" && member.classId === "rom-rogue" && member.secondaryClassId === "rom-warden") { const revivalCharge = combat.auras.find((aura) => ( aura.sourceId === member.id && aura.targetId === member.id && (aura.expiresAt === null || aura.expiresAt > now) && aura.definition.utilityTags?.includes("rom-revival-charge") )); const requested = romWardenRevivalNormalHeal({ stacks: matchingAuraStacks(combat.auras, "rom-revival-charge", member.id, member.id, now), targetMaxHealth: member.maxHealth, }); // RuneWaker's FixPer heal is already max-HP based; skip generic healing // done/taken modifiers and critical rolls just as the player path does. const result = useCombatStore.getState().healActor(member.id, requested, { sourceActorId: member.id, abilityId: ability.id, criticalChance: 0, amountAlreadyModified: true, seed: `${member.id}:${ability.id}:${now}:rom-warden-revival`, now, }); effectiveAmount += result.effectiveAmount; if (revivalCharge) { useCombatStore.setState((state) => ({ auras: state.auras.filter((aura) => aura.instanceId !== revivalCharge.instanceId), })); } combat = useCombatStore.getState(); runtimeMember = memberFromCompanionActor(runtimeMember, combat.actors[member.id]); } else if (effect.kind === "utility" && effect.action === "class-script" && ability.source === "runewaker" && ability.dbcSpellId === 494321 && member.classId === "rom-druid" && member.secondaryClassId === "rom-rogue" && effect.triggerSpellId === 494024) { const poisonAbility = abilityById("rom-druid-rom-rogue-elite-25"); const poisonEffect = poisonAbility?.effects.find((candidate) => candidate.kind === "dot"); if (poisonAbility && poisonEffect?.kind === "dot") { const affected = new Set(hostileTargets); useCombatStore.setState((state) => ({ effects: state.effects.filter((active) => !(active.kind === "dot" && active.targetId !== null && affected.has(active.targetId) && active.tag === poisonEffect.tag)), })); for (const poisonTargetId of hostileTargets) { useCombatStore.getState().applyTimedActorEffect(member.id, poisonTargetId, poisonAbility, poisonEffect, now); } combat = useCombatStore.getState(); } } else if (effect.kind === "heal") { const healingTargetIds = healingRecipientIds(effect, member.id, targetId, combat.actors, id => partyFriendlyPosition(id) ?? getMobPosition(id), friendlyTargets, combatHasLineOfSight, now); if (depth === 0 && healingTargetIds.length > 0 && !romPriestHolyCandleChargedForCast && (channelPulse || ability.castMode !== "channel") && useCombatStore.getState().triggerRomPriestHolyCandle(member.id, ability.id, now) > 0) { romPriestHolyCandleChargedForCast = true; if (channelPulse && runtimeMember.activeCast) { runtimeMember = { ...runtimeMember, activeCast: { ...runtimeMember.activeCast, romPriestHolyCandleCharged: true }, }; } } for (const [targetIndex, friendlyTargetId] of healingTargetIds.entries()) { const actor = useCombatStore.getState().actors[friendlyTargetId]; if (!actor?.alive) continue; const riptideHot = member.classId === "shaman" && ability.name === "Chain Heal" && targetIndex === 0 ? partyShamanRiptideHotOnTarget(member.id, friendlyTargetId, now) : undefined; const swiftmendHot = member.classId === "druid" && ability.name === "Swiftmend" ? partySwiftmendHotOnTarget(member.id, friendlyTargetId, now) : undefined; const swiftmendHotAbility = swiftmendHot ? abilityById(swiftmendHot.sourceAbilityId) : null; const swiftmendHotEffect = swiftmendHotAbility?.effects.find((candidate) => candidate.kind === "hot"); const swiftmendTickCount = swiftmendHotAbility?.name === "Rejuvenation" ? 4 : swiftmendHotAbility?.name === "Regrowth" ? 6 : 0; const swiftmendAmount = swiftmendHot && swiftmendHotAbility && swiftmendHotEffect?.kind === "hot" ? (swiftmendHot.fixedAmount ?? partyAbilityAmountWithHolyBlessing(runtimeMember, swiftmendHotAbility, swiftmendHotEffect, 0, now)) * swiftmendTickCount : null; if (riptideHot) { useCombatStore.setState((state) => ({ effects: state.effects.filter((candidate) => candidate.id !== riptideHot.id), })); combat = useCombatStore.getState(); } const tidalWavesBonus = partyShamanTidalWavesBonusHealing(runtimeMember, ability, now); const healingSchool = member.classId === "shaman" ? abilityDamageSchool("shaman", ability) : spellSchool; const requested = Math.round(((swiftmendAmount ?? partyAbilityAmountWithHolyBlessing(runtimeMember, ability, effect, 0, now)) + tidalWavesBonus + Math.round(actor.maxHealth * (effect.percentMaxHealth ?? 0))) * (riptideHot ? PARTY_SHAMAN_RIPTIDE_CHAIN_HEAL_MULTIPLIER : 1)); const result = useCombatStore.getState().healActor(friendlyTargetId, requested, { sourceActorId: member.id, romNativeSource: effect.romNativeSource, abilityId: ability.id, school: healingSchool, criticalChance, seed: `${member.id}:${ability.id}:${friendlyTargetId}:${now}:heal`, now, }); if (member.classId === "paladin" && result.critical) { const sourceAbility = abilityById(ability.id) ?? ability; const spellBaseManaCost = illuminationBaseManaCostSnapshot ?? abilityAtLevel(sourceAbility, member.level).cost.amount; const illumination = wow335PaladinIlluminationRefundPlan( member.talentRanks[WOW335_PALADIN_ILLUMINATION_TALENT_ID] ?? 0, { casterClassId: member.classId, abilityId: ability.id, abilityName: ability.name, criticalHeal: true, spellBaseManaCost, roll: stableCombatUnit(`${member.id}:${ability.id}:${friendlyTargetId}:${now}:wow335-illumination`), }, ); if (illumination) { const currentMana = runtimeMember.resourcePools.mana ?? (runtimeMember.resourceType === "mana" ? runtimeMember.resource : 0); const maximumMana = companionResourceMaximum(runtimeMember, "mana"); const restored = useCombatStore.getState().restoreActorResource( member.id, "mana", illumination.manaRefund, false, member.id, ability.id, now, ); if (restored > 0) { const mana = Math.min(maximumMana, currentMana + restored); runtimeMember = { ...runtimeMember, ...(runtimeMember.resourceType === "mana" ? { resource: mana } : {}), resourcePools: { ...runtimeMember.resourcePools, mana }, }; } } } if (swiftmendHot && !runtimeMember.glyphIds?.includes(WOW335_GLYPH_OF_SWIFTMEND_ID)) { useCombatStore.setState((state) => ({ effects: state.effects.filter((candidate) => candidate.id !== swiftmendHot.id), })); } effectiveAmount += result.effectiveAmount; directHealingRawAmount += result.rawAmount; runtimeMember = maybeProcPartyShamanImprovedWaterShield( runtimeMember, ability, friendlyTargetId, healingSchool, result.critical, now, ); if (ability.name === "Divine Hymn") { useCombatStore.getState().applyActorAura(member.id, friendlyTargetId, ability, { kind: "apply-aura", recipient: "ability-target", aura: { id: "wow335:priest:divine-hymn-healing", name: "Divine Hymn", disposition: "buff", durationMs: 8_000, maxStacks: 1, stackBehavior: "refresh", modifiers: [{ kind: "healing", direction: "taken", operation: "percent", value: 0.03 }], }, sourceEffectType: 6, sourceAuraType: 118, miscValue: 0, triggerSpellId: 0, }, now); } } if (!romBeaconHealTriggeredForCast && ability.castMode !== "channel" && member.classId === "rom-priest" && member.secondaryClassId === "rom-champion") { romBeaconHealTriggeredForCast = true; const beaconHeal = resolveRomPriestChampionBeaconFromAuras( useCombatStore.getState().auras, member.id, now, "direct-cast", ); if (beaconHeal) { const result = useCombatStore.getState().healActor(beaconHeal.targetId, beaconHeal.amount, { sourceActorId: member.id, abilityId: ROM_PRIEST_CHAMPION_BEACON_OF_REGENERATION_ID, school: "holy", criticalChance: 0, seed: `${member.id}:${ability.id}:${beaconHeal.targetId}:${now}:rom-beacon-of-regeneration`, now, }); effectiveAmount += result.effectiveAmount; directHealingRawAmount += result.rawAmount; } } } else if (effect.kind === "hot") { const fallbackRecipients = effect.recipient === "pet" ? partyHunterPetRecipientIds(combat.summons, member.id, now) : friendlyTargets; const recipients = groundAreaCreated ? [] : healingRecipientIds(effect, member.id, targetId, combat.actors, id => partyFriendlyPosition(id) ?? getMobPosition(id), fallbackRecipients, combatHasLineOfSight, now); if (depth === 0 && recipients.length > 0 && !channelPulse && !romPriestHolyCandleChargedForCast && ability.castMode !== "channel" && useCombatStore.getState().triggerRomPriestHolyCandle(member.id, ability.id, now) > 0) { romPriestHolyCandleChargedForCast = true; } for (const friendlyTargetId of recipients) { useCombatStore.getState().applyTimedActorEffect(member.id, friendlyTargetId, ability, effect, now); } } else if (effect.kind === "shield") { for (const friendlyTargetId of healingRecipientIds(effect, member.id, targetId, combat.actors, id => partyFriendlyPosition(id) ?? getMobPosition(id), friendlyTargets, combatHasLineOfSight, now)) { effectiveAmount += useCombatStore.getState().shieldActor( friendlyTargetId, partyAbilityAmountWithHolyBlessing(runtimeMember, ability, effect, 0, now), effect.durationMs, member.id, ability, now, ); } } else if (effect.kind === "resurrection") { effectiveAmount += useCombatStore.getState().reviveActor( targetId, effect.percentMaxHealth, member.id, ability.id, now, ); } else if (effect.kind === "execute") { for (const hostileTargetId of hostileTargets) { const target = useCombatStore.getState().mobs[hostileTargetId]; if (target && !target.boss && target.health / Math.max(1, target.maxHealth) <= 0.2) { effectiveAmount += combat.damageMob( hostileTargetId, Math.max(target.health, partyAbilityAmountWithHolyBlessing(runtimeMember, ability, effect, 0, now)), now, { actorId: member.id, role: member.role, abilityId: ability.id }, spellSchool, ).amount; } } } else if (effect.kind === "interrupt") { let kickInterruptedCast = false; for (const hostileTargetId of hostileTargets) { const vowSilenceLockoutMs = ability.dbcSpellId === 490275 ? romPriestVowOfSilenceDurationMs(Boolean(combat.mobs[hostileTargetId]?.characterDefender)) : effect.lockoutMs; const interrupted = combat.interruptMob(hostileTargetId, ability.id, vowSilenceLockoutMs, now); if (interrupted && member.classId === "mage") { const silenceDurationMs = wow335MageImprovedCounterspellSilenceDurationMs( member.talentRanks[WOW335_MAGE_IMPROVED_COUNTERSPELL_TALENT_ID] ?? 0, ability.name, ); if (silenceDurationMs > 0) { useCombatStore.getState().controlMob( hostileTargetId, ability.id, "silence", silenceDurationMs, now, false, member.id, ); } } const kickSilenceDurationMs = partyRogueImprovedKickSilenceDurationMs( member, ability, hostileTargetId, now, ); if (kickSilenceDurationMs > 0) { useCombatStore.getState().controlMob( hostileTargetId, ability.id, "silence", kickSilenceDurationMs, now, false, member.id, ); } kickInterruptedCast ||= interrupted && ability.name === "Kick" && member.classId === "rogue"; } if (kickInterruptedCast) runtimeMember = grantPartyImprovedKickCapstone(runtimeMember, ability, now); } else if (effect.kind === "taunt") { const tauntTargets = effect.maxTargets === undefined ? hostileTargets : hostileTargets.slice(0, effect.maxTargets); for (const hostileTargetId of tauntTargets) { combat.tauntMob(hostileTargetId, { actorId: member.id, role: member.role, abilityId: ability.id }, effect.durationMs, now); } } else if (effect.kind === "status") { let kickInterruptedCast = false; for (const hostileTargetId of hostileTargets.slice(0, effect.maxTargets)) { if (effect.status === "interrupt") { const interrupted = useCombatStore.getState().interruptMob(hostileTargetId, ability.id, effect.durationMs, now); const kickSilenceDurationMs = partyRogueImprovedKickSilenceDurationMs( member, ability, hostileTargetId, now, ); if (kickSilenceDurationMs > 0) { useCombatStore.getState().controlMob( hostileTargetId, ability.id, "silence", kickSilenceDurationMs, now, false, member.id, ); } kickInterruptedCast ||= interrupted && ability.name === "Kick" && member.classId === "rogue"; } else { combat.controlMob(hostileTargetId, ability.id, effect.status, effect.durationMs, now, effect.breakOnDamage, member.id); } } if (kickInterruptedCast) runtimeMember = grantPartyImprovedKickCapstone(runtimeMember, ability, now); } else if (effect.kind === "apply-aura") { if (ability.name === "Inner Focus" || ability.name === "Divine Hymn") continue; const recipients = effect.scope === "party" ? [ ...(simulatedPartyExcludesPlayer() ? [] : [PLAYER_AGGRO_ID]), ...members.filter((candidate) => candidate.health > 0).map((candidate) => candidate.id), ] : effect.recipient === "caster" ? [member.id] : effect.recipient === "pet" ? partyHunterPetRecipientIds(combat.summons, member.id, now) : combat.mobs[targetId] ? hostileTargets : friendlyTargets; const inRangeRecipients = effect.scope === "party" && effect.radius !== undefined ? recipients.filter((id) => { const recipientPosition = id === PLAYER_AGGRO_ID ? combat.playerPosition : partyFriendlyPosition(id); return Boolean(recipientPosition && distanceSquared(origin, recipientPosition) <= effect.radius! ** 2 && combatHasLineOfSight(origin, recipientPosition)); }) : recipients; const hitFilteredRecipients = effect.requiresLandedHit && effect.scope !== "party" && effect.recipient !== "caster" && effect.recipient !== "pet" ? inRangeRecipients.filter((id) => landedDirectHitTargetIdsForCast.has(id)) : inRangeRecipients; for (const recipientId of hitFilteredRecipients.filter((id) => !effect.excludeCaster || id !== member.id)) { const frostWardingEffect = member.classId === "mage" ? wow335MageFrostWardingArmorAuraEffect( ability, member.talentRanks[WOW335_MAGE_FROST_WARDING_TALENT_ID] ?? 0, effect, ) : effect; const appliedEffect = ability.dbcSpellId === 490275 && combat.mobs[recipientId]?.characterDefender ? { ...effect, aura: { ...effect.aura, durationMs: romPriestVowOfSilenceDurationMs(true) } } : ability.name === "Rupture" ? { ...effect, aura: { ...effect.aura, durationMs: Math.max(4, Math.min(8, 3 + runtimeMember.comboPoints)) * 2_000 } } : ability.name === "Envenom" && effect.aura.utilityTags?.includes(ROGUE_ENVENOM_BUFF_TAG) ? { ...effect, aura: { ...effect.aura, durationMs: (runtimeMember.comboPoints + 1) * 1_000 }, } : effect.aura.id === "rom-knight-deadly-wound" && combat.mobs[recipientId]?.characterDefender ? { ...effect, aura: { ...effect.aura, durationMs: 5_000 } } : frostWardingEffect; useCombatStore.getState().applyActorAura(member.id, recipientId, ability, appliedEffect, now); if (ability.name === "Presence of Mind" && recipientId === member.id) { applyPartyMageArcanePotency(member, ability, "presence-of-mind", now); } if (effect.control && useCombatStore.getState().mobs[recipientId]) { useCombatStore.getState().controlMob(recipientId, ability.id, effect.control.kind, appliedEffect.aura.durationMs ?? 1_000, now); } } } else if (effect.kind === "dispel") { const recipients = effect.scope === "party" ? [...(simulatedPartyExcludesPlayer() ? [] : [PLAYER_AGGRO_ID]), ...members.map((candidate) => candidate.id)] : [targetId]; for (const recipientId of recipients) { effectiveAmount += useCombatStore.getState().dispelActor(recipientId, effect.categories, effect.maxCount); } } else if (effect.kind === "resource") { const resourceRecipients = effect.scope === "caster" ? [member.id] : effect.scope === "party" ? friendlyTargets : friendlyTargets.length ? friendlyTargets : [member.id]; for (const friendlyTargetId of resourceRecipients) { const resource = effect.resource ?? member.resourceType; if (effect.scope === "party" && !useCombatStore.getState().actors[friendlyTargetId]?.resources[resource]) continue; effectiveAmount += useCombatStore.getState().restoreActorResource( friendlyTargetId, resource, effect.amount, effect.percentage, member.id, ability.id, now, ); // Resource restoration writes through the authoritative actor store. // Mirror only its resource fields: the actor has not received this // action's cooldown/GCD yet, so a full actor-to-member copy would erase // the committed action state and let generators such as Bloodrage spam. const mutableMembers = members as PartyMember[]; const targetMemberIndex = mutableMembers.findIndex((candidate) => candidate.id === friendlyTargetId); if (targetMemberIndex >= 0) { const currentMember = friendlyTargetId === member.id ? runtimeMember : mutableMembers[targetMemberIndex]; const resourceMirror = memberFromCompanionActor( currentMember, useCombatStore.getState().actors[friendlyTargetId], ); const restoredPool = resourceMirror.resourcePools[resource]; if (restoredPool === undefined) continue; const updatedMember = { ...currentMember, ...(resource === currentMember.resourceType ? { resource: restoredPool, maxResource: resourceMirror.maxResource } : {}), resourcePools: { ...currentMember.resourcePools, [resource]: restoredPool }, ...(resourceMirror.lastResourceSpentAt[resource] === undefined ? {} : { lastResourceSpentAt: { ...currentMember.lastResourceSpentAt, [resource]: resourceMirror.lastResourceSpentAt[resource], } }), }; if (friendlyTargetId === member.id) runtimeMember = updatedMember; mutableMembers[targetMemberIndex] = updatedMember; } } } else if (effect.kind === "combo") { if ([490151, 499909].includes(ability.dbcSpellId ?? -1)) { useCombatStore.setState((state) => ({ effects: state.effects.filter((entry) => !(entry.kind === "dot" && entry.tag === "rom-holy-seal" && entry.sourceActorId === member.id && entry.targetId === targetId)), })); } if (targetId && ability.dbcSpellId === 499909 && member.classId === "rom-knight" && member.secondaryClassId === "rom-rogue") { const pairAbilities = abilitiesForCharacter(member.classId, member.secondaryClassId); const vengeanceBase = pairAbilities.find((candidate) => candidate.source === "runewaker" && candidate.classId === "rom-knight" && candidate.secondaryClassId === "rom-rogue" && candidate.dbcSpellId === 499934 && candidate.passive); const shadowStepBase = pairAbilities.find((candidate) => candidate.source === "runewaker" && candidate.classId === "rom-rogue" && candidate.dbcSpellId === 490344); const vengeance = vengeanceBase ? learnedAbilityForBuild(vengeanceBase, runtimeMember.level, runtimeMember.talentRanks) : null; const shadowStep = shadowStepBase ? learnedAbilityForBuild(shadowStepBase, runtimeMember.level, runtimeMember.talentRanks) : null; if (shadowStep && romKnightGodsVengeanceShouldResetShadowStep({ characterClassId: member.classId, secondaryClassId: member.secondaryClassId, ability, learnedPassiveSpellIds: vengeance ? [vengeance.dbcSpellId ?? -1] : [], shadowStepLearned: true, targetSealStacks: runtimeMember.comboTargetId === targetId ? runtimeMember.comboPoints : 0, sameTarget: runtimeMember.comboTargetId === targetId, landed: landedDirectHitTargetIdsForCast.has(targetId), })) { runtimeMember = { ...runtimeMember, cooldowns: { ...runtimeMember.cooldowns, [shadowStep.id]: 0 }, }; } } const current = runtimeMember.comboTargetId === targetId ? runtimeMember.comboPoints : 0; const comboPoints = Math.max(0, Math.min(member.classId === "rom-knight" ? 4 : 5, current + effect.amount)); runtimeMember = { ...runtimeMember, comboPoints, comboTargetId: comboPoints > 0 ? targetId : null, }; } else if (effect.kind === "summon") { if (member.classId === "death-knight" && ability.id === WOW335_DEATH_KNIGHT_DANCING_RUNE_WEAPON_ABILITY_ID) { useCombatStore.setState((state) => ({ auras: [...applyCombatAura(state.auras, { definition: WOW335_DEATH_KNIGHT_DANCING_RUNE_WEAPON_AURA, sourceId: member.id, targetId: member.id, now, }).auras], })); combat = useCombatStore.getState(); } else { const summonTargetId = combat.mobs[targetId] && !combat.mobs[targetId].dead ? targetId : combat.selectedTargetId && combat.mobs[combat.selectedTargetId] && !combat.mobs[combat.selectedTargetId].dead ? combat.selectedTargetId : Object.values(combat.mobs).find((mob) => mob.engaged && !mob.dead)?.id ?? null; useCombatStore.getState().summonActor(member.id, ability, effect, origin, summonTargetId, now); combat = useCombatStore.getState(); } } else if (effect.kind === "pet" && ["summon", "call", "resurrect", "tame"].includes(effect.action)) { const summonEffect: Extract = { kind: "summon", creatureId: effect.creatureId ?? 1, coefficient: 0.35, durationMs: Number.MAX_SAFE_INTEGER - now, }; const summonTargetId = combat.mobs[targetId] && !combat.mobs[targetId].dead ? targetId : combat.selectedTargetId && combat.mobs[combat.selectedTargetId] && !combat.mobs[combat.selectedTargetId].dead ? combat.selectedTargetId : Object.values(combat.mobs).find((mob) => mob.engaged && !mob.dead)?.id ?? null; useCombatStore.getState().summonActor(member.id, ability, summonEffect, origin, summonTargetId, now); } else if (effect.kind === "movement") { const targetPosition = combat.mobs[targetId] ? getMobPosition(targetId) : partyFriendlyPosition(targetId); if (ability.dbcSpellId === 490344) { const target = getMobRuntimeTransform(targetId); if (target) updatePartyRuntimePosition(member.id, target.position[0] - Math.sin(target.yaw) * 1.5, target.position[1], target.position[2] - Math.cos(target.yaw) * 1.5); } else if (ability.dbcSpellId === 490150 && targetPosition) { const dx = targetPosition[0] - origin[0]; const dz = targetPosition[2] - origin[2]; const length = Math.max(0.001, Math.hypot(dx, dz)); updatePartyRuntimePosition(member.id, targetPosition[0] - dx / length * 1.5, targetPosition[1], targetPosition[2] - dz / length * 1.5); } else if (ability.dbcSpellId === 54785 && targetPosition) { const dx = targetPosition[0] - origin[0]; const dz = targetPosition[2] - origin[2]; const length = Math.max(0.001, Math.hypot(dx, dz)); updatePartyRuntimePosition(member.id, targetPosition[0] - dx / length * 1.5, targetPosition[1], targetPosition[2] - dz / length * 1.5); } else if (effect.toward === "away") { const danger = Object.values(combat.mobs) .filter((mob) => mob.engaged && !mob.dead) .sort((left, right) => ( Number(right.targetActorId === member.id) - Number(left.targetActorId === member.id) || Number(right.boss) - Number(left.boss) || left.id.localeCompare(right.id) ))[0]; const dangerPosition = danger ? getMobPosition(danger.id) : null; if (dangerPosition) { const dx = origin[0] - dangerPosition[0]; const dz = origin[2] - dangerPosition[2]; const length = Math.max(0.001, Math.hypot(dx, dz)); const distance = effect.movement === "teleport" ? 15 : 10; updatePartyRuntimePosition( member.id, origin[0] + dx / length * distance, origin[1], origin[2] + dz / length * distance, ); } } else if (targetPosition && effect.movement !== "knockback") { updatePartyRuntimePosition(member.id, targetPosition[0], targetPosition[1], targetPosition[2]); } } else if (effect.kind === "threat") { combat.applyActorThreatEffect( member.id, effect, effect.mode === "redirect" && !combat.mobs[targetId] ? targetId : null, now, ); } else if (effect.kind === "cooldown-reset") { const candidates = cooldownResetCandidates( Object.keys(runtimeMember.cooldowns) .map((id) => abilityById(id)) .filter((candidate): candidate is AbilityDefinition => candidate !== null), ability.id, effect.abilityName, ).map((candidate) => candidate.id); const cooldowns = { ...runtimeMember.cooldowns }; for (const id of candidates) { const readyAt = cooldowns[id] ?? now; const reduction = effect.amountPercent !== undefined ? Math.max(0, readyAt - now) * Math.max(0, Math.min(1, effect.amountPercent)) : effect.amountMs ?? Number.MAX_SAFE_INTEGER; cooldowns[id] = Math.max(now, readyAt - reduction); } runtimeMember = { ...runtimeMember, cooldowns }; } else if (effect.kind === "resource-drain" && (!combat.mobs[targetId] || effect.recipient === "caster")) { const resource = effect.resource ?? runtimeMember.resourceType; const maximum = resource === runtimeMember.resourceType ? runtimeMember.maxResource : 100; const drained = effect.percentage ? maximum * effect.amount / 100 : effect.amount; const current = runtimeMember.resourcePools[resource] ?? 0; const actualDrain = Math.min(Math.max(0, current), Math.max(0, drained)); const resourcePools: Partial> = { ...runtimeMember.resourcePools, [resource]: Math.max(0, current - drained), }; if (resource === "nature-power" && effect.recipient === "caster") { applyPartyDruidMageInspirationSpend(runtimeMember, actualDrain, now); } runtimeMember = { ...runtimeMember, resourcePools, resource: resource === runtimeMember.resourceType ? resourcePools[resource] ?? runtimeMember.resource : runtimeMember.resource, }; } else if (effect.kind === "trigger-spell" && !effect.expanded && depth < 2) { const triggered = abilitiesForClass(member.classId).find((candidate) => candidate.dbcSpellId === effect.spellId) ?? triggeredAbilityBySpellId(effect.spellId); if (triggered && triggered.id !== ability.id) { const result = executePartyAbility(runtimeMember, triggered, targetId, now, members, depth + 1); runtimeMember = result.member; effectiveAmount += result.effectiveAmount; } } } if (isRomChampionDarkEnergyStrike && !channelPulse && targetId) { const runeSiphonBase = abilitiesForCharacter(member.classId, member.secondaryClassId) .find((candidate) => candidate.source === "runewaker" && candidate.classId === "rom-champion" && candidate.secondaryClassId === "rom-warlock" && candidate.dbcSpellId === ROM_CHAMPION_RUNE_SIPHON_ID); const learnedRuneSiphon = runeSiphonBase ? learnedAbilityForBuild(runeSiphonBase, member.level, member.talentRanks) : null; if (romChampionDarkEnergyStrikeShouldResetRuneSiphon({ characterClassId: member.classId, secondaryClassId: member.secondaryClassId, ability, runeSiphonLearned: learnedRuneSiphon !== null, singleTargetCast: hostileTargets.length === 1 && hostileTargets[0] === targetId, group0Survived: romChampionDarkEnergyStrikeGroup0SurvivedTargets.has(targetId), roll: stablePartyUnit( `${member.id}:${ability.id}:${targetId}:${now}:rom-champion-dark-energy-strike-rune-siphon`, ), }) && learnedRuneSiphon) { runtimeMember = { ...runtimeMember, cooldowns: { ...runtimeMember.cooldowns, [learnedRuneSiphon.id]: 0 }, }; useCombatStore.setState((state) => { const actor = state.actors[member.id]; return actor ? { actors: { ...state.actors, [member.id]: { ...actor, cooldowns: { ...actor.cooldowns, [learnedRuneSiphon.id]: 0 } }, }, } : {}; }); combat = useCombatStore.getState(); } } if (!channelPulse && depth === 0) { maybeProcPartyShamanTidalWaves(member, ability, directHealingRawAmount, now); } const meditationPassive = partyMeditationPathPassive(member); if (!channelPulse && depth === 0 && meditationPassive && romMageWarriorMeditationPathCanProc( member.classId, member.secondaryClassId, true, ability, )) { const current = useCombatStore.getState(); if (!romMageWarriorMeditationPathHasState( current.auras, member.id, ROM_MAGE_WARRIOR_MEDITATION_PATH_LOCKOUT_TAG, now, )) { useCombatStore.getState().applyActorAura( member.id, member.id, meditationPassive, ROM_MAGE_WARRIOR_MEDITATION_PATH_ACTIVE_EFFECT, now, ); useCombatStore.getState().applyActorAura( member.id, member.id, meditationPassive, ROM_MAGE_WARRIOR_MEDITATION_PATH_LOCKOUT_EFFECT, now, ); combat = useCombatStore.getState(); } } if (!channelPulse && member.classId === "priest" && combat.mobs[targetId] && ["Mind Flay", "Mind Sear", "Mind Blast"].includes(ability.name)) { const rank = Math.min(3, Math.max(0, Math.trunc(member.talentRanks["talent-1909"] ?? 0))); const shadowWordPainCatalog = abilitiesForClass("priest").find(candidate => candidate.name === "Shadow Word: Pain"); const shadowWordPain = shadowWordPainCatalog ? abilityAtLevel(shadowWordPainCatalog, member.level) : null; if (rank > 0 && shadowWordPain) { const current = useCombatStore.getState(); const refreshed = refreshPriestPainAndSufferingDot( current.effects, member.id, targetId, ability, rank, shadowWordPain, now, ); if (refreshed !== current.effects) useCombatStore.setState({ effects: refreshed }); } } const chargeRank = member.classId === "warrior" ? wow335WarriorChargeRank(ability.dbcSpellId) : 0; if (chargeRank > 0 && hostileTargets.length > 0) { const angerRank = member.talentRanks[WOW335_WARRIOR_ANGER_MANAGEMENT_TALENT_ID] ?? 0; const charge = wow335WarriorChargeRageProfile(chargeRank, angerRank); if (charge) { const resourcePools = { ...runtimeMember.resourcePools }; const rage = Math.min( companionResourceMaximum(runtimeMember, "rage"), (resourcePools.rage ?? runtimeMember.resource) + charge.totalRage, ); resourcePools.rage = rage; runtimeMember = { ...runtimeMember, resourcePools, ...(runtimeMember.resourceType === "rage" ? { resource: rage } : {}), }; const angerManagement = wow335WarriorAngerManagementProfile(angerRank); if (angerManagement && angerManagement.chargeDamageReductionPercent > 0 && angerManagement.chargeDamageReductionDurationMs > 0) { useCombatStore.getState().applyActorAura(member.id, member.id, ability, { kind: "apply-aura", recipient: "caster", sourceEffectType: -1, sourceAuraType: -1, miscValue: 0, triggerSpellId: angerManagement.chargeDamageReductionSpellId ?? 0, aura: { id: "wow335:warrior:anger-management-charge-reduction", name: "Anger Management", disposition: "buff", durationMs: angerManagement.chargeDamageReductionDurationMs, maxStacks: 1, stackBehavior: "replace", utilityTags: [WOW335_WARRIOR_ANGER_MANAGEMENT_CHARGE_AURA_TAG], modifiers: [{ kind: "damage", direction: "taken", operation: "percent", value: -angerManagement.chargeDamageReductionPercent / 100, }], }, }, now); } } } if (ability.name === "Chimera Shot" && effectiveAmount > 0 && combat.mobs[targetId]) { effectiveAmount += useCombatStore.getState().triggerChimeraShot(member.id, targetId, ability.id, now); } if (consumesColdBlood) useCombatStore.getState().consumeActorAuraTag(member.id, "next-offensive-critical"); if (consumesShamanNaturesGuardianCrit) { useCombatStore.getState().consumeActorAuraTag( member.id, WOW335_SHAMAN_NATURES_GUARDIAN_NEXT_NATURE_CRIT_TAG, ); } if (ability.dbcSpellId === 491171) useCombatStore.getState().consumeActorAuraTag(member.id, "rom-charged"); if (ability.dbcSpellId === 491172) useCombatStore.getState().consumeActorAuraTag(member.id, "rom-static-field-charge"); if (ability.dbcSpellId === 498759) { const currentCombat = useCombatStore.getState(); const reset = romFireLightningBurstReset( ability, Boolean(currentCombat.mobs[targetId] && !currentCombat.mobs[targetId].dead && currentCombat.mobs[targetId].health > 0), currentCombat.auras.some((aura) => aura.targetId === member.id && aura.definition.utilityTags?.includes(ROM_FIRE_LIGHTNING_BURST_RESET_GUARD_TAG)), ); if (reset.reset) { useCombatStore.getState().applyActorAura(member.id, member.id, ability, { kind: "apply-aura", recipient: "caster", sourceEffectType: -1, sourceAuraType: -1, miscValue: 0, triggerSpellId: 621514, aura: { id: "rom-fire-lightning-burst-reset-guard", name: "Fire Lightning Burst reset guard", disposition: "buff", durationMs: reset.guardDurationMs, utilityTags: [ROM_FIRE_LIGHTNING_BURST_RESET_GUARD_TAG], }, }, now); runtimeMember = { ...runtimeMember, cooldowns: { ...runtimeMember.cooldowns, [ability.id]: 0 } }; } } if (!channelPulse && depth === 0) applyPartyWarlockBackdraft(member, ability, now); emitPartyAbility(member, ability, targetId, "release", now, !castStartAlreadyEmitted); emitPartyAbility(member, ability, targetId, "impact", now, !castStartAlreadyEmitted); if (effectiveAmount > 0 && ability.effects.some((effect) => ["heal", "hot", "shield", "resurrection"].includes(effect.kind))) { useCombatStore.getState().addHealingThreat({ actorId: member.id, role: member.role, abilityId: ability.id }, effectiveAmount); } if (suddenDoomTrigger && depth === 0) { const deathCoilBase = abilitiesForCharacter(member.classId, member.secondaryClassId) .find((candidate) => candidate.id === suddenDoomTrigger.abilityId); const deathCoil = deathCoilBase ? learnedAbilityForBuild(deathCoilBase, member.level, member.talentRanks) : null; if (deathCoil) { const execution = executePartyAbility( runtimeMember, deathCoil, suddenDoomTrigger.targetId, now, members, depth + 1, ); runtimeMember = execution.member; effectiveAmount += execution.effectiveAmount; } } if (!channelPulse && runtimeMember.classId === "rom-rogue" && runtimeMember.secondaryClassId === "rom-champion") { const passiveSource = abilitiesForCharacter(runtimeMember.classId, runtimeMember.secondaryClassId) .find((candidate) => candidate.source === "runewaker" && candidate.classId === "rom-rogue" && candidate.secondaryClassId === "rom-champion" && candidate.passive && candidate.dbcSpellId === ROM_ROGUE_CHAMPION_NERVE_GEAR_PASSIVE_SPELL_ID); const passive = passiveSource ? learnedAbilityForBuild(passiveSource, runtimeMember.level, runtimeMember.talentRanks) : null; if (passive && romRogueChampionNerveGearShouldTrigger( runtimeMember.classId, runtimeMember.secondaryClassId, ability, true, )) { useCombatStore.getState().applyActorAura( runtimeMember.id, runtimeMember.id, passive, romRogueChampionNerveGearEffect(), now, ); } } return { member: runtimeMember, effectiveAmount }; } function membersDiffer(left: readonly PartyMember[], right: readonly PartyMember[]): boolean { if (left === right) return false; if (left.length !== right.length) return true; return left.some((member, index) => member !== right[index]); } /** * Low-frequency party simulation. Call immediately after mob combat so a living * tank can redirect that tick's pressure before the death/respawn boundary. */ export function advanceDungeonPartyCombat(now = Date.now()): void { const combat = useCombatStore.getState(); const party = usePartyStore.getState(); if (!party.members.length) return; const deltaSeconds = lastAdvanceAt > 0 ? Math.max(0, Math.min(1, (now - lastAdvanceAt) / 1_000)) : 0; lastAdvanceAt = now; const authoritativeMembers: readonly PartyMember[] = party.members.map((member) => ( memberFromCompanionActor(member, combat.actors[member.id]) )); const simulation = useDungeonSimulationStore.getState(); if (simulation.sessionMode === "spectate-live" && simulation.mode === "navigation-only") { const members = authoritativeMembers.map((member) => ({ ...member, status: member.health > 0 ? "advancing" as const : "down" as const, activeCast: null, activeCastTargetId: null, nextActionAt: 0, })); party.commitRuntime({ members, activeMobId: null, objectiveBossId: simulation.objectiveId, memberTargetIds: {}, }); useCombatStore.getState().commitCompanionActors(members); return; } let members: readonly PartyMember[] = authoritativeMembers; const partyOrigin = partyRuntimeCenter( members.filter((member) => !partyRuntimeIsRejoining(member.id)).map((member) => member.id), combat.playerPosition, ); let targetId = partyCommandTargetId( party.command, combat.mobs, combat.selectedTargetId, party.activeMobId, partyOrigin, ); if (!targetId && party.command !== "stop") targetId = activeRaceOrbId(); const nativePartyInCombat = Boolean(targetId) || Object.values(combat.mobs).some((mob) => ( !mob.dead && (mob.engaged || Object.values(mob.threatByActor).some((threat) => threat > 0)) )); members = members.map((member) => { const profile = resourceProfileForClass(member.classId); const memberAuras = combat.auras.filter((aura) => aura.targetId === member.id && (aura.expiresAt === null || aura.expiresAt > now)); const nativeRecoveryBaseStats = member.statRuleset === "rom" && isRomClassId(member.classId) ? deriveCharacterStats( member.classId, member.level, member.gearStats, member.raceId, {}, member.secondaryClassId, member.gender, "rom", ) : undefined; const nativeRecoveryBase = nativeRecoveryBaseStats?.romNative; const living = member.health > 0; const recoveryClockAdvance = living ? advanceRomNativeRecoveryClock( member.romNativeRecoveryClock, member.statRuleset ?? "wow", "party-npc", deltaSeconds * 1_000, ) : { clock: undefined, pulseOffsetsMs: [] as readonly number[] }; let nativeHealthAfterRecovery = member.health; let nativeManaPulseAmount = 0; if (living && nativeRecoveryBaseStats && nativeRecoveryBase) { const intervalStartAt = now - deltaSeconds * 1_000; for (const offsetMs of recoveryClockAdvance.pulseOffsetsMs) { const pulseAt = intervalStartAt + offsetMs; const pulseStats = auraAdjustedCharacterStats( nativeRecoveryBaseStats, combat.auras.filter((aura) => aura.expiresAt === null || aura.expiresAt > pulseAt), member.id, pulseAt, ).romNative; if (!pulseStats) continue; const amount = romNativeRecoveryPulseAmounts(pulseStats, "party-npc", nativePartyInCombat); nativeHealthAfterRecovery += amount.health; nativeManaPulseAmount += amount.mana; } } nativeHealthAfterRecovery = Math.min(member.maxHealth, nativeHealthAfterRecovery); const nativeManaMaximum = companionResourceMaximum(member, "mana"); const nativeManaPoolEnabled = Boolean(nativeRecoveryBase?.usesMana && nativeManaMaximum > 0); const nativeManaPeriodicAmount = nativeManaPoolEnabled && living ? romNativePeriodicManaEffectDuring( combat.auras.filter((aura) => aura.targetId === member.id), now - deltaSeconds * 1_000, now, nativeManaMaximum, ) : 0; const nativeManaAfterRecovery = nativeManaPoolEnabled ? Math.max(0, Math.min( nativeManaMaximum, (member.resourcePools.mana ?? (member.resourceType === "mana" ? member.resource : 0)) + (living ? nativeManaPulseAmount + nativeManaPeriodicAmount : 0), )) : undefined; const passiveRates: Partial> = { mana: 4, rage: 0, energy: 10, focus: 6, "runic-power": 0, }; const resourcePools: Partial> = { ...member.resourcePools }; if (nativeManaAfterRecovery !== undefined) resourcePools.mana = nativeManaAfterRecovery; for (const type of Object.keys(resourcePools) as ResourceType[]) { if (type === "mana" && nativeManaAfterRecovery !== undefined) continue; const manaRegeneration = member.gearStats.mp5 / 5 + member.gearStats.spirit * 0.02; const baseRegeneration = type === member.resourceType ? (profile.type === "rage" || profile.type === "runic-power" ? 0 : profile.regenerationPerSecond + manaRegeneration) : (passiveRates[type] ?? 0) + (type === "mana" ? manaRegeneration : 0); const regeneration = resolveAuraResourceValue(baseRegeneration, memberAuras, type, "regeneration"); resourcePools[type] = advanceCombatResourcePool(normalizeActorResourcePool( type, resourcePools[type] ?? 0, companionResourceMaximum(member, type), regeneration, member.lastResourceSpentAt[type] ?? 0, ), { inCombat: Boolean(targetId), actorLevel: member.level, now, deltaSeconds, manaSpiritRegenerationPerSecond: type === "mana" ? member.gearStats.spirit * 0.02 : 0, }).current; } const angerManagement = member.classId === "warrior" ? wow335WarriorAngerManagementProfile( member.talentRanks[WOW335_WARRIOR_ANGER_MANAGEMENT_TALENT_ID] ?? 0, ) : null; const angerManagementElapsedMs = angerManagement ? (member.warriorAngerManagementRageRemainderMs ?? 0) + Math.round(deltaSeconds * 1_000) : 0; const angerManagementTicks = angerManagement ? Math.floor(angerManagementElapsedMs / angerManagement.tickIntervalMs) : 0; const warriorAngerManagementRageRemainderMs = angerManagement ? angerManagementElapsedMs % angerManagement.tickIntervalMs : 0; if (angerManagement && angerManagementTicks > 0) { const rage = Math.min( companionResourceMaximum(member, "rage"), (resourcePools.rage ?? 0) + angerManagementTicks * angerManagement.ragePerTick, ); resourcePools.rage = rage; } return { ...member, health: nativeHealthAfterRecovery, resource: resourcePools[member.resourceType] ?? member.resource, resourcePools, romNativeRecoveryClock: living ? recoveryClockAdvance.clock : undefined, warriorAngerManagementRageRemainderMs, cooldowns: Object.fromEntries(Object.entries(member.cooldowns).filter(([, readyAt]) => readyAt > now)), schoolLockedUntil: Object.fromEntries(Object.entries(member.schoolLockedUntil).filter(([, readyAt]) => (readyAt ?? 0) > now)), }; }); const objectiveBossId = nextPartyBossObjective(combat.mobs)?.id ?? null; let memberTargetIds: Record = {}; const shouldRegen = !targetId && now >= nextRegenAt; if (shouldRegen) nextRegenAt = now + 1_000; if (!targetId) { members = members.map((member) => { if ( member.health <= 0 || member.statRuleset === "rom" || ( member.controlledUntil > now && enemyControlPreventsActions(member.controlMechanic) ) || !shouldRegen || deltaSeconds <= 0 ) return member; return { ...member, health: Math.min( member.maxHealth, member.health + Math.max(1, member.maxHealth * OUT_OF_COMBAT_REGEN_FRACTION), ), }; }); } // A defeated player can watch the encounter continue while waiting for a // resurrection. Living companions remain active until the whole party falls. if (members.some((member) => member.health > 0)) { const nextMembers = [...members]; for (let index = 0; index < nextMembers.length; index += 1) { let member = nextMembers[index]; if (member.health <= 0) { if (member.status !== "down") nextMembers[index] = { ...member, status: "down" }; continue; } if (partyRuntimeIsRejoining(member.id)) { const canceled = member.activeCast ? awardPartyPriestChampionRageControl(member, member.activeCast, now) : member; nextMembers[index] = { ...canceled, status: "advancing", activeCast: null, activeCastTargetId: null }; continue; } if ( member.controlledUntil > now && enemyControlPreventsActions(member.controlMechanic) ) { nextMembers[index] = member.status === "holding" ? member : { ...member, status: "holding" }; continue; } if (combat.auras.some((aura) => aura.targetId === member.id && aura.definition.utilityTags?.includes("pacified"))) { nextMembers[index] = member.status === "holding" ? member : { ...member, status: "holding" }; continue; } const activeMember = member.controlMechanic && member.controlledUntil <= now ? { ...member, controlledUntil: 0, controlMechanic: null } : member; member = activeMember; const currentCombat = useCombatStore.getState(); if (member.activeCast) { const activeCast = member.activeCast; const catalogAbility = abilityById(member.activeCast.abilityId); const castingAbility = catalogAbility ? learnedAbilityForBuild(catalogAbility, member.level, member.talentRanks) : null; const castTargetId = member.activeCastTargetId; if (!castingAbility || !castTargetId) { const canceled = awardPartyPriestChampionRageControl(member, activeCast, now); nextMembers[index] = { ...canceled, activeCast: null, activeCastTargetId: null }; continue; } const hostileCast = Boolean(currentCombat.mobs[castTargetId]); const desiredStatus = hostileCast ? member.role === "tank" ? "guarding" : "attacking" : "healing"; const hostilePosition = hostileCast ? getMobPosition(castTargetId) : null; if (hostileCast && (!hostilePosition || !combatHasLineOfSight( getPartyRuntimePosition(member.id) ?? currentCombat.playerPosition, hostilePosition, ))) { emitPartyAbility(member, castingAbility, castTargetId, "cast-cancel", now); memberTargetIds[member.id] = castTargetId; const canceled = awardPartyPriestChampionRageControl(member, activeCast, now); nextMembers[index] = { ...canceled, activeCast: null, activeCastTargetId: null, status: "advancing", nextActionAt: now + 250 }; continue; } if (member.activeCast.mode === "channel") { const cast = member.activeCast; if (!partyAbilityTargetIsValid( member, castingAbility, castTargetId, cast.origin, now, cast.canCastWhileMoving, )) { emitPartyAbility(member, castingAbility, castTargetId, "cast-cancel", now); const canceled = awardPartyPriestChampionRageControl(member, cast, now); nextMembers[index] = { ...canceled, activeCast: null, activeCastTargetId: null, status: "advancing", nextActionAt: now + 250 }; continue; } const totalTicks = channelPulseCount(castingAbility); let completed = cast.channelTicksCompleted ?? 0; while (completed < totalTicks) { const pulseAt = channelPulseAt(castingAbility, cast.startedAt, cast.originalDurationMs, completed); if (pulseAt > now || pulseAt > cast.completesAt) break; const effects: AbilityEffect[] = channelAmountEffects(castingAbility, totalTicks).map(({ effect, multiplier }) => { const amount = Math.round(partyAbilityAmountWithHolyBlessing(member, castingAbility, effect, 0, pulseAt) * multiplier); if (effect.kind === "heal" || effect.kind === "hot") return { kind: "heal", coefficient: 0, basePoints: amount, sourceLevel: member.level, ...("scope" in effect ? { scope: effect.scope, center: effect.center, radius: effect.radius, maximumTargets: effect.maximumTargets, recipient: effect.recipient, } : {}), ...(effect.kind === "heal" && effect.percentMaxHealth !== undefined ? { percentMaxHealth: effect.percentMaxHealth * multiplier } : {}), }; return { kind: "damage", coefficient: 0, basePoints: amount, sourceLevel: member.level }; }); const execution = executePartyAbility(member, { ...castingAbility, effects }, castTargetId, pulseAt, nextMembers, 0, true, true, cast.tidalWavesChargesConsumed ?? 0, cast.arcanePotencyCriticalChanceBonusPercent ?? 0, cast.manaSpent ?? 0, cast.maelstromWeaponRank ?? 0, cast.maelstromWeaponStacksConsumed ?? 0); member = execution.member; completed += 1; } member = { ...member, activeCast: { ...(member.activeCast ?? cast), channelTicksCompleted: completed } }; } if (now < activeCast.completesAt) { nextMembers[index] = member.status === desiredStatus ? member : { ...member, status: desiredStatus }; if (hostileCast) memberTargetIds[member.id] = castTargetId; continue; } const completedMember = { ...member, activeCast: null, activeCastTargetId: null }; const valid = partyAbilityTargetIsValid( completedMember, castingAbility, castTargetId, activeCast.origin, now, activeCast.canCastWhileMoving, ); if (valid) { const execution = executePartyAbility( completedMember, activeCast.mode === "channel" ? { ...castingAbility, effects: castingAbility.effects.filter((effect) => !["damage", "dot", "heal", "hot"].includes(effect.kind)) } : castingAbility, castTargetId, now, nextMembers, 0, true, false, activeCast.tidalWavesChargesConsumed ?? 0, activeCast.arcanePotencyCriticalChanceBonusPercent ?? 0, activeCast.manaSpent ?? 0, activeCast.maelstromWeaponRank ?? 0, activeCast.maelstromWeaponStacksConsumed ?? 0, undefined, activeCast.illuminationBaseManaCost, ); const awardedMember = activeCast.mode === "channel" ? awardPartyPriestChampionRageControl(execution.member, activeCast, now) : execution.member; nextMembers[index] = { ...awardedMember, status: desiredStatus, nextActionAt: activeCast.mode === "channel" ? Math.max(now + 250, execution.member.globalCooldownEndsAt) : now + Math.max(250, activeCast.backdraftGcdMs ?? Math.round(castingAbility.gcdMs / raceAffixHasteMultiplier(now))), }; } else { const interruptedMember = awardPartyPriestChampionRageControl(completedMember, activeCast, now); nextMembers[index] = { ...interruptedMember, status: hostileCast ? "advancing" : "ready", nextActionAt: now + 250, }; } continue; } const supportIntent = choosePartySupportIntent(member, nextMembers, now) ?? partyGreenGuardianIntent(member, now, Boolean(targetId)); if (supportIntent) { delete memberTargetIds[member.id]; if (now < member.nextActionAt) { nextMembers[index] = member.status === "healing" ? member : { ...member, status: "healing" }; continue; } if (!partyAbilityTargetIsValid(member, supportIntent.ability, supportIntent.targetId, undefined, now)) { nextMembers[index] = { ...member, status: "advancing", nextActionAt: now + 250 }; continue; } const backdraftCast = partyWarlockBackdraftCast(member, supportIntent.ability, now); const tidalWavesCast = partyShamanTidalWavesCast(member, supportIntent.ability, now); const selectedCastAbility = backdraftCast?.ability ?? tidalWavesCast?.ability ?? supportIntent.ability; const elementalFocusAura = partyShamanElementalFocusAuraForCast(member, selectedCastAbility, now); const maelstromCast = partyShamanMaelstromCast(member, selectedCastAbility, now); const consumesHunterLockAndLoad = partyHunterLockAndLoadReady(member, selectedCastAbility, now); const castAbility = partyAbilityWithHunterLockAndLoad(member, maelstromCast?.ability ?? selectedCastAbility, now); const castingHastePercent = partyCastingHastePercent(member, now); let committed = commitPartyAbilityUseWithRogueCooldown(member, castAbility, now, { ignoreResourceCosts: pendingInnerFocus.has(member.id) && abilityHasDirectHealing(castAbility), }); if (!committed) { nextMembers[index] = { ...member, status: "ready", nextActionAt: now + 250 }; continue; } applyPartyRomChampionRunePulseOnUse(member, castAbility, now); if (castAbility.gcdMs > 0 && castingHastePercent > 0) { committed = { ...committed, globalCooldownEndsAt: now + Math.max(1_000, Math.round( castAbility.gcdMs / (1 + castingHastePercent / 100), )), }; } applyPartyDeathKnightBladeBarrier(member, committed, castAbility, now); if (maelstromCast) consumePartyShamanMaelstromWeapon(member.id, maelstromCast.auraInstanceId); const committedAfterHunterLockAndLoad = consumesHunterLockAndLoad ? { ...committed, cooldowns: { ...committed.cooldowns, [castAbility.id]: 0 } } : committed; if (consumesHunterLockAndLoad) consumePartyHunterLockAndLoad(member.id); const mageAuras = useCombatStore.getState().auras.filter((aura) => aura.targetId === member.id); const arcanePotencyBonus = partyMageArcanePotencyBonus( member, castAbility, mageAuras.some((aura) => aura.definition.utilityTags?.includes(WOW335_MAGE_ARCANE_POTENCY_TAG)), ); const manaSpent = partyAbilityCosts(member, castAbility, now) .filter((cost) => cost.resource === "mana") .reduce((total, cost) => total + cost.amount, 0); if (elementalFocusAura) consumePartyShamanElementalFocus(member.id, elementalFocusAura.instanceId); consumePartyMageCastAuras(member, castAbility); if (backdraftCast) consumePartyWarlockBackdraftCharge(member.id, backdraftCast.auraInstanceId); if (tidalWavesCast) consumePartyShamanTidalWaves( member.id, tidalWavesCast.auraInstanceId, ); const instantOutOfCombatResurrection = !targetId && castAbility.effects.some((effect) => effect.kind === "resurrection"); if (castAbility.castTimeMs > 0 && !instantOutOfCombatResurrection) { const castTimeMs = backdraftCast ? partyDurationMsWithHaste(backdraftCast.castTimeMs, now, castingHastePercent) : partyCastTimeMsWithHaste(castAbility, now, castingHastePercent); const castOrigin = getPartyRuntimePosition(member.id) ?? currentCombat.playerPosition; nextMembers[index] = { ...committedAfterHunterLockAndLoad, status: "healing", activeCast: { abilityId: castAbility.id, school: presentationSchoolForAbility(castAbility), mode: castAbility.castMode === "channel" ? "channel" : "cast", startedAt: now, completesAt: now + castTimeMs, originalDurationMs: castTimeMs, pushbackHits: 0, canCastWhileMoving: partyCastCanMoveDuringMeditationPath(member, castAbility, now), origin: castOrigin, targetId: supportIntent.targetId, ...(backdraftCast ? { backdraftGcdMs: backdraftCast.gcdMs } : {}), ...(tidalWavesCast ? { tidalWavesChargesConsumed: tidalWavesCast.chargesConsumed } : {}), ...(maelstromCast ? { maelstromWeaponRank: maelstromCast.rank, maelstromWeaponStacksConsumed: maelstromCast.stacksConsumed, } : {}), ...(arcanePotencyBonus > 0 ? { arcanePotencyCriticalChanceBonusPercent: arcanePotencyBonus } : {}), ...(member.classId === "mage" ? { manaSpent } : {}), ...(member.classId === "paladin" ? { illuminationBaseManaCost: abilityAtLevel(abilityById(castAbility.id) ?? castAbility, member.level).cost.amount } : {}), }, activeCastTargetId: supportIntent.targetId, nextActionAt: now + castTimeMs, }; applyPartyMageNetherwindPresenceCast(member, castAbility, now); emitPartyAbility(member, castAbility, supportIntent.targetId, "cast-start", now); } else { applyPartyMageNetherwindPresenceCast(member, castAbility, now); const execution = executePartyAbility( committedAfterHunterLockAndLoad, castAbility, supportIntent.targetId, now, nextMembers, 0, false, false, tidalWavesCast?.chargesConsumed ?? 0, arcanePotencyBonus, manaSpent, maelstromCast?.rank ?? 0, maelstromCast?.stacksConsumed ?? 0, { runesBefore: member.runes, spentRuneSlots: wow335DeathKnightSpentRuneSlots(member.runes, committed.runes, now), }, ); nextMembers[index] = { ...execution.member, status: "healing", nextActionAt: now + Math.max(250, backdraftCast?.gcdMs ?? Math.round(castAbility.gcdMs / raceAffixHasteMultiplier(now))), }; } continue; } targetId = partyCommandTargetId( party.command, currentCombat.mobs, currentCombat.selectedTargetId, targetId, partyOrigin, ); if (!targetId && party.command !== "stop") targetId = activeRaceOrbId(); if (!targetId) { nextMembers[index] = member.status === "ready" && member.nextActionAt === 0 ? member : { ...member, status: party.command === "stop" ? "holding" : "ready", nextActionAt: 0 }; continue; } const desiredStatus = member.role === "tank" ? "guarding" : "attacking"; const memberPosition = getPartyRuntimePosition(member.id) ?? currentCombat.playerPosition; const memberTargetId = member.role === "damage" && party.command !== "stop" && activeRaceOrbId() ? activeRaceOrbId() : member.role === "tank" ? tankPriorityTargetId( currentCombat.mobs, nextMembers, member.id, memberPosition, now, targetId, ) : targetId; if (!memberTargetId) continue; memberTargetIds[member.id] = memberTargetId; const targetPosition = getMobPosition(memberTargetId); const targetVisible = Boolean( targetPosition && partyRuntimeTargetVisible(member.id, memberTargetId) ); let runtimeMember = targetVisible ? advancePartyWeaponAttack(member, memberTargetId, now) : member; if (useCombatStore.getState().mobs[memberTargetId]?.dead) { nextMembers[index] = { ...runtimeMember, status: desiredStatus }; continue; } const targetMob = currentCombat.mobs[memberTargetId]; if (now < member.nextActionAt) { if (runtimeMember.status !== desiredStatus) { runtimeMember = { ...runtimeMember, status: desiredStatus }; } nextMembers[index] = runtimeMember; continue; } const hasDangerousThreat = member.role !== "tank" && targetMob?.targetActorId === member.id; const enemyUsesMeleeAttacks = targetMob?.attacks.some((attack) => ( attack.delivery === "melee" || attack.range <= 5 )) ?? false; const incomingMeleeDanger = Boolean(targetMob?.activeCast && targetMob.activeCast.targetActorId === member.id && targetMob.activeCast.completesAt >= now && targetMob.activeCast.completesAt - now <= 3_000 && targetMob.attacks.some((attack) => attack.id === targetMob.activeCast?.attackId && (attack.delivery === "melee" || attack.range <= 5) && attack.damageMultiplier >= 1.25)); const availableConditionalAbilityNames = new Set(); if (currentCombat.combatEvents.some((event) => ( event.targetActorId === member.id && event.occurredAt > now - 5_000 && ["dodge", "parry", "block"].includes(event.outcome ?? "") ))) availableConditionalAbilityNames.add("Revenge"); if (member.classId === "warrior" && currentCombat.auras.some((aura) => ( aura.targetId === member.id && (aura.expiresAt === null || aura.expiresAt > now) && (aura.definition.utilityTags?.includes(WOW335_WARRIOR_IMPROVED_VICTORY_RUSH_READY_TAG) || aura.definition.utilityTags?.includes(WOW335_WARRIOR_VICTORY_RUSH_KILL_READY_TAG)) ))) availableConditionalAbilityNames.add("Victory Rush"); const selectedDecision = choosePartyRotationDecision(runtimeMember, { now, target: targetMob ?? null, targetIsLoose: member.role === "tank" && targetMob?.targetActorId !== member.id, hasDangerousThreat, enemyUsesMeleeAttacks, incomingMeleeDanger, needsEscape: hasDangerousThreat || member.health / Math.max(1, member.maxHealth) <= 0.4 || incomingMeleeDanger, moving: runtimeMember.status === "advancing", incomingDefensiveMechanic: imminentCooldownMechanic(targetMob ?? null, member.role, now), availableConditionalAbilityNames: availableConditionalAbilityNames.size > 0 ? availableConditionalAbilityNames : undefined, hostileCount: Object.values(currentCombat.mobs).filter((mob) => mob.engaged && !mob.dead).length, friendlyTargets: nextMembers.filter((candidate) => candidate.health > 0).map((candidate) => ({ id: candidate.id, health: candidate.health, maxHealth: candidate.maxHealth, role: candidate.role, })), partyResourceDeficit: (ability) => partyAbilityHasDepletedPartyResource( ability, partyResourcePoolSnapshotsInRange(ability, runtimeMember, nextMembers), ), maintainedAbilityIds: new Set([ ...currentCombat.effects.filter((effect) => effect.sourceActorId === member.id && effect.remainingTicks > 0) .map((effect) => effect.sourceAbilityId), ...currentCombat.auras.filter((aura) => aura.sourceId === member.id && (aura.expiresAt === null || aura.expiresAt > now)) .flatMap((aura) => abilitiesForCharacter(member.classId, member.secondaryClassId) .map((ability) => learnedAbilityForBuild(ability, member.level, member.talentRanks)) .filter((ability): ability is AbilityDefinition => ability !== null) .filter((ability) => ability.effects.some((effect) => effect.kind === "apply-aura" && effect.aura.id === aura.definition.id)) .map((ability) => ability.id)), ]), activeAuraExclusiveGroups: new Set(currentCombat.auras .filter((aura) => aura.sourceId === member.id && (aura.expiresAt === null || aura.expiresAt > now)) .flatMap((aura) => aura.definition.exclusiveGroup ? [aura.definition.exclusiveGroup] : [])), activeSummonAbilityIds: new Set(currentCombat.summons .filter((summon) => summon.ownerActorId === member.id && summon.health > 0 && summon.expiresAt > now) .map((summon) => summon.sourceAbilityId)), hasActivePet: currentCombat.summons.some((summon) => { if (summon.ownerActorId !== member.id || summon.health <= 0 || summon.expiresAt <= now) return false; const source = abilityById(summon.sourceAbilityId); return source?.effects.some((effect) => effect.kind === "pet" && ["summon", "call", "resurrect", "tame"].includes(effect.action)) ?? false; }), canUseAbility: (ability) => { const hints = new Set(aiHintsForAbility(ability).categories); const hostileSelfArea = ability.target === "self" && (ability.radius ?? 0) > 0 && (["damage", "execute", "debuff", "control", "interrupt", "taunt"] as const) .some((category) => hints.has(category)); const abilityTargetId = (ability.target === "self" && !hostileSelfArea) || ability.target === "friendly" ? member.id : memberTargetId; const minimumHostiles = minimumHostileTargetsForAbility(ability.name); return partyAbilityTargetIsValid(runtimeMember, ability, abilityTargetId, undefined, now) && (minimumHostiles <= 1 || partyHostileTargets( ability, abilityTargetId, memberPosition, member.id, ).length >= minimumHostiles) && (ability.name !== "Envenom" || matchingAuraStacks( currentCombat.auras, ROGUE_DEADLY_POISON_DOSE_TAG, member.id, abilityTargetId, now, ) >= Math.min(5, runtimeMember.comboPoints)) && (ability.name !== "Shiv" || ( runtimeMember.comboPoints >= 4 && matchingAuraStacks( currentCombat.auras, ROGUE_DEADLY_POISON_DOSE_TAG, member.id, abilityTargetId, now, ) < Math.min(5, runtimeMember.comboPoints) )) // Let maintained effects tick instead of resetting their first tick on every GCD. && !currentCombat.effects.some((effect) => effect.sourceActorId === member.id && effect.sourceAbilityId === ability.id && effect.targetId === abilityTargetId && effect.remainingTicks > 0) && !currentCombat.areaEffects.some((effect) => effect.sourceActorId === member.id && effect.sourceAbilityId === ability.id && effect.expiresAt > now) && !ability.effects.some((effect) => effect.kind === "apply-aura" && currentCombat.auras.some((aura) => aura.sourceId === member.id && aura.definition.id === effect.aura.id && aura.targetId === abilityTargetId)); }, }); const selectedAbility = selectedDecision?.ability ?? null; if (!selectedAbility) { const inAttackRange = targetVisible && targetPosition && distanceSquared(memberPosition, targetPosition) <= partyAttackRange(member) ** 2; nextMembers[index] = { ...runtimeMember, status: inAttackRange ? desiredStatus : "advancing", nextActionAt: now + 250 }; continue; } const abilityTargetId = selectedDecision?.targetId ?? (selectedAbility.target === "self" || selectedAbility.target === "friendly" ? member.id : memberTargetId); const inRange = (selectedAbility.target === "self" || targetVisible) && partyAbilityTargetIsValid(runtimeMember, selectedAbility, abilityTargetId, undefined, now); if (!inRange) { nextMembers[index] = runtimeMember.status === "advancing" ? runtimeMember : { ...runtimeMember, status: "advancing" }; continue; } const sniperAdjustedAbility = partyScoutPriestSniperAbility(runtimeMember, selectedAbility); const backdraftCast = partyWarlockBackdraftCast(runtimeMember, sniperAdjustedAbility, now); const tidalWavesCast = partyShamanTidalWavesCast(runtimeMember, selectedAbility, now); const selectedCastAbility = backdraftCast?.ability ?? tidalWavesCast?.ability ?? sniperAdjustedAbility; const elementalFocusAura = partyShamanElementalFocusAuraForCast(runtimeMember, selectedCastAbility, now); const maelstromCast = partyShamanMaelstromCast(runtimeMember, selectedCastAbility, now); const consumesHunterLockAndLoad = partyHunterLockAndLoadReady(runtimeMember, selectedCastAbility, now); const castAbility = partyAbilityWithHunterLockAndLoad(runtimeMember, maelstromCast?.ability ?? selectedCastAbility, now); const castingHastePercent = partyCastingHastePercent(runtimeMember, now); let committed = commitPartyAbilityUseWithRogueCooldown(runtimeMember, castAbility, now); if (!committed) { nextMembers[index] = { ...runtimeMember, status: desiredStatus, nextActionAt: now + 250 }; continue; } applyPartyRomChampionRunePulseOnUse(runtimeMember, castAbility, now); if (castAbility.gcdMs > 0 && castingHastePercent > 0) { committed = { ...committed, globalCooldownEndsAt: now + Math.max(1_000, Math.round( castAbility.gcdMs / (1 + castingHastePercent / 100), )), }; } applyPartyDeathKnightBladeBarrier(runtimeMember, committed, castAbility, now); if (maelstromCast) consumePartyShamanMaelstromWeapon(runtimeMember.id, maelstromCast.auraInstanceId); const committedAfterHunterLockAndLoad = consumesHunterLockAndLoad ? { ...committed, cooldowns: { ...committed.cooldowns, [castAbility.id]: 0 } } : committed; if (consumesHunterLockAndLoad) consumePartyHunterLockAndLoad(runtimeMember.id); const mageAuras = useCombatStore.getState().auras.filter((aura) => aura.targetId === runtimeMember.id); const arcanePotencyBonus = partyMageArcanePotencyBonus( runtimeMember, castAbility, mageAuras.some((aura) => aura.definition.utilityTags?.includes(WOW335_MAGE_ARCANE_POTENCY_TAG)), ); const manaSpent = partyAbilityCosts(runtimeMember, castAbility, now) .filter((cost) => cost.resource === "mana") .reduce((total, cost) => total + cost.amount, 0); if (elementalFocusAura) consumePartyShamanElementalFocus(runtimeMember.id, elementalFocusAura.instanceId); consumePartyMageCastAuras(runtimeMember, castAbility); if (backdraftCast) consumePartyWarlockBackdraftCharge(runtimeMember.id, backdraftCast.auraInstanceId); if (tidalWavesCast) consumePartyShamanTidalWaves( runtimeMember.id, tidalWavesCast.auraInstanceId, ); if (selectedDecision) { emitRotationDecision({ occurredAt: now, actorId: member.id, abilityId: selectedAbility.id, targetId: abilityTargetId, profileId: selectedDecision.profileId, ruleId: selectedDecision.ruleId, reason: selectedDecision.reason, }); } if (castAbility.castTimeMs > 0) { const castTimeMs = backdraftCast ? partyDurationMsWithHaste(backdraftCast.castTimeMs, now, castingHastePercent) : partyCastTimeMsWithHaste(castAbility, now, castingHastePercent); nextMembers[index] = { ...committedAfterHunterLockAndLoad, status: desiredStatus, activeCast: { abilityId: castAbility.id, school: presentationSchoolForAbility(castAbility), mode: castAbility.castMode === "channel" ? "channel" : "cast", startedAt: now, completesAt: now + castTimeMs, originalDurationMs: castTimeMs, pushbackHits: 0, canCastWhileMoving: partyCastCanMoveDuringMeditationPath(runtimeMember, castAbility, now), origin: memberPosition, targetId: abilityTargetId, ...(backdraftCast ? { backdraftGcdMs: backdraftCast.gcdMs } : {}), ...(tidalWavesCast ? { tidalWavesChargesConsumed: tidalWavesCast.chargesConsumed } : {}), ...(maelstromCast ? { maelstromWeaponRank: maelstromCast.rank, maelstromWeaponStacksConsumed: maelstromCast.stacksConsumed, } : {}), ...(arcanePotencyBonus > 0 ? { arcanePotencyCriticalChanceBonusPercent: arcanePotencyBonus } : {}), ...(runtimeMember.classId === "mage" ? { manaSpent } : {}), ...(runtimeMember.classId === "paladin" ? { illuminationBaseManaCost: abilityAtLevel(abilityById(castAbility.id) ?? castAbility, runtimeMember.level).cost.amount } : {}), }, activeCastTargetId: abilityTargetId, nextActionAt: now + castTimeMs, }; applyPartyMageNetherwindPresenceCast(runtimeMember, castAbility, now); emitPartyAbility(member, castAbility, abilityTargetId, "cast-start", now); } else { applyPartyMageNetherwindPresenceCast(runtimeMember, castAbility, now); const execution = executePartyAbility( committedAfterHunterLockAndLoad, castAbility, abilityTargetId, now, nextMembers, 0, false, false, tidalWavesCast?.chargesConsumed ?? 0, arcanePotencyBonus, manaSpent, maelstromCast?.rank ?? 0, maelstromCast?.stacksConsumed ?? 0, { runesBefore: runtimeMember.runes, spentRuneSlots: wow335DeathKnightSpentRuneSlots(runtimeMember.runes, committed.runes, now), }, ); nextMembers[index] = { ...execution.member, status: desiredStatus, nextActionAt: now + Math.max(250, backdraftCast ? backdraftCast.gcdMs : Math.round(castAbility.gcdMs / (raceAffixHasteMultiplier(now) * (1 + castingHastePercent / 100)))), }; } } members = nextMembers; } else { memberTargetIds = {}; const idleStatus = party.command === "stop" ? "holding" as const : party.command === "attack" && objectiveBossId ? "advancing" as const : "ready" as const; members = members.map((member) => { if (member.health <= 0) return member.status === "down" ? member : { ...member, status: "down" }; if ( member.controlledUntil > now && enemyControlPreventsActions(member.controlMechanic) ) { return member.status === "holding" ? member : { ...member, status: "holding" }; } const activeMember = member.controlMechanic && member.controlledUntil <= now ? { ...member, controlledUntil: 0, controlMechanic: null } : member; const recovered = activeMember.health; if (activeMember.role === "healer" && activeMember.nextActionAt > now) { if (recovered === activeMember.health) return activeMember; return { ...activeMember, health: recovered }; } if (recovered === activeMember.health && activeMember.status === idleStatus && activeMember.nextActionAt === 0) return activeMember; return { ...activeMember, health: recovered, status: idleStatus, nextActionAt: 0 }; }); } // Execution actions may have changed health while this low-frequency party // loop was still holding its pre-cast compatibility records. Actor vitals win; // runtime-owned resources, cooldowns, status, and casts remain on the member. const actorsAfterActions = useCombatStore.getState().actors; members = members.map((member) => { const actor = actorsAfterActions[member.id]; const baseline = authoritativeMembers.find((candidate) => candidate.id === member.id); const health = actor?.kind === "companion" && baseline && actor.health !== baseline.health ? actor.health : member.health; return { ...(actor?.kind === "companion" && baseline && actor.health !== baseline.health ? { ...member, health: actor.health, maxHealth: actor.maxHealth } : member), romNativeRecoveryClock: health <= 0 ? undefined : member.romNativeRecoveryClock, }; }); const targetMapChanged = ( Object.keys(party.memberTargetIds).length !== Object.keys(memberTargetIds).length || Object.entries(memberTargetIds).some(([memberId, mobId]) => party.memberTargetIds[memberId] !== mobId) ); if ( membersDiffer(authoritativeMembers, members) || party.activeMobId !== targetId || party.objectiveBossId !== objectiveBossId || targetMapChanged ) { party.commitRuntime({ members, activeMobId: targetId, objectiveBossId, memberTargetIds }); } useCombatStore.getState().commitCompanionActors(members); }